Compare commits

..

8 Commits

Author SHA1 Message Date
Lambda
eaa492297e fix(chat): eliminate unmount+remount flash when streaming completes
Route the in-flight task through the same MessageBubble path as persisted
messages, keyed by task_id. When the persisted assistant message lands,
React reconciles into the same subtree instead of tearing down the live
timeline div and mounting a fresh AssistantMessage — which previously
caused visible scroll jump + content re-render via useAutoScroll's
ResizeObserver/MutationObserver firing on each DOM swap.
2026-04-24 01:57:59 +08:00
Jiayuan Zhang
35aca57939 feat(chat): Chat V2 — sidebar entry + main-area page (#1580)
* feat(chat): Chat V2 — sidebar entry + main-area page

Replace the floating drawer + FAB with a first-class workspace route
`/:slug/chat`. Sidebar gets a single `Chat` entry under Inbox with an
unread dot; session history lives inside the Chat tab via a popover
rather than leaking into the global sidebar (keeps Multica's "nouns in
the nav" semantic — Inbox / Issues / Projects are work objects, Chat is
a tool).

- Add `paths.workspace(slug).chat()` + update link-handler route set.
- New `ChatPage` view with PageHeader, history popover, centered
  messages/composer column, and empty-state starter prompts.
- Delete `ChatWindow`, `ChatFab`, resize helpers, and standalone
  `ChatSessionHistory` (history now embedded in the popover).
- Drop `isOpen`/`toggle`/`showHistory`/resize fields from `useChatStore`
  — the page is a route now, not an overlay.
- Wire the new `/chat` route on web (App Router) and desktop
  (react-router + tab-store icon mapping).

Addresses MUL-1322.

* fix(chat): align composer width with message column

The ChatPage wrapper added px-4 on top of ChatInput's own px-5, making
the composer 32px narrower than the messages column. Drop the outer
px-4 so both share the same max-w-3xl outer + px-5 inner padding
provided by ChatMessageList / ChatInput.

* fix(chat): taller default composer (~3 lines visible, 8 max)

min-h 4rem → 7rem, max-h 10rem → 15rem. Empty state previously
showed only 1 text row after pb-9 for the action bar; raise the
floor so there's visible writing room and lift the ceiling so a
longer draft can grow before scrolling kicks in.

* fix(chat): restore anchor + in-flight indicator + cold-start session restore

Three issues surfaced by review:

1. ContextAnchorButton always disabled on /:slug/chat — useRouteAnchorCandidate
   only matches issue/project/inbox pathnames, so moving chat to its own route
   dropped 'bring the page I was on into the conversation'. Track the last
   anchor-eligible location globally (new useAnchorTracker mounted in AppSidebar
   + lastAnchorLocation on useChatStore) and substitute it when on /chat.

2. No global 'Multica is working' cue after ChatFab deletion. Subscribe the
   sidebar Chat entry to pendingChatTasksOptions and swap the unread dot for a
   spinner while any chat task is in flight.

3. ChatPage restore effect latched didRestoreRef before the sessions query
   resolved, so cold-start direct nav to /chat landed on the empty state even
   when the server had an active session. Wait for isSuccess before locking
   the ref.

* fix(chat): clear lastAnchorLocation on workspace rehydration

The pathname captured in workspace A would otherwise be reused against
workspace B's wsId, triggering a cross-workspace issue/project fetch
and silently leaking anchor context into chat messages.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 01:46:37 +08:00
Bohan Jiang
e0e91fc792 feat(daemon): harden agent mention-loop instructions (#1581)
* feat(daemon): harden agent mention-loop instructions

Two agents that mention each other via `mention://agent/<id>` can fall into
an infinite reply loop — each says "I'm done" in prose but keeps
`@mentioning` the other, which re-enqueues their run. Adding hard caps on
agent-to-agent turns conflicts with Multica's design principle of giving
agents the same authorship freedom as humans, so this change hardens the
instructions that the harness injects instead.

- Replace the terse "mentions are actions" blurb with a full Mentions
  protocol: `side-effecting` warning, explicit "when NOT to mention"
  (replying to another agent, sign-offs, thanks) and "when a mention IS
  appropriate" (human escalation, first-time delegation, user asked).
- Add a pre-workflow decision step for comment-triggered runs: decide
  whether a reply is warranted at all, decide whether to include any
  `@mention`, and clarify that the post-a-comment rule is mandatory *if*
  you reply — silence is a valid exit for agent-to-agent threads.
- Thread the triggering comment's author kind + display name
  (`TriggerAuthorType` / `TriggerAuthorName`) from the claim endpoint
  through the daemon task type, per-turn prompt, and CLAUDE.md workflow.
  When the author is another agent, both surfaces now name that agent
  and warn against sign-off mentions.
- Soften the old closing line that told agents to `always` use the
  mention format — the word generalized to member/agent mentions and
  encouraged the very behavior that causes loops.

Refs GH#1576, MUL-1323.

* fix(daemon): remove MUST-respond conflict and sanitize trigger author name

Addresses two blocking points on PR #1581:

1. buildCommentPrompt told the agent "You MUST respond to THIS comment"
   and unconditionally appended the reply command — directly conflicting
   with the new agent-to-agent silence-as-valid-exit workflow. Models
   were likely to keep following the older must-reply rule and fall back
   into the loop this PR is trying to close.

   Rewrite the header as "Focus on THIS comment — do not confuse it
   with previous ones" (keeps the anti-stale-comment signal) and change
   BuildCommentReplyInstructions to open with "If you decide to reply,
   post it by running exactly this command" so the reply command is
   available but conditional across both prompt surfaces.

2. Raw agent/user display names were being embedded directly into the
   high-priority prompt and CLAUDE.md via TriggerAuthorName. Agent and
   member names are only validated as non-empty at write time, so a
   name containing newlines, backticks, or fake mention markup would
   turn the field into a cross-agent prompt-injection surface.

   Add execenv.SanitizePromptField — strip control runes, collapse
   whitespace, drop markdown structural characters (backtick, asterisk,
   brackets, pipe, angle brackets, hash, backslash), truncate to 64
   runes — and apply it at both embed sites (per-turn prompt and
   CLAUDE.md). Defense-in-depth at the consumption layer so this works
   for already-stored names without a migration.

Tests: TestSanitizePromptField covers the policy; TestBuildPromptSanitizesAgentName
plants an attack payload in TriggerAuthorName and checks the rendered prompt
does not leak the newline-anchored injection or the fake mention markup.
TestBuildPromptCommentTriggered*{,ByMember} updated to lock in the
conditional reply-command framing.

* refactor(daemon): trim redundant CLAUDE.md preamble and drop name sanitizer

Per PR #1581 feedback:

1. Remove the `if ctx.TriggerAuthorType == "agent"` preamble block in
   runtime_config.go. It duplicated what workflow steps 4 and 5 already
   say ("Decide whether a reply is warranted", "Never @mention the
   agent you are replying to as a thank-you or sign-off"), so the
   signal lands the same without the extra ~7 lines of CLAUDE.md. The
   per-turn prompt preamble in prompt.go stays — that surface has no
   numbered workflow below it and would otherwise lose the
   silence-as-exit signal.

2. Delete execenv.SanitizePromptField + its test. Workspace agents are
   created by trusted team members, so the cross-agent name-injection
   surface it defended isn't realistic in the current trust model.

3. Drop TriggerAuthorType/Name from execenv.TaskContextForEnv and stop
   populating them in daemon.go — they're no longer read by the
   execenv package. The same fields on daemon.Task stay because
   prompt.go still needs them to label the triggering author in the
   per-turn prompt.

Tests simplified to match the leaner shape: CLAUDE.md regression
guards now assert that the anti-loop phrases live in the numbered
workflow, and the sanitizer-specific tests are removed.
2026-04-24 01:39:12 +08:00
Jiayuan Zhang
977b0c0558 feat(agents): show profile card on agent avatar hover (#1577)
* feat(agents): show profile card on agent avatar hover

Hovering an agent avatar now opens a preview card with name, status,
runtime mode + connectivity, model, skills, and owner. Wired through
the shared ActorAvatar wrapper so every render site gets it; opt-out
via disableHoverCard in pickers and the agent's own detail header
where the card would be redundant or interfere with click selection.

* fix(agents): keyboard-focusable hover card + opt out on settings avatar

- Make the agent profile-card hover trigger focusable (tabIndex=0 with
  visible focus ring), so keyboard users can open the card. Drops
  cursor-default so the trigger inherits the parent control's cursor
  instead of fighting it.
- Disable the hover card on the agent settings avatar — it's a
  click-to-upload target on the agent's own settings page, where the
  card would be redundant and the trigger conflicted with the upload
  affordance.

* fix(agents): scope hover-card tab stop to standalone avatars only

Detect a focusable ancestor (link/button/role=button/tabindex>=0) at
mount and only flip the agent profile-card trigger to tabIndex=0 when
none exists. Avatars rendered inside an existing focusable parent (issue
list rows wrapped in AppLink, button-style cards, etc.) keep the trigger
unfocusable so they don't add redundant nested tab stops or bloat
keyboard navigation. Standalone avatars (e.g. comment author, issue
detail meta) remain keyboard-accessible with a focus-visible ring.

---------

Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:53:55 +08:00
Black
17136742b9 fix(runtimes): fix dark mode chart visibility and invalid CSS color syntax (#1573)
All chart components used `hsl(var(--chart-X))` but `--chart-X` holds a
full oklch value, not bare HSL components — making the expression invalid
CSS. Browsers silently fell back to black, so bars/areas/heatmap cells were
invisible against the dark background.

- Replace `hsl(var(--chart-X))` with `var(--color-chart-X)` across all
  runtime chart components and the landing feature section
- Fix heatmap opacity using `color-mix(in oklch, ...)` instead of the
  invalid `hsl(var(--chart-3) / 0.3)` syntax; switch to foreground color
  so cells blend with the neutral theme in both light and dark mode
- Raise dark-mode chart-2 through chart-5 lightness values so they
  contrast clearly against the dark background

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 00:47:41 +08:00
Jiayuan Zhang
5e51f5b356 feat(desktop): add right-click context menu with clipboard actions (#1575)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:11:16 +08:00
Jiayuan Zhang
13daede63e docs: remove Star History chart from README (#1574)
Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai>
2026-04-24 00:09:09 +08:00
Bohan Jiang
6107211a6e docs(selfhost): correct WebSocket guidance for LAN access (#1567)
The previous note claimed the frontend's auto-derived WebSocket URL
worked on LAN without extra configuration. It does not: Next.js
`rewrites()` only proxy HTTP requests, so the `Upgrade` handshake
required for WebSocket never reaches the Go backend, and real-time
features (chat streaming, live issue updates, notifications) silently
fail when accessing the app via a non-localhost host.

Replace the incorrect sentence with a dedicated subsection that points
users at the reverse-proxy recipe (already documented above, includes
the correct /ws Upgrade headers) and, for setups without a proxy,
documents the build-time NEXT_PUBLIC_WS_URL + selfhost.build.yml
override path.

Refs: GH #1522
2026-04-23 18:25:02 +08:00
52 changed files with 1176 additions and 1205 deletions

View File

@@ -185,13 +185,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -172,13 +172,3 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -0,0 +1,33 @@
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
// Electron ships with no default right-click menu, so a user selecting text
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
// menu using `roles`, which keeps i18n + accelerator handling native.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable } = params;
const hasSelection = selectionText.trim().length > 0;
const menu = new Menu();
if (isEditable && editFlags.canCut) {
menu.append(new MenuItem({ role: "cut" }));
}
if (hasSelection && editFlags.canCopy) {
menu.append(new MenuItem({ role: "copy" }));
}
if (isEditable && editFlags.canPaste) {
menu.append(new MenuItem({ role: "paste" }));
}
if (isEditable && editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ role: "selectAll" }));
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}

View File

@@ -6,6 +6,7 @@ import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -109,6 +110,8 @@ function createWindow(): void {
return { action: "deny" };
});
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {

View File

@@ -12,7 +12,6 @@ import {
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
@@ -124,11 +123,9 @@ export function DesktopShell() {
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
{/* Content area with inset styling */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</SidebarProvider>

View File

@@ -5,6 +5,7 @@ import {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
X,
Plus,
@@ -39,6 +40,7 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Bot,
Monitor,
BookOpenText,
MessageSquare,
Settings,
};

View File

@@ -115,10 +115,10 @@ export function DesktopNavigationProvider({
const { tabId: activeTabId } = useActiveTabIdentity();
const router = useActiveTabRouter();
// Mirror the active tab router's full location (pathname + search) so
// shell-level consumers of useNavigation() — ChatWindow in particular —
// can read URL search params. Must stay in sync with TabNavigationProvider
// below; a partial shape here (just pathname) silently broke focus-mode
// anchor resolution on `/inbox?issue=…`.
// shell-level consumers of useNavigation() can read URL search params.
// Must stay in sync with TabNavigationProvider below; a partial shape
// here (just pathname) silently broke focus-mode anchor resolution on
// `/inbox?issue=…`.
const [location, setLocation] = useState<{ pathname: string; search: string }>(
() => ({
pathname: router?.state.location.pathname ?? "/",

View File

@@ -17,6 +17,7 @@ import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { ChatPage } from "@multica/views/chat";
import { SettingsPage } from "@multica/views/settings";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
@@ -119,6 +120,7 @@ export const appRoutes: RouteObject[] = [
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{ path: "chat", element: <ChatPage />, handle: { title: "Chat" } },
{
path: "settings",
element: (

View File

@@ -101,6 +101,7 @@ interface TabStore {
const ROUTE_ICONS: Record<string, string> = {
inbox: "Inbox",
chat: "MessageSquare",
"my-issues": "CircleUser",
issues: "ListTodo",
projects: "FolderKanban",

View File

@@ -0,0 +1 @@
export { ChatPage as default } from "@multica/views/chat";

View File

@@ -3,7 +3,6 @@
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -14,8 +13,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
extra={
<>
<SearchCommand />
<ChatWindow />
<ChatFab />
<StarterContentPrompt />
</>
}

View File

@@ -701,14 +701,9 @@ const mockUsageData = USAGE_SEEDS.map((s, i) => ({
/* Heatmap color helper — same as real ActivityHeatmap */
function getHeatmapColor(level: number): string {
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
if (level === 0) return "var(--color-muted)";
const opacities = ["25%", "45%", "68%", "90%"];
return `color-mix(in oklch, var(--color-foreground) ${opacities[level - 1]}, transparent)`;
}
/* Generate heatmap cells — simplified version of real ActivityHeatmap */
@@ -766,7 +761,7 @@ function DailyCostBars({ data }: { data: typeof mockUsageData }) {
width={8}
height={Math.max(h, 2)}
rx={1}
fill="hsl(var(--chart-1))"
fill="var(--color-chart-1)"
/>
);
})}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,4 +1,4 @@
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export { createChatStore, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -11,9 +11,6 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
/** Focus mode is a personal preference — global across workspaces/sessions. */
const FOCUS_MODE_KEY = "multica:chat:focusMode";
@@ -41,11 +38,6 @@ function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
/**
* Kept as a public type because existing consumers (chat-message-list,
* views/chat types) import it. Items themselves no longer live in the
@@ -76,10 +68,8 @@ export interface ContextAnchor {
}
export interface ChatState {
isOpen: boolean;
activeSessionId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/**
@@ -88,22 +78,20 @@ export interface ChatState {
* the preference survives workspace switches and reloads.
*/
focusMode: boolean;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
isExpanded: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
/**
* Last location where a context anchor could be derived (issue/project/inbox).
* Updated globally by useAnchorTracker; used as a fallback for the Chat page
* which is its own route and therefore has no anchor of its own.
* Not persisted — resets per session; focus mode itself persists.
*/
lastAnchorLocation: { pathname: string; search: string } | null;
setActiveSession: (id: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
setFocusMode: (on: boolean) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
setLastAnchorLocation: (loc: { pathname: string; search: string } | null) => void;
}
export interface ChatStoreOptions {
@@ -119,24 +107,12 @@ export function createChatStore(options: ChatStoreOptions) {
};
const store = create<ChatState>((set, get) => ({
isOpen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => {
logger.debug("setOpen", { from: get().isOpen, to: open });
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
set({ isOpen: next });
},
lastAnchorLocation: null,
setLastAnchorLocation: (loc) => set({ lastAnchorLocation: loc }),
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
@@ -151,10 +127,6 @@ export function createChatStore(options: ChatStoreOptions) {
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => {
logger.debug("setShowHistory", { to: show });
set({ showHistory: show });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
@@ -180,23 +152,6 @@ export function createChatStore(options: ChatStoreOptions) {
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
logger.info("setExpanded", { to: expanded });
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
}
set({ isExpanded: expanded });
},
}));
registerForWorkspaceRehydration(() => {
@@ -210,10 +165,15 @@ export function createChatStore(options: ChatStoreOptions) {
nextAgent,
draftCount: Object.keys(nextDrafts).length,
});
// lastAnchorLocation is not persisted — reset it here so a pathname
// captured in the previous workspace can't be reused against the new
// workspace's wsId (would trigger a cross-workspace issue/project fetch
// and silently leak context into chat messages).
store.setState({
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
lastAnchorLocation: null,
});
});

View File

@@ -22,6 +22,7 @@ describe("paths.workspace() shape", () => {
"autopilots",
"agents",
"inbox",
"chat",
"myIssues",
"runtimes",
"skills",
@@ -40,6 +41,7 @@ describe("paths.workspace() shape", () => {
["autopilots", "autopilots"],
["agents", "agents"],
["inbox", "inbox"],
["chat", "chat"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],

View File

@@ -26,6 +26,7 @@ function workspaceScoped(slug: string) {
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
inbox: () => `${ws}/inbox`,
chat: () => `${ws}/chat`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,
skills: () => `${ws}/skills`,

View File

@@ -362,7 +362,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
});
// --- Chat / task events (global, survives ChatWindow unmount) ---
// --- Chat / task events (global, survives chat page unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
// earlier mirror caused a race where the cache and store disagreed

View File

@@ -115,10 +115,10 @@
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--chart-2: oklch(0.75 0.016 285.938);
--chart-3: oklch(0.62 0.017 285.786);
--chart-4: oklch(0.52 0.013 285.805);
--chart-5: oklch(0.42 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);

View File

@@ -92,7 +92,7 @@ export function AgentDetail({
{/* Header */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} disableHoverCard />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>

View File

@@ -24,7 +24,7 @@ export function AgentListItem({
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} disableHoverCard />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,193 @@
"use client";
import { Cloud, Monitor, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, AgentRuntime } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { statusConfig } from "../config";
import { formatLastSeen } from "../../runtimes/utils";
interface AgentProfileCardProps {
agentId: string;
}
export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
const wsId = useWorkspaceId();
const { data: agents = [], isLoading: agentsLoading } = useQuery(agentListOptions(wsId));
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const agent = agents.find((a) => a.id === agentId);
if (agentsLoading && !agent) {
return (
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
);
}
if (!agent) {
return (
<div className="text-xs text-muted-foreground">Agent unavailable</div>
);
}
const runtime = runtimes.find((r) => r.id === agent.runtime_id) ?? null;
const owner = agent.owner_id
? members.find((m) => m.user_id === agent.owner_id) ?? null
: null;
const isArchived = !!agent.archived_at;
const initials = agent.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<div className="flex flex-col gap-3 text-left">
{/* Header */}
<div className="flex items-start gap-3">
<ActorAvatarBase
name={agent.name}
initials={initials}
avatarUrl={agent.avatar_url}
isAgent
size={40}
className="rounded-md"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-semibold">{agent.name}</p>
{isArchived && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Archived
</span>
)}
</div>
<AgentStatusLine agent={agent} />
</div>
</div>
{/* Description */}
{agent.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{agent.description}
</p>
)}
{/* Meta rows */}
<div className="flex flex-col gap-1.5 text-xs">
<RuntimeRow agent={agent} runtime={runtime} />
{agent.model && <MetaRow label="Model" value={agent.model} mono />}
{agent.skills.length > 0 && (
<SkillsRow skills={agent.skills.map((s) => s.name)} />
)}
{owner && <MetaRow label="Owner" value={owner.name} />}
</div>
</div>
);
}
function AgentStatusLine({ agent }: { agent: Agent }) {
const st = statusConfig[agent.status];
return (
<div className="mt-0.5 flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
<span className={`text-xs ${st.color}`}>{st.label}</span>
</div>
);
}
function RuntimeRow({
agent,
runtime,
}: {
agent: Agent;
runtime: AgentRuntime | null;
}) {
const isCloud = agent.runtime_mode === "cloud";
const Icon = isCloud ? Cloud : Monitor;
const isOnline = runtime?.status === "online";
// Cloud runtimes are always reachable from the user's perspective.
const showOnline = isCloud || isOnline;
let detail: string;
if (isCloud) {
detail = runtime?.name ?? "Cloud";
} else if (runtime) {
detail = isOnline
? runtime.name
: `${runtime.name} · last seen ${formatLastSeen(runtime.last_seen_at)}`;
} else {
detail = "Unknown runtime";
}
return (
<div className="flex items-center gap-1.5">
<span className="w-12 shrink-0 text-muted-foreground">Runtime</span>
<Icon className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate" title={detail}>
{detail}
</span>
{showOnline ? (
<Wifi className="ml-auto h-3 w-3 shrink-0 text-success" />
) : (
<WifiOff className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" />
)}
</div>
);
}
function MetaRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-center gap-1.5">
<span className="w-12 shrink-0 text-muted-foreground">{label}</span>
<span className={`truncate ${mono ? "font-mono text-[11px]" : ""}`} title={value}>
{value}
</span>
</div>
);
}
function SkillsRow({ skills }: { skills: string[] }) {
const visible = skills.slice(0, 3);
const overflow = skills.length - visible.length;
return (
<div className="flex items-center gap-1.5">
<span className="w-12 shrink-0 text-muted-foreground">Skills</span>
<div className="flex min-w-0 flex-wrap gap-1">
{visible.map((s) => (
<span
key={s}
className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{s}
</span>
))}
{overflow > 0 && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
+{overflow}
</span>
)}
</div>
</div>
);
}

View File

@@ -130,7 +130,7 @@ export function SettingsTab({
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" />
<ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" disableHoverCard />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />

View File

@@ -52,7 +52,7 @@ export function AgentPicker({
<>
{selected ? (
<>
<ActorAvatar actorType="agent" actorId={selected.id} size={16} />
<ActorAvatar actorType="agent" actorId={selected.id} size={16} disableHoverCard />
<span className="truncate">{selected.name}</span>
</>
) : (
@@ -77,7 +77,7 @@ export function AgentPicker({
setOpen(false);
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
<ActorAvatar actorType="agent" actorId={a.id} size={16} disableHoverCard />
<span className="truncate">{a.name}</span>
</PickerItem>
))

View File

@@ -1,63 +0,0 @@
"use client";
import { MessageCircle } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { useChatStore } from "@multica/core/chat";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { createLogger } from "@multica/core/logger";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
const logger = createLogger("chat.ui");
export function ChatFab() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const toggle = useChatStore((s) => s.toggle);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
if (isOpen) return null;
const unreadSessionCount = sessions.filter((s) => s.has_unread).length;
const isRunning = (pending?.tasks ?? []).length > 0;
const handleClick = () => {
logger.info("fab.click (open chat)", { unreadSessionCount, isRunning });
toggle();
};
// Tooltip text communicates the state that isn't carried by the icon/badge.
const tooltip = isRunning
? "Multica is working..."
: unreadSessionCount > 0
? `${unreadSessionCount} unread ${unreadSessionCount === 1 ? "chat" : "chats"}`
: "Ask Multica";
return (
<Tooltip>
<TooltipTrigger
onClick={handleClick}
className={cn(
"absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95",
// Impulse the button itself while a chat task is running — no
// outer ring to keep things calm.
isRunning && "animate-chat-impulse",
)}
>
<MessageCircle className="size-5" />
{unreadSessionCount > 0 && (
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex min-w-4 h-4 items-center justify-center rounded-full bg-brand px-1 text-xs font-semibold leading-none text-background">
{unreadSessionCount > 9 ? "9+" : unreadSessionCount}
</span>
)}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
</Tooltip>
);
}

View File

@@ -81,7 +81,7 @@ export function ChatInput({
return (
<div className="px-5 pb-3 pt-0">
<div className="relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
<div className="relative mx-auto flex min-h-28 max-h-60 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand">
{topSlot}
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useMemo, useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
@@ -35,23 +35,30 @@ export function ChatMessageList({
const fadeStyle = useScrollFade(scrollRef);
useAutoScroll(scrollRef);
// Once the assistant message for this pending task has landed in the
// messages list, AssistantMessage owns its rendering — suppress the live
// timeline to avoid rendering the same content in two places during the
// invalidate → refetch window.
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
);
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
// current via setQueryData on task:message events.
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
const { data: liveTaskMessages } = useQuery({
...taskMessagesOptions(pendingTaskId ?? ""),
enabled: showLiveTimeline,
});
const liveTimeline: ChatTimelineItem[] = (liveTaskMessages ?? []).map(toTimelineItem);
const hasLive = showLiveTimeline && liveTimeline.length > 0;
// Synthesize a virtual assistant message for the in-flight task so it flows
// through the same MessageBubble path as persisted messages. Keying by
// `task-<id>` means the persisted message that replaces it reconciles into
// the same DOM subtree instead of unmounting + remounting — which caused a
// visible jump + re-render when streaming finished.
const items = useMemo<ChatMessage[]>(() => {
if (!pendingTaskId || pendingAlreadyPersisted) return messages;
const pending: ChatMessage = {
id: `pending-${pendingTaskId}`,
chat_session_id: "",
role: "assistant",
content: "",
task_id: pendingTaskId,
created_at: new Date().toISOString(),
};
return [...messages, pending];
}, [messages, pendingTaskId, pendingAlreadyPersisted]);
const showWaitingSpinner =
isWaiting && !pendingTaskId && !pendingAlreadyPersisted;
return (
<div ref={scrollRef} style={fadeStyle} className="flex-1 overflow-y-auto">
@@ -60,15 +67,10 @@ export function ChatMessageList({
* views doesn't jolt the reading width. px-5 is a touch tighter
* than issue-detail's px-8 because the chat window can be narrow. */}
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-4">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
{items.map((msg) => (
<MessageBubble key={bubbleKey(msg)} message={msg} />
))}
{hasLive && (
<div className="w-full space-y-1.5">
<TimelineView items={liveTimeline} />
</div>
)}
{isWaiting && !hasLive && !pendingAlreadyPersisted && (
{showWaitingSpinner && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
</div>
@@ -76,6 +78,13 @@ export function ChatMessageList({
);
}
function bubbleKey(msg: ChatMessage): string {
// Keying assistant messages by task_id lets a virtual "pending" entry and
// the persisted message that replaces it share React identity.
if (msg.role === "assistant" && msg.task_id) return `task-${msg.task_id}`;
return msg.id;
}
/**
* Placeholder shown while `chat_message` for a session is being fetched
* (initial refresh, or switching to an un-cached session). Shape roughly

View File

@@ -0,0 +1,513 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { History, Plus, Bot, ChevronDown, Check } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@multica/ui/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
chatKeys,
} from "@multica/core/chat/queries";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { PageHeader } from "../../layout/page-header";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
useRouteAnchorCandidate,
} from "./context-anchor";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
export function ChatPage() {
const wsId = useWorkspaceId();
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery(
chatSessionsOptions(wsId),
);
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
const messages = activeSessionId ? rawMessages ?? [] : [];
const showSkeleton = !!activeSessionId && messagesLoading;
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const pendingTaskId = pendingTask?.task_id ?? null;
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const availableAgents = agents.filter(
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Restore most recent active session once the session query resolves.
// The ref is set only AFTER we've seen a successful query — setting it
// unconditionally on first render would lose the restore whenever the
// page mounts before the query returns (cold-start / direct navigate).
const didRestoreRef = useRef(false);
useEffect(() => {
if (didRestoreRef.current) return;
if (!sessionsLoaded) return;
didRestoreRef.current = true;
if (activeSessionId) return;
const latest = sessions.find((s) => s.status === "active");
if (latest) setActiveSession(latest.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
}, [sessionsLoaded, sessions]);
// Auto mark-as-read whenever the viewer is on a session with unread.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!activeSessionId || !currentHasUnread) return;
markRead.mutate(activeSessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [activeSessionId, currentHasUnread]);
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
let sessionId = activeSessionId;
const isNewSession = !sessionId;
apiLogger.info("sendChatMessage.start", {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: finalContent.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
const result = await api.sendChatMessage(sessionId, finalContent);
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[activeSessionId, activeAgent, anchorCandidate, createSession, setActiveSession, qc],
);
const handleStop = useCallback(async () => {
if (!pendingTaskId) return;
try {
await api.cancelTaskById(pendingTaskId);
} catch (err) {
apiLogger.warn("cancelTask.error", { taskId: pendingTaskId, err });
}
if (activeSessionId) {
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
}
}, [pendingTaskId, activeSessionId, qc]);
const handleSelectAgent = useCallback(
(agent: Agent) => {
if (activeAgent && agent.id === activeAgent.id) return;
uiLogger.info("selectAgent", { from: selectedAgentId, to: agent.id });
setSelectedAgentId(agent.id);
setActiveSession(null);
},
[activeAgent, selectedAgentId, setSelectedAgentId, setActiveSession],
);
const handleNewChat = useCallback(() => {
setActiveSession(null);
}, [setActiveSession]);
const handleSelectSession = useCallback(
(session: ChatSession) => {
if (activeAgent && session.agent_id !== activeAgent.id) {
setSelectedAgentId(session.agent_id);
}
setActiveSession(session.id);
},
[activeAgent, setSelectedAgentId, setActiveSession],
);
const hasMessages = messages.length > 0 || !!pendingTaskId;
const activeTitle = currentSession?.title?.trim() || "New chat";
return (
<div className="flex h-full min-h-0 flex-1 flex-col">
<PageHeader className="gap-2">
<span className="text-sm font-medium truncate">{activeTitle}</span>
<div className="ml-auto flex items-center gap-1">
<HistoryPopover
sessions={allSessions}
agents={agents}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleNewChat}
aria-label="New chat"
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
</div>
</PageHeader>
{/* Body — centered max-width column */}
<div className="relative flex min-h-0 flex-1 flex-col">
{showSkeleton ? (
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col">
<ChatMessageSkeleton />
</div>
) : hasMessages ? (
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col min-h-0">
<ChatMessageList
messages={messages}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
</div>
) : (
<EmptyState agentName={activeAgent?.name} onPickPrompt={handleSend} />
)}
<div
className={cn(
"mx-auto w-full max-w-3xl pb-4",
hasMessages ? "" : "pb-8",
)}
>
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
</div>
</div>
);
}
/**
* Popover-based history list. Per product direction, session history lives
* inside the Chat tab — not in the global sidebar — so that Multica doesn't
* read as "just another chat app." The trigger is a History icon in the
* page header.
*/
function HistoryPopover({
sessions,
agents,
activeSessionId,
onSelectSession,
}: {
sessions: ChatSession[];
agents: Agent[];
activeSessionId: string | null;
onSelectSession: (session: ChatSession) => void;
}) {
const [open, setOpen] = React.useState(false);
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="History"
/>
}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="bottom">History</TooltipContent>
</Tooltip>
<PopoverContent align="end" sideOffset={6} className="w-80 p-0">
<div className="px-3 py-2 border-b">
<span className="text-xs font-medium text-muted-foreground">History</span>
</div>
<div className="max-h-96 overflow-y-auto">
{sessions.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No previous chats
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
return (
<button
key={session.id}
type="button"
onClick={() => {
onSelectSession(session);
setOpen(false);
}}
className={cn(
"flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-accent/60",
isCurrent && "bg-accent/40",
)}
>
<AgentAvatarSmall agent={agent} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{session.title?.trim() || "New chat"}
</div>
<div className="truncate text-xs text-muted-foreground">
{agent?.name ?? "Unknown agent"}
</div>
</div>
{session.has_unread && (
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && (
<Check className="mt-1 size-3.5 shrink-0 text-muted-foreground" />
)}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
function AgentDropdown({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
const { mine, others } = useMemo(() => {
const mine: Agent[] = [];
const others: Agent[] = [];
for (const a of agents) {
if (a.owner_id === userId) mine.push(a);
else others.push(a);
}
return { mine, others };
}, [agents, userId]);
if (!activeAgent) {
return <span className="text-xs text-muted-foreground">No agents</span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My agents</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentMenuItem({
agent,
isCurrent,
onSelect,
}: {
agent: Agent;
isCurrent: boolean;
onSelect: (agent: Agent) => void;
}) {
return (
<DropdownMenuItem
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
}
function AgentAvatarSmall({ agent }: { agent: Agent | null }) {
return (
<Avatar className="size-6 shrink-0">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3.5" />
</AvatarFallback>
</Avatar>
);
}
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open tasks by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Plan what to work on next" },
];
function EmptyState({
agentName,
onPickPrompt,
}: {
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-6 px-6 py-12">
<div className="text-center space-y-1">
<h3 className="text-xl font-semibold">
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
</h3>
<p className="text-sm text-muted-foreground">How can I help?</p>
</div>
<div className="w-full max-w-md space-y-2">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt.text}
type="button"
onClick={() => onPickPrompt(prompt.text)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
>
<span className="mr-2">{prompt.icon}</span>
{prompt.text}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
"use client";
import React from "react";
type DragDir = "left" | "top" | "corner";
interface ChatResizeHandlesProps {
onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
}
export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
return (
<>
{/* Left edge — expands width when dragged left */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "left")}
className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
/>
{/* Top edge — expands height when dragged up */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "top")}
className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
/>
{/* Top-left corner — expands both width and height */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "corner")}
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
/>
</>
);
}

View File

@@ -1,148 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useChatStore } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import type { ChatSession, Agent } from "@multica/core/types";
const logger = createLogger("chat.ui");
export function ChatSessionHistory() {
const wsId = useWorkspaceId();
const setShowHistory = useChatStore((s) => s.setShowHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const agentMap = new Map(agents.map((a) => [a.id, a]));
const handleSelectSession = (session: ChatSession) => {
logger.info("selectSession", {
from: activeSessionId,
to: session.id,
agentId: session.agent_id,
status: session.status,
});
// Changing activeSessionId flips the query keys for messages +
// pending-task; no manual clear needed.
setActiveSession(session.id);
setShowHistory(false);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(false)}
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipContent side="bottom">Back</TooltipContent>
</Tooltip>
<span className="text-sm font-medium">Chat History</span>
</div>
{/* Session list */}
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<MessageSquare className="size-6" />
<span className="text-sm">No chat sessions yet</span>
</div>
) : (
<div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => handleSelectSession(session)}
/>
))}
</div>
)}
</div>
</div>
);
}
function SessionItem({
session,
agent,
isActive,
onSelect,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
}) {
const timeAgo = formatTimeAgo(session.updated_at);
return (
<button
onClick={onSelect}
className={cn(
"flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{session.title || "Untitled"}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{agent && (
<span className="text-xs text-muted-foreground truncate">
{agent.name}
</span>
)}
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
</div>
</div>
</button>
);
}
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

View File

@@ -1,648 +0,0 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, ChevronDown, Bot, Plus, Check } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
import { useAuthStore } from "@multica/core/auth";
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
import { canAssignAgent } from "@multica/views/issues/components";
import { api } from "@multica/core/api";
import {
chatSessionsOptions,
allChatSessionsOptions,
chatMessagesOptions,
pendingChatTaskOptions,
chatKeys,
} from "@multica/core/chat/queries";
import { useCreateChatSession, useMarkChatSessionRead } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import {
ContextAnchorButton,
ContextAnchorCard,
buildAnchorMarkdown,
useRouteAnchorCandidate,
} from "./context-anchor";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: allSessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: rawMessages, isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId ?? ""),
);
// When no active session, always show empty — don't use stale cache
const messages = activeSessionId ? rawMessages ?? [] : [];
// Skeleton only shows for an un-cached session fetch. Cached switches
// return data synchronously — no flash. `enabled: false` (new chat)
// keeps isLoading false so the starter prompts aren't hidden.
const showSkeleton = !!activeSessionId && messagesLoading;
// Server-authoritative pending task. Survives refresh / reopen / session
// switch because it's keyed on sessionId in the Query cache; WS events
// (chat:message / chat:done / task:*) keep it invalidated in real time.
//
// This is the SOLE source for pendingTaskId — no mirror in the store.
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId ?? ""),
);
const pendingTaskId = pendingTask?.task_id ?? null;
// Check if current session is archived
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const availableAgents = agents.filter(
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
// Resolve selected agent: stored preference → first available
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
// fires on layout mount (login / workspace switch / fresh page load).
useEffect(() => {
uiLogger.info("ChatWindow mount", {
isOpen,
activeSessionId,
pendingTaskId,
selectedAgentId,
wsId,
});
return () => {
uiLogger.info("ChatWindow unmount", {
activeSessionId,
pendingTaskId,
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
}, []);
// Auto-restore most recent active session from server (only once on mount)
const didRestoreRef = useRef(false);
useEffect(() => {
if (didRestoreRef.current) return;
didRestoreRef.current = true;
if (activeSessionId || sessions.length === 0) {
uiLogger.debug("restore session skipped", {
reason: activeSessionId ? "already has session" : "no sessions",
activeSessionId,
sessionCount: sessions.length,
});
return;
}
const latest = sessions.find((s) => s.status === "active");
if (latest) {
uiLogger.info("restore session on mount", { sessionId: latest.id });
setActiveSession(latest.id);
} else {
uiLogger.debug("restore session: no active session found");
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once when sessions load
}, [sessions]);
// WS events are handled globally in useRealtimeSync — the query cache
// stays current even when this window is closed. See packages/core/realtime/.
// Auto mark-as-read whenever the user is looking at a session with unread
// state: window open + a session active + has_unread → PATCH.
// has_unread comes from the list query; WS handlers invalidate it on
// chat:done so a reply arriving while the user watches triggers this
// effect again and is instantly cleared.
const currentHasUnread =
sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
useEffect(() => {
if (!isOpen || !activeSessionId) return;
if (!currentHasUnread) return;
uiLogger.info("auto markRead", { sessionId: activeSessionId });
markRead.mutate(activeSessionId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable
}, [isOpen, activeSessionId, currentHasUnread]);
// Focus-mode anchor: derived from route each render. Prepended to the
// outgoing message when focus is on; the anchor persists across sends
// (focus mode tracks the user's page, not a per-message attachment).
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
const handleSend = useCallback(
async (content: string) => {
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
const focusOn = useChatStore.getState().focusMode;
const finalContent = focusOn && anchorCandidate
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
: content;
let sessionId = activeSessionId;
const isNewSession = !sessionId;
apiLogger.info("sendChatMessage.start", {
sessionId,
isNewSession,
agentId: activeAgent.id,
contentLength: finalContent.length,
hasAnchor: focusOn && !!anchorCandidate,
});
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: finalContent.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: finalContent,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, finalContent);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[
activeSessionId,
activeAgent,
anchorCandidate,
createSession,
setActiveSession,
qc,
],
);
const handleStop = useCallback(async () => {
if (!pendingTaskId) {
apiLogger.debug("cancelTask skipped: no pending task");
return;
}
apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId });
try {
await api.cancelTaskById(pendingTaskId);
apiLogger.info("cancelTask.success", { taskId: pendingTaskId });
} catch (err) {
// Task may already be completed
apiLogger.warn("cancelTask.error (task may have already finished)", { taskId: pendingTaskId, err });
}
if (activeSessionId) {
// Clear pending immediately; WS task:cancelled will confirm.
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) });
}
}, [pendingTaskId, activeSessionId, qc]);
const handleSelectAgent = useCallback(
(agent: Agent) => {
// No-op when clicking the already-active agent — don't clobber the
// current session just because the user closed the menu this way.
// Compare against activeAgent (what the UI shows), not selectedAgentId
// (which may be null / point to an archived agent on first load).
if (activeAgent && agent.id === activeAgent.id) return;
uiLogger.info("selectAgent", {
from: selectedAgentId,
to: agent.id,
previousSessionId: activeSessionId,
});
setSelectedAgentId(agent.id);
// Reset session when switching agent
setActiveSession(null);
},
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
);
const handleNewChat = useCallback(() => {
uiLogger.info("newChat", {
previousSessionId: activeSessionId,
previousPendingTask: pendingTaskId,
});
setActiveSession(null);
}, [activeSessionId, pendingTaskId, setActiveSession]);
const handleSelectSession = useCallback(
(session: ChatSession) => {
// Sessions are bound 1:1 to an agent — picking a session from a
// different agent implicitly switches the agent too.
if (activeAgent && session.agent_id !== activeAgent.id) {
uiLogger.info("selectSession (cross-agent)", {
from: activeAgent.id,
toAgent: session.agent_id,
toSession: session.id,
});
setSelectedAgentId(session.agent_id);
}
setActiveSession(session.id);
},
[activeAgent, setSelectedAgentId, setActiveSession],
);
const handleMinimize = useCallback(() => {
uiLogger.info("minimize (close)", {
activeSessionId,
pendingTaskId,
});
setOpen(false);
}, [activeSessionId, pendingTaskId, setOpen]);
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
// Show the list (vs empty state) as soon as there's anything to display —
// a real message, or a pending task whose timeline will stream in.
const hasMessages = messages.length > 0 || !!pendingTaskId;
const isVisible = isOpen && boundsReady;
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
return (
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header — ⊕ new + session dropdown | window tools */}
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
<div className="flex items-center gap-1 min-w-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={handleNewChat}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="top">New chat</TooltipContent>
</Tooltip>
<SessionDropdown
sessions={sessions}
// Use the full agent list (incl. archived) so historical
// sessions can still resolve their avatar.
agents={agents}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
/>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleMinimize}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="top">Minimize</TooltipContent>
</Tooltip>
</div>
</div>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
messages={messages}
pendingTaskId={pendingTaskId}
isWaiting={!!pendingTaskId}
/>
) : (
<EmptyState
agentName={activeAgent?.name}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived}
agentName={activeAgent?.name}
topSlot={<ContextAnchorCard />}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
);
}
/**
* Agent dropdown: avatar trigger, lists all available agents. Selecting a
* different agent = switch agent + start a fresh chat (session=null).
* The current agent is marked with a check and not clickable.
*/
function AgentDropdown({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
// Split into the user's own agents and everyone else so the menu groups
// them — matches the old AgentSelector layout.
const { mine, others } = useMemo(() => {
const mine: Agent[] = [];
const others: Agent[] = [];
for (const a of agents) {
if (a.owner_id === userId) mine.push(a);
else others.push(a);
}
return { mine, others };
}, [agents, userId]);
if (!activeAgent) {
return <span className="text-xs text-muted-foreground">No agents</span>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="max-h-80 w-auto max-w-64">
{mine.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My agents</DropdownMenuLabel>
{mine.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
{mine.length > 0 && others.length > 0 && <DropdownMenuSeparator />}
{others.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{others.map((agent) => (
<AgentMenuItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={onSelect}
/>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentMenuItem({
agent,
isCurrent,
onSelect,
}: {
agent: Agent;
isCurrent: boolean;
onSelect: (agent: Agent) => void;
}) {
return (
<DropdownMenuItem
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate flex-1">{agent.name}</span>
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
}
/**
* Session dropdown: lists ALL sessions across agents. Each row carries the
* owning agent's avatar so the user can tell them apart. Selecting a
* session from a different agent implicitly switches the agent too
* (sessions are bound 1:1 to an agent). "New chat" lives in the header's
* ⊕ button, not inside this dropdown.
*/
function SessionDropdown({
sessions,
agents,
activeSessionId,
onSelectSession,
}: {
sessions: ChatSession[];
agents: Agent[];
activeSessionId: string | null;
onSelectSession: (session: ChatSession) => void;
}) {
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const activeSession = sessions.find((s) => s.id === activeSessionId);
const title = activeSession?.title?.trim() || "New chat";
const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && <AgentAvatarSmall agent={triggerAgent} />}
<span className="truncate text-sm font-medium">{title}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-80 w-auto min-w-56 max-w-80">
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No previous chats
</div>
) : (
sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
const agent = agentById.get(session.agent_id) ?? null;
return (
<DropdownMenuItem
key={session.id}
onClick={() => onSelectSession(session)}
className="flex min-w-0 items-center gap-2"
>
{agent ? (
<AgentAvatarSmall agent={agent} />
) : (
<span className="size-6 shrink-0" />
)}
<span className="truncate flex-1 text-sm">
{session.title?.trim() || "New chat"}
</span>
{session.has_unread && (
<span className="size-1.5 shrink-0 rounded-full bg-brand" />
)}
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function AgentAvatarSmall({ agent }: { agent: Agent }) {
return (
<Avatar className="size-6">
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3.5" />
</AvatarFallback>
</Avatar>
);
}
/**
* Three starter prompts shown on the empty state. Tapping one sends it
* immediately — ChatGPT-style — because the point is showing users what
* this chat is for: operating on the workspace, not open-ended Q&A.
*/
const STARTER_PROMPTS: { icon: string; text: string }[] = [
{ icon: "📋", text: "List my open tasks by priority" },
{ icon: "📝", text: "Summarize what I did today" },
{ icon: "💡", text: "Plan what to work on next" },
];
function EmptyState({
agentName,
onPickPrompt,
}: {
agentName?: string;
onPickPrompt: (text: string) => void;
}) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">
<h3 className="text-base font-semibold">
{agentName ? `Hi, I'm ${agentName}` : "Welcome to Multica"}
</h3>
<p className="text-sm text-muted-foreground">Try asking</p>
</div>
<div className="w-full max-w-xs space-y-2">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt.text}
type="button"
onClick={() => onPickPrompt(prompt.text)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
>
<span className="mr-2">{prompt.icon}</span>
{prompt.text}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Focus } from "lucide-react";
import type { ContextAnchor } from "@multica/core/chat";
@@ -33,11 +34,42 @@ export function buildAnchorMarkdown(anchor: ContextAnchor): string {
return `Context: Project "${anchor.label}"`;
}
/**
* Returns true when the given pathname can resolve to an anchor candidate
* (issue detail, project detail, or inbox). Used by both the resolver and
* the tracker so they agree on which routes are anchor-eligible.
*/
function isAnchorEligiblePath(pathname: string): boolean {
if (/^\/[^/]+\/issues\/[^/]+$/.test(pathname)) return true;
if (/^\/[^/]+\/projects\/[^/]+$/.test(pathname)) return true;
if (/^\/[^/]+\/inbox$/.test(pathname)) return true;
return false;
}
/**
* Runs an effect that remembers the last anchor-eligible location the user
* visited. Mount this in a component that's present on every page (the app
* sidebar) so the chat page — which is its own route and therefore has no
* anchor of its own — can still know what the user was just looking at.
*/
export function useAnchorTracker(): void {
const { pathname, searchParams } = useNavigation();
const setLastAnchorLocation = useChatStore((s) => s.setLastAnchorLocation);
useEffect(() => {
if (!isAnchorEligiblePath(pathname)) return;
setLastAnchorLocation({ pathname, search: searchParams.toString() });
}, [pathname, searchParams, setLastAnchorLocation]);
}
/**
* Resolve the current page into an anchorable candidate, or null if the user
* is somewhere without a natural focus object. Subscribes via react-query so
* the result updates the instant the relevant cache fills.
*
* When the user is on the Chat route (no intrinsic anchor), falls back to
* the last anchor-eligible location remembered by `useAnchorTracker`, so
* "open Chat from an issue → focus mode still attaches that issue" works.
*
* `wsId` is passed in (per CLAUDE.md convention) so this hook works outside
* a WorkspaceIdProvider if ever reused elsewhere.
*/
@@ -46,10 +78,20 @@ export function useRouteAnchorCandidate(wsId: string): {
isResolving: boolean;
} {
const { pathname, searchParams } = useNavigation();
const lastAnchorLocation = useChatStore((s) => s.lastAnchorLocation);
const issueMatch = pathname.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(pathname);
// On the Chat route there's no intrinsic anchor; substitute the last
// anchor-eligible location the user visited. Anywhere else, use the
// live route directly.
const useFallback = !isAnchorEligiblePath(pathname) && !!lastAnchorLocation;
const effectivePath = useFallback ? lastAnchorLocation!.pathname : pathname;
const effectiveSearch = useFallback
? new URLSearchParams(lastAnchorLocation!.search)
: searchParams;
const issueMatch = effectivePath.match(/^\/[^/]+\/issues\/([^/]+)$/);
const projectMatch = effectivePath.match(/^\/[^/]+\/projects\/([^/]+)$/);
const isInbox = /^\/[^/]+\/inbox$/.test(effectivePath);
const routeIssueId = issueMatch ? decodeURIComponent(issueMatch[1]!) : null;
const routeProjectId = projectMatch
@@ -61,7 +103,7 @@ export function useRouteAnchorCandidate(wsId: string): {
...inboxListOptions(wsId),
enabled: isInbox,
});
const inboxKey = isInbox ? searchParams.get("issue") : null;
const inboxKey = isInbox ? effectiveSearch.get("issue") : null;
const inboxSelectedIssueId =
isInbox && inboxKey
? inboxItems.find((i) => (i.issue_id ?? i.id) === inboxKey)?.issue_id ??

View File

@@ -1,140 +0,0 @@
"use client";
import React, { useRef, useCallback, useState, useEffect } from "react";
import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
type DragDir = "left" | "top" | "corner";
const MAX_RATIO = 0.9;
const FALLBACK_MAX_W = 800;
const FALLBACK_MAX_H = 700;
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
export function useChatResize(
windowRef: React.RefObject<HTMLDivElement | null>,
) {
const chatWidth = useChatStore((s) => s.chatWidth);
const chatHeight = useChatStore((s) => s.chatHeight);
const isExpanded = useChatStore((s) => s.isExpanded);
const setChatSize = useChatStore((s) => s.setChatSize);
const setExpanded = useChatStore((s) => s.setExpanded);
// ── Container bounds via ResizeObserver ────────────────────────────────
const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
const [boundsReady, setBoundsReady] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [, setRevision] = useState(0);
useEffect(() => {
const el = windowRef.current;
const parent = el?.parentElement;
if (!parent) return;
const update = () => {
const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
setBoundsReady(true); // idempotent once true
// Only trigger a re-render if the bounds actually changed. Without this
// guard, any spurious ResizeObserver notification (including sub-pixel
// layout jitter during mount) schedules a setState that feeds back into
// the observer, producing "Maximum update depth exceeded".
const prev = boundsRef.current;
if (prev.maxW === maxW && prev.maxH === maxH) return;
boundsRef.current = { maxW, maxH };
setRevision((r) => r + 1);
};
// Measure immediately (parent is already in DOM at this point)
update();
const ro = new ResizeObserver(update);
ro.observe(parent);
return () => ro.disconnect();
}, [windowRef]);
// ── Derive rendered size ──────────────────────────────────────────────
const { maxW, maxH } = boundsRef.current;
const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
// ── Expand / Restore ──────────────────────────────────────────────────
const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
const toggleExpand = useCallback(() => {
if (isExpanded || isAtMax) {
setChatSize(CHAT_MIN_W, CHAT_MIN_H);
} else {
setExpanded(true);
}
}, [isExpanded, isAtMax, setChatSize, setExpanded]);
// ── Drag ──────────────────────────────────────────────────────────────
const dragRef = useRef<{
startX: number;
startY: number;
startW: number;
startH: number;
dir: DragDir;
} | null>(null);
const startDrag = useCallback(
(e: React.PointerEvent, dir: DragDir) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startW: renderWidth,
startH: renderHeight,
dir,
};
setIsDragging(true);
const onPointerMove = (ev: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const { maxW: mw, maxH: mh } = boundsRef.current;
const rawW =
dir === "left" || dir === "corner"
? d.startW - (ev.clientX - d.startX)
: d.startW;
const rawH =
dir === "top" || dir === "corner"
? d.startH - (ev.clientY - d.startY)
: d.startH;
setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
};
const onPointerUp = () => {
dragRef.current = null;
setIsDragging(false);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
const cursorMap: Record<DragDir, string> = {
left: "col-resize",
top: "row-resize",
corner: "nw-resize",
};
document.body.style.cursor = cursorMap[dir];
document.body.style.userSelect = "none";
},
[renderWidth, renderHeight, setChatSize],
);
return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
}

View File

@@ -1,2 +1 @@
export { ChatFab } from "./components/chat-fab";
export { ChatWindow } from "./components/chat-window";
export { ChatPage } from "./components/chat-page";

View File

@@ -1,18 +1,36 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from "@multica/ui/components/ui/hover-card";
import { useActorName } from "@multica/core/workspace/hooks";
import { AgentProfileCard } from "../agents/components/agent-profile-card";
interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: number;
className?: string;
/** Disable the hover-card preview (e.g. when the avatar is itself the page subject). */
disableHoverCard?: boolean;
}
export function ActorAvatar({ actorType, actorId, size, className }: ActorAvatarProps) {
const FOCUSABLE_ANCESTOR_SELECTOR =
'a[href], button:not([disabled]), [role="button"]:not([aria-disabled="true"]), [tabindex]:not([tabindex="-1"])';
export function ActorAvatar({
actorType,
actorId,
size,
className,
disableHoverCard,
}: ActorAvatarProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
return (
const avatar = (
<ActorAvatarBase
name={getActorName(actorType, actorId)}
initials={getActorInitials(actorType, actorId)}
@@ -22,4 +40,53 @@ export function ActorAvatar({ actorType, actorId, size, className }: ActorAvatar
className={className}
/>
);
if (disableHoverCard || actorType !== "agent") {
return avatar;
}
return <AgentAvatarHoverCard agentId={actorId}>{avatar}</AgentAvatarHoverCard>;
}
/**
* Wraps an agent avatar in a hover-card. The trigger is keyboard-focusable
* only when no focusable ancestor (link/button) already provides a tab stop —
* this prevents nested tabbable descendants and keyboard-nav bloat at sites
* where the avatar lives inside a row link or click target.
*/
function AgentAvatarHoverCard({
agentId,
children,
}: {
agentId: string;
children: React.ReactNode;
}) {
const triggerRef = useRef<HTMLSpanElement>(null);
const [standalone, setStandalone] = useState(false);
useEffect(() => {
const el = triggerRef.current;
if (!el) return;
const ancestor = el.parentElement?.closest(FOCUSABLE_ANCESTOR_SELECTOR);
setStandalone(!ancestor);
}, []);
return (
<HoverCard>
<HoverCardTrigger
render={<span ref={triggerRef} />}
tabIndex={standalone ? 0 : -1}
className={
standalone
? "inline-flex rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "inline-flex"
}
>
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-72">
<AgentProfileCard agentId={agentId} />
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -24,6 +24,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([
"autopilots",
"agents",
"inbox",
"chat",
"my-issues",
"runtimes",
"skills",

View File

@@ -99,7 +99,7 @@ export function AssigneePicker({
trigger={
customTrigger ? customTrigger : assigneeType && assigneeId ? (
<>
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} disableHoverCard />
<span className="truncate">{triggerLabel}</span>
</>
) : (
@@ -162,7 +162,7 @@ export function AssigneePicker({
setOpen(false);
}}
>
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
<ActorAvatar actorType="agent" actorId={a.id} size={18} disableHoverCard />
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />

View File

@@ -29,6 +29,8 @@ import {
SquarePen,
CircleUser,
FolderKanban,
MessageSquare,
Loader2,
X,
Zap,
} from "lucide-react";
@@ -65,6 +67,8 @@ import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/pat
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { chatSessionsOptions, pendingChatTasksOptions } from "@multica/core/chat/queries";
import { useAnchorTracker } from "../chat/components/context-anchor";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
@@ -84,12 +88,14 @@ const EMPTY_PINS: PinnedItem[] = [];
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
const EMPTY_CHAT_SESSIONS: Awaited<ReturnType<typeof api.listChatSessions>> = [];
// Nav items reference WorkspacePaths method names so they can be resolved
// against the current workspace slug at render time (see AppSidebar body).
// Only parameterless paths are valid nav destinations.
type NavKey =
| "inbox"
| "chat"
| "myIssues"
| "issues"
| "projects"
@@ -101,6 +107,7 @@ type NavKey =
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
{ key: "inbox", label: "Inbox", icon: Inbox },
{ key: "chat", label: "Chat", icon: MessageSquare },
{ key: "myIssues", label: "My Issues", icon: CircleUser },
];
@@ -321,6 +328,22 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
[inboxItems],
);
const { data: chatSessions = EMPTY_CHAT_SESSIONS } = useQuery({
...chatSessionsOptions(wsId ?? ""),
enabled: !!wsId,
});
const hasChatUnread = React.useMemo(
() => chatSessions.some((s) => s.has_unread),
[chatSessions],
);
const { data: pendingChatTasks } = useQuery({
...pendingChatTasksOptions(wsId ?? ""),
enabled: !!wsId,
});
const hasChatRunning = (pendingChatTasks?.tasks.length ?? 0) > 0;
// Track last anchor-eligible route so the Chat page (which is its own route)
// can still resolve focus-mode context from the page the user was just on.
useAnchorTracker();
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = EMPTY_PINS } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
@@ -575,6 +598,12 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{item.label === "Chat" && hasChatRunning && (
<Loader2 className="ml-auto !size-3 animate-spin text-muted-foreground" />
)}
{item.label === "Chat" && !hasChatRunning && hasChatUnread && (
<span className="ml-auto size-1.5 rounded-full bg-brand" />
)}
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -8,7 +8,7 @@ import { DashboardGuard } from "./dashboard-guard";
interface DashboardLayoutProps {
children: ReactNode;
/** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
/** Rendered inside SidebarInset — absolute-positioned overlays */
extra?: ReactNode;
/** Rendered inside sidebar header as a search trigger */
searchSlot?: ReactNode;

View File

@@ -8,14 +8,9 @@ const CELL_GAP = 2;
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
function getHeatmapColor(level: number): string {
const colors = [
"var(--color-muted, hsl(var(--muted)))",
"hsl(var(--chart-3) / 0.3)",
"hsl(var(--chart-3) / 0.5)",
"hsl(var(--chart-3) / 0.75)",
"hsl(var(--chart-3) / 1)",
];
return colors[level] ?? colors[0]!;
if (level === 0) return "var(--color-muted)";
const opacities = ["25%", "45%", "68%", "90%"];
return `color-mix(in oklch, var(--color-foreground) ${opacities[level - 1]}, transparent)`;
}
export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) {

View File

@@ -14,7 +14,7 @@ import {
import type { DailyCostData } from "../../utils";
const costChartConfig = {
cost: { label: "Cost", color: "hsl(var(--chart-1))" },
cost: { label: "Cost", color: "var(--color-chart-1)" },
} satisfies ChartConfig;
export function DailyCostChart({ data }: { data: DailyCostData[] }) {

View File

@@ -15,7 +15,7 @@ import type { DailyTokenData } from "../../utils";
import { formatTokens } from "../../utils";
const tokenChartConfig = {
total: { label: "Total", color: "hsl(var(--chart-1))" },
total: { label: "Total", color: "var(--color-chart-1)" },
} satisfies ChartConfig;
type DailyTokenRow = DailyTokenData & { total: number };

View File

@@ -17,7 +17,7 @@ import { api } from "@multica/core/api";
import type { RuntimeHourlyActivity } from "@multica/core/types";
const hourlyChartConfig = {
count: { label: "Tasks", color: "hsl(var(--chart-2))" },
count: { label: "Tasks", color: "var(--color-chart-2)" },
} satisfies ChartConfig;
export function HourlyActivityChart({ runtimeId }: { runtimeId: string }) {

View File

@@ -9,11 +9,11 @@ import type { ModelDistribution } from "../../utils";
import { formatTokens } from "../../utils";
const MODEL_COLORS = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
export function ModelDistributionChart({ data }: { data: ModelDistribution[] }) {

View File

@@ -119,10 +119,15 @@ func TestBuildPromptCommentTriggered(t *testing.T) {
for _, want := range []string{
issueID,
commentContent,
"comment that triggered this task",
"Focus on THIS comment",
commentID,
"multica issue comment add " + issueID + " --parent " + commentID,
"do NOT reuse --parent values from previous turns",
// Silence-as-valid-exit for agent-to-agent loops depends on the
// reply command being framed conditionally rather than as a hard
// requirement. Guard the phrasing so the conflict with the new
// workflow (MUL-1323) doesn't come back.
"If you decide to reply",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
@@ -135,6 +140,67 @@ func TestBuildPromptCommentTriggered(t *testing.T) {
}
}
// TestBuildPromptCommentTriggeredByAgent covers the agent-to-agent mention
// loop signal injected into the per-turn prompt (MUL-1323 / GH#1576). When
// the triggering comment was posted by another agent, the prompt must name
// the author, warn against sign-off @mentions, and point at silence as a
// valid exit.
func TestBuildPromptCommentTriggeredByAgent(t *testing.T) {
t.Parallel()
prompt := BuildPrompt(Task{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
TriggerCommentContent: "thanks, looks good!",
TriggerAuthorType: "agent",
TriggerAuthorName: "Atlas",
Agent: &AgentData{Name: "Test"},
})
for _, want := range []string{
"Another agent (Atlas)",
"do not @mention the other agent as a sign-off",
"silence is the preferred way",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
}
}
}
// TestBuildPromptCommentTriggeredByMember guards against the agent-loop warning
// leaking into human-authored triggers — a human asking a question should not
// be pre-discouraged from getting a reply.
func TestBuildPromptCommentTriggeredByMember(t *testing.T) {
t.Parallel()
prompt := BuildPrompt(Task{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
TriggerCommentContent: "can you translate this?",
TriggerAuthorType: "member",
TriggerAuthorName: "Alice",
Agent: &AgentData{Name: "Test"},
})
if !strings.Contains(prompt, "A user just left a new comment") {
t.Fatalf("member-triggered prompt should label the author as a user\n---\n%s", prompt)
}
if strings.Contains(prompt, "Another agent") {
t.Fatalf("member-triggered prompt should not claim the author was another agent")
}
// Must NOT use the old "You MUST respond" language — that conflicts with
// the agent-to-agent silence-as-valid-exit workflow. Even on human-authored
// triggers, the reply command is framed conditionally for a single
// consistent rule across turn types.
if strings.Contains(prompt, "MUST respond") {
t.Fatalf("prompt should not contain unconditional \"MUST respond\" language\n---\n%s", prompt)
}
if !strings.Contains(prompt, "If you decide to reply") {
t.Fatalf("prompt should frame the reply command conditionally\n---\n%s", prompt)
}
}
func TestBuildPromptCommentTriggeredNoContent(t *testing.T) {
t.Parallel()

View File

@@ -1353,3 +1353,76 @@ func TestReadGCMeta_NoFile(t *testing.T) {
t.Fatal("expected error for missing file")
}
}
// TestInjectRuntimeConfigMentionLoopHardening locks in the mention-loop
// instructions (see MUL-1323 / GH#1576). Two agents were stuck in an infinite
// @mention loop because the harness told them mentions were "actions" but did
// not tell them (a) when NOT to mention, (b) that silence ends a thread, or
// (c) that the triggering comment was from another agent. If any of the
// signals below regress, agent-to-agent loops come back.
func TestInjectRuntimeConfigMentionLoopHardening(t *testing.T) {
t.Parallel()
commentTriggerCtx := TaskContextForEnv{
IssueID: "issue-1",
TriggerCommentID: "comment-1",
}
assignmentCtx := TaskContextForEnv{IssueID: "issue-1"}
readClaudeMD := func(t *testing.T, ctx TaskContextForEnv) string {
t.Helper()
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
return string(data)
}
t.Run("mentions-section-lists-loop-protocol", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, assignmentCtx)
for _, want := range []string{
"side-effecting actions",
"enqueues a new run for that agent",
"When NOT to use a mention link",
"When a mention IS appropriate",
"end with no mention at all",
"Silence ends conversations",
} {
if !strings.Contains(s, want) {
t.Errorf("Mentions section missing %q\n---\n%s", want, s)
}
}
})
t.Run("closing-line-no-longer-says-always-mention", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, assignmentCtx)
// The old footer said "**always** use the mention format" which models
// over-generalized to agent/member mentions. Guard against regression.
if strings.Contains(s, "**always** use the mention format") {
t.Errorf("CLAUDE.md still contains the overreaching \"**always** use the mention format\" guidance")
}
})
t.Run("workflow-carries-silence-as-exit-and-no-signoff-mention", func(t *testing.T) {
t.Parallel()
s := readClaudeMD(t, commentTriggerCtx)
// The anti-loop signal for CLAUDE.md lives in the numbered workflow
// steps (4 + 5), not in a dedicated preamble. Lock in the key phrases
// so the signal can't decay back into pure prose again.
for _, want := range []string{
"Decide whether a reply is warranted",
"Silence is a valid and preferred way",
"Never @mention the agent you are replying to as a thank-you or sign-off",
} {
if !strings.Contains(s, want) {
t.Errorf("comment-triggered CLAUDE.md missing %q", want)
}
}
})
}

View File

@@ -16,7 +16,7 @@ func BuildCommentReplyInstructions(issueID, triggerCommentID string) string {
return ""
}
return fmt.Sprintf(
"Reply by running exactly this command — always use the trigger comment ID below, "+
"If you decide to reply, post it by running exactly this command — always use the trigger comment ID below, "+
"do NOT reuse --parent values from previous turns in this session:\n\n"+
" multica issue comment add %s --parent %s --content \"...\"\n",
issueID, triggerCommentID,

View File

@@ -131,10 +131,11 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the conversation\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
b.WriteString("4. If the comment requests code changes or further work, do the work first\n")
b.WriteString("5. **Post your reply as a comment — this step is mandatory.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString("4. **Decide whether a reply is warranted.** If the triggering comment is an acknowledgment / thanks / sign-off from another agent and no concrete question or task is being asked of you, do NOT post a reply — just exit. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n")
b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID))
b.WriteString("6. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
} else {
// Assignment-triggered: defer to agent Skills for workflow specifics.
b.WriteString("You are responsible for managing the issue status throughout your work.\n\n")
@@ -170,12 +171,19 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
}
b.WriteString("## Mentions\n\n")
b.WriteString("When referencing issues or people in comments, use the mention format so they render as interactive links:\n\n")
b.WriteString("- **Issue**: `[MUL-123](mention://issue/<issue-id>)` — renders as a clickable link to the issue\n")
b.WriteString("- **Member**: `[@Name](mention://member/<user-id>)` — renders as a styled mention and sends a notification\n")
b.WriteString("- **Agent**: `[@Name](mention://agent/<agent-id>)` — renders as a styled mention and re-triggers the agent\n\n")
b.WriteString("⚠️ Agent and member mentions are **actions**, not text references: agent mentions enqueue a new task for the agent, and member mentions send a notification. ")
b.WriteString("If you only want to refer to someone by name in prose (e.g. \"GPT-Boy is correct\"), write the plain name without the mention link.\n\n")
b.WriteString("Mention links are **side-effecting actions**, not just formatting:\n\n")
b.WriteString("- `[MUL-123](mention://issue/<issue-id>)` — clickable link to an issue (safe, no side effect)\n")
b.WriteString("- `[@Name](mention://member/<user-id>)` — **sends a notification to a human**\n")
b.WriteString("- `[@Name](mention://agent/<agent-id>)` — **enqueues a new run for that agent**\n\n")
b.WriteString("### When NOT to use a mention link\n\n")
b.WriteString("- Referring to someone in prose (e.g. \"GPT-Boy is right\") write the plain name, no link.\n")
b.WriteString("- **Replying to another agent that just spoke to you.** By default, do NOT put a `mention://agent/...` link anywhere in your reply. The platform already shows your comment to everyone on the issue; re-mentioning the other agent will make them run again, and if they reply with a mention back, you will be triggered again. That is a loop and it costs the user money.\n")
b.WriteString("- Thanking, acknowledging, wrapping up, or signing off. These are exactly the moments where an accidental `@mention` causes the other agent to reply \"you're welcome\" and restart the loop. If the work is done, **end with no mention at all**.\n\n")
b.WriteString("### When a mention IS appropriate\n\n")
b.WriteString("- Escalating to a human owner who is not yet involved.\n")
b.WriteString("- Delegating a concrete sub-task to another agent for the first time, with a clear request.\n")
b.WriteString("- The user explicitly asked you to loop someone in.\n\n")
b.WriteString("If you are unsure whether a mention is warranted, **don't mention**. Silence ends conversations; `@` restarts them.\n\n")
b.WriteString("Use `multica issue list --output json` to look up issue IDs, and `multica workspace members --output json` for member IDs.\n\n")
b.WriteString("## Attachments\n\n")
@@ -197,7 +205,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n")
b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n")
b.WriteString("Bad: \"1. Read the issue 2. Found the bug in auth.go 3. Created branch 4. ...\"\n")
b.WriteString("When referencing issues in comments, **always** use the mention format `[MUL-123](mention://issue/<issue-id>)` so they render as clickable links.\n")
b.WriteString("When referencing an issue in a comment, use the issue mention format `[MUL-123](mention://issue/<issue-id>)` so it renders as a clickable link. (Issue mentions have no side effect; only member/agent mentions do — see the Mentions section above.)\n")
return b.String()
}

View File

@@ -35,10 +35,21 @@ func buildCommentPrompt(task Task) string {
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
if task.TriggerCommentContent != "" {
b.WriteString("[NEW COMMENT] A user just left a new comment that triggered this task. You MUST respond to THIS comment, not any previous ones:\n\n")
authorLabel := "A user"
if task.TriggerAuthorType == "agent" {
name := task.TriggerAuthorName
if name == "" {
name = "another agent"
}
authorLabel = fmt.Sprintf("Another agent (%s)", name)
}
fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel)
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
if task.TriggerAuthorType == "agent" {
b.WriteString("⚠️ The triggering comment was posted by another agent. Before replying, decide whether a reply is warranted at all. If that comment was an acknowledgment, thanks, or sign-off and no concrete question or task is being asked of you, do NOT reply — silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n")
}
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n\n", task.IssueID)
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
b.WriteString(execenv.BuildCommentReplyInstructions(task.IssueID, task.TriggerCommentID))
return b.String()
}

View File

@@ -36,6 +36,8 @@ type Task struct {
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
}

View File

@@ -137,6 +137,8 @@ type AgentTaskResponse struct {
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks

View File

@@ -660,10 +660,30 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
// Fetch the triggering comment content so the daemon can embed it
// directly in the agent prompt (prevents the agent from ignoring comments
// when stale output files exist in a reused workdir).
// when stale output files exist in a reused workdir). Also surface the
// comment author's kind and display name so the agent knows whether it
// was triggered by a human or by another agent — a signal used by the
// harness instructions to avoid mention loops between agents.
if task.TriggerCommentID.Valid {
if comment, err := h.Queries.GetComment(r.Context(), task.TriggerCommentID); err == nil {
resp.TriggerCommentContent = comment.Content
resp.TriggerAuthorType = comment.AuthorType
switch comment.AuthorType {
case "agent":
if comment.AuthorID.Valid {
if a, err := h.Queries.GetAgent(r.Context(), comment.AuthorID); err == nil {
resp.TriggerAuthorName = a.Name
}
}
case "member":
// For member-authored comments, AuthorID is a user UUID
// (see handler.resolveActor) — look up the user's display name.
if comment.AuthorID.Valid {
if u, err := h.Queries.GetUser(r.Context(), comment.AuthorID); err == nil {
resp.TriggerAuthorName = u.Name
}
}
}
}
}