mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
5 Commits
fix/selfho
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465284898c | ||
|
|
94c88bb1bc | ||
|
|
87ade200af | ||
|
|
122c095e43 | ||
|
|
42123fd672 |
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ?? "/",
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -101,6 +101,7 @@ interface TabStore {
|
||||
|
||||
const ROUTE_ICONS: Record<string, string> = {
|
||||
inbox: "Inbox",
|
||||
chat: "MessageSquare",
|
||||
"my-issues": "CircleUser",
|
||||
issues: "ListTodo",
|
||||
projects: "FolderKanban",
|
||||
|
||||
1
apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
Normal file
1
apps/web/app/[workspaceSlug]/(dashboard)/chat/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ChatPage as default } from "@multica/views/chat";
|
||||
@@ -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 />
|
||||
</>
|
||||
}
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
513
packages/views/chat/components/chat-page.tsx
Normal file
513
packages/views/chat/components/chat-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export { ChatFab } from "./components/chat-fab";
|
||||
export { ChatWindow } from "./components/chat-window";
|
||||
export { ChatPage } from "./components/chat-page";
|
||||
|
||||
@@ -24,6 +24,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([
|
||||
"autopilots",
|
||||
"agents",
|
||||
"inbox",
|
||||
"chat",
|
||||
"my-issues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user