diff --git a/apps/web/src/components/chat-layout/index.tsx b/apps/web/src/components/chat-layout/index.tsx index eb2312f4dfa..11ffa3f47c0 100644 --- a/apps/web/src/components/chat-layout/index.tsx +++ b/apps/web/src/components/chat-layout/index.tsx @@ -4,9 +4,16 @@ import { createContext, isValidElement, use, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useIsMobile } from "@decocms/ui/hooks/use-mobile.ts"; import { Button } from "@decocms/ui/components/button.tsx"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; +import { Maximize01, Minimize01, XClose } from "@untitledui/icons"; import { Panel } from "@/components/panel"; import { Page } from "@/components/page"; import { SidebarThreadButtonPortal } from "@/components/sidebar/thread-button"; +import { ToolbarIconButton } from "@/components/toolbar-icon-button"; import { ErrorBoundary } from "@/components/error-boundary"; import { ResizableHandle, @@ -28,6 +35,7 @@ import { RoutePageHeader } from "@/layouts/route-page-header"; const THREAD_PANEL_ID = "chat-layout-thread"; const CONTENT_PANEL_ID = "chat-layout-content"; +const PANEL_MIN_SIZE = "320px"; interface ChatLayoutProps extends ChatLayoutState, ChatLayoutActions { children: ReactNode; @@ -101,32 +109,25 @@ function ChatLayoutRoot({ children, ...layout }: ChatLayoutProps) { const value = { ...layout, isMobile, - mobileSurface: resolveMobileSurface({ - visibility: layout, - threadVisibilityExplicit: layout.threadVisibilityExplicit, - }), + mobileSurface: resolveMobileSurface(layout), }; return ( - {!layout.threadless && ( + {isMobile && !layout.threadless && ( { - if (isMobile) { - void navigate({ - to: ".", - search: (prev) => ({ - ...prev, - ...mobileSurfaceSearch( - value.mobileSurface === "chat" ? "main" : "chat", - ), - }), - replace: true, - }); - } else { - layout.toggleThread(); - } + void navigate({ + to: ".", + search: (prev) => ({ + ...prev, + ...mobileSurfaceSearch( + value.mobileSurface === "chat" ? "main" : "chat", + ), + }), + replace: true, + }); }} /> )} @@ -158,7 +159,7 @@ function ChatLayoutRoot({ children, ...layout }: ChatLayoutProps) { }} orientation="horizontal" data-slot="chat-layout" - className="flex-1 min-h-0 pt-1 pb-1 pr-1 pl-0 [&>[data-chat-layout-panel-open]]:!min-w-[320px]" + className="flex-1 min-h-0 pt-1 pb-1 pr-1 pl-0" style={{ overflow: "visible" }} onLayoutChanged={(nextLayout, { isUserInteraction }) => { if (!isUserInteraction) { @@ -203,35 +204,140 @@ function ChatLayoutThread({ ) : null; } + // Full screen unmounts the chat panel, so no drag can ever close it. + if (!layout.threadOpen) return null; + return ( <> + {layout.contentOpen && }
- {layout.threadOpen && ( - <> - {topbar} - - {children} - - - )} + {topbar} + {children}
- ); } +function LayoutButton({ + label, + pressed, + onClick, + children, +}: { + label: string; + pressed?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + + + {children} + + + {label} + + ); +} + +/** Like an artifact: expand over the chat, or close back into it. */ +function MainPanelControls() { + const t = useT(); + const layout = useChatLayoutContext(); + if (layout.isMobile || layout.threadless) return null; + const expanded = !layout.threadOpen; + return ( +
+ {/* Window controls sit apart from the page's own actions. */} + + {/* Labeled once expanded: it is the only way back to the chat. */} + {expanded ? ( + + ) : ( + + + + )} + + + +
+ ); +} + +/** The chat never closes, so a closed main panel reopens from its header. */ +export function OpenMainPanelButton() { + const t = useT(); + const isMobile = useIsMobile(); + const layout = useOptionalChatLayout(); + if (isMobile || !layout || layout.threadless || layout.contentOpen) { + return null; + } + return ( + <> + + + + ); +} + +/** A bar with an arrow leaving it: the panel slides out from the left. */ +function PanelOpenIcon() { + return ( + + + + ); +} + function ContentBody({ children, routeId, @@ -277,6 +383,7 @@ function ChatLayoutContent({ } />
@@ -294,16 +401,14 @@ function ChatLayoutContent({ ); - return layout.isMobile ? ( - panel - ) : ( + if (layout.isMobile) return panel; + if (!layout.contentOpen) return null; + + return ( {/* Flex, not a nested resizable group: that one sized itself after diff --git a/apps/web/src/components/chat/agent-home.tsx b/apps/web/src/components/chat/agent-home.tsx deleted file mode 100644 index 92827db0227..00000000000 --- a/apps/web/src/components/chat/agent-home.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - getWellKnownDecopilotVirtualMCP, - useProjectContext, - useVirtualMCP, -} from "@/sdk"; -import { AgentAvatar } from "@/components/agent-icon"; -import { Chat } from "./index"; -import { useChatPrefs } from "./context"; - -export function AgentHome({ - onOpenContextPanel, -}: { - onOpenContextPanel: () => void; -}) { - const { org } = useProjectContext(); - const { selectedVirtualMcp } = useChatPrefs(); - const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); - const agent = selectedVirtualMcp ?? defaultAgent; - const fullVm = useVirtualMCP(agent.id); - - // Synthesized agents (the Super Agent) aren't persisted, so useVirtualMCP - // returns null — fall back to the resolved agent for the identity block. - const entity = fullVm ?? agent; - const title = entity.title ?? "Super Agent"; - const icon = (entity.icon as string | null | undefined) ?? null; - const description = entity.description ?? null; - - return ( - <> - {/* Empty-chat identity — the agent's icon/title/description centered in - the void above the composer, so an empty thread doesn't read as a - blank panel. Threads for this agent live in the sidebar. */} -
- -
-

- {title} -

- {description ? ( -

- {description} -

- ) : null} -
-
- - {/* docked input */} - - - - - - ); -} diff --git a/apps/web/src/components/chat/context-chips.tsx b/apps/web/src/components/chat/context-chips.tsx new file mode 100644 index 00000000000..d7ebb13a51d --- /dev/null +++ b/apps/web/src/components/chat/context-chips.tsx @@ -0,0 +1,94 @@ +/** The composer's word on what the agent will see: the page you're on and the + * Library file open, each with a way to leave it out of this question. */ + +import type { ReactNode } from "react"; +import { Browser, XClose } from "@untitledui/icons"; +import { IconButton } from "@decocms/ui/components/icon-button.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { AgentAvatar } from "@/components/agent-icon"; +import { FileTypeIcon } from "@/components/file-type-icon"; +import { useChatViewContext } from "@/hooks/use-current-view"; +import { useOpenLibraryFile } from "@/hooks/use-open-library-file"; +import { useT } from "@/i18n/use-t.ts"; +import { basename } from "@/layouts/library/location"; + +function ContextChip({ + icon, + inset, + label, + title, + dismissLabel, + onDismiss, +}: { + icon: ReactNode; + /** The icon carries its own tile, so the chip needs less room before it. */ + inset?: boolean; + label: string; + title: string; + dismissLabel: string; + onDismiss: () => void; +}) { + return ( + + {icon} + {label} + + + + + ); +} + +export function ContextChips() { + const t = useT(); + const view = useChatViewContext(); + const openFile = useOpenLibraryFile(); + if (!view && !openFile) return null; + const viewLabel = view + ? view.project && !view.isHome + ? `${view.project.title} · ${view.title}` + : view.title + : ""; + const fileName = openFile ? basename(openFile.path) : ""; + return ( +
+ {view && ( + + ) : ( + + ) + } + inset={!!view.project} + label={viewLabel} + title={t("chat.input.viewContext", { name: viewLabel })} + dismissLabel={t("chat.input.viewDismiss")} + onDismiss={view.dismiss} + /> + )} + {openFile && ( + + } + label={fileName} + title={t("chat.input.openFileContext", { name: fileName })} + dismissLabel={t("chat.input.openFileDismiss")} + onDismiss={openFile.dismiss} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/chat/drafts/draft-card.tsx b/apps/web/src/components/chat/drafts/draft-card.tsx new file mode 100644 index 00000000000..e14c5821042 --- /dev/null +++ b/apps/web/src/components/chat/drafts/draft-card.tsx @@ -0,0 +1,69 @@ +import { Link } from "@tanstack/react-router"; +import { Button } from "@decocms/ui/components/button.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { AgentAvatar } from "@/components/agent-icon"; +import { useT } from "@/i18n/use-t.ts"; +import { useProjectContext } from "@/sdk"; +import { useOptionalChatTask } from "../chat-context"; +import { type ChatDraft, useChatDraft } from "./use-chat-draft"; +import { WorkCard } from "../work-card"; + +function DraftStatus({ draft }: { draft: ChatDraft }) { + const t = useT(); + return ( + <> + + {t(draft.editing ? "chat.draft.editing" : "chat.draft.ready")} + + ); +} + +function OpenDraftButton({ draft }: { draft: ChatDraft }) { + const t = useT(); + const { org } = useProjectContext(); + const taskId = useOptionalChatTask()?.taskId ?? null; + return ( + + ); +} + +/** The draft this chat changed, shown in the conversation where the work began. */ +export function ChatDraftCard() { + const t = useT(); + const draft = useChatDraft(); + if (!draft) return null; + return ( + + } + title={draft.projectTitle} + subtitle={{draft.branch}} + status={} + detail={t( + draft.changeCount === 1 ? "chat.draft.change" : "chat.draft.changes", + { count: draft.changeCount }, + )} + action={} + /> + ); +} diff --git a/apps/web/src/components/chat/drafts/use-chat-draft.ts b/apps/web/src/components/chat/drafts/use-chat-draft.ts new file mode 100644 index 00000000000..a7d89a15286 --- /dev/null +++ b/apps/web/src/components/chat/drafts/use-chat-draft.ts @@ -0,0 +1,72 @@ +import { useQuery } from "@tanstack/react-query"; +import { createContentClient } from "@decocms/blocks/protocol"; +import { agentHasClonableSource } from "@/lib/agent-capabilities"; +import { KEYS } from "@/lib/query-keys"; +import { useProjectContext, useVirtualMCPNonBlocking } from "@/sdk"; +import { useOptionalChatStream, useOptionalChatTask } from "../chat-context"; + +export interface ChatDraft { + virtualMcpId: string; + projectTitle: string; + projectIcon: string | null; + branch: string; + changeCount: number; + /** The agent is still working in it. */ + editing: boolean; +} + +/** Re-read while the chat is open, so the card follows edits made elsewhere. */ +const POLL_MS = 15_000; + +/** A thread draft's pending changes, read from the hosted draft (not the editor's runtime). */ +export function useDraftChanges( + virtualMcpId: string | null | undefined, + branch: string | null | undefined, + options: { poll?: boolean } = {}, +) { + const { org } = useProjectContext(); + const project = useVirtualMCPNonBlocking(virtualMcpId); + const enabled = + !!virtualMcpId && !!branch && agentHasClonableSource(project?.metadata); + const { data: changeCount = 0 } = useQuery({ + queryKey: KEYS.chatDraftChanges(org.slug, virtualMcpId ?? "", branch ?? ""), + queryFn: async () => { + const client = createContentClient({ + endpoint: new URL( + `/api/${org.slug}/decofile/${encodeURIComponent(virtualMcpId!)}/${encodeURIComponent(branch!)}/rpc`, + window.location.origin, + ).href, + }); + const result = await client.changesList(); + return result.notModified ? 0 : Object.keys(result.changes).length; + }, + enabled, + refetchInterval: options.poll === false ? false : POLL_MS, + // A site that isn't v8, or not hosted, simply has no draft to show. + retry: false, + }); + return { project: enabled ? project : null, changeCount }; +} + +/** The draft this chat changed, when it changed one. */ +export function useChatDraft(): ChatDraft | null { + const task = useOptionalChatTask(); + const stream = useOptionalChatStream(); + const virtualMcpId = task?.virtualMcpId ?? null; + const branch = task?.currentBranch ?? null; + const { project, changeCount } = useDraftChanges(virtualMcpId, branch); + + if (!project || !virtualMcpId || !branch || changeCount === 0) return null; + return { + virtualMcpId, + projectTitle: project.title, + projectIcon: (project.icon as string | null | undefined) ?? null, + branch, + changeCount, + editing: + !!stream && + (stream.isRunInProgress || + stream.status === "streaming" || + stream.status === "submitted"), + }; +} diff --git a/apps/web/src/components/chat/drafts/used-tool.ts b/apps/web/src/components/chat/drafts/used-tool.ts new file mode 100644 index 00000000000..7a6fd9079fb --- /dev/null +++ b/apps/web/src/components/chat/drafts/used-tool.ts @@ -0,0 +1,8 @@ +import type { ChatMessage } from "../types"; + +/** Whether an answer did work (a tool call), not just replied. */ +export function usedTool(message: ChatMessage): boolean { + return message.parts.some( + (part) => part.type === "dynamic-tool" || part.type.startsWith("tool-"), + ); +} diff --git a/apps/web/src/components/chat/empty-state.tsx b/apps/web/src/components/chat/empty-state.tsx new file mode 100644 index 00000000000..2d3ba657632 --- /dev/null +++ b/apps/web/src/components/chat/empty-state.tsx @@ -0,0 +1,85 @@ +import { usePreferences } from "@/hooks/use-preferences"; +import { useT } from "@/i18n/use-t.ts"; +import { formatRelativeTime } from "@/lib/format-time"; +import { useThreadsPanel } from "@/components/sidebar/task-groups/use-threads-panel"; +import type { Task } from "./task/types"; +import { useDraftChanges } from "./drafts/use-chat-draft"; + +const RECENT_LIMIT = 4; + +/** A recent chat, marked when its draft still has changes to publish. */ +function RecentChatRow({ + thread, + onSelect, +}: { + thread: Task; + onSelect: () => void; +}) { + const t = useT(); + const [preferences] = usePreferences(); + const { changeCount } = useDraftChanges( + thread.virtual_mcp_id, + thread.branch, + { poll: false }, + ); + return ( + + ); +} + +/** An empty chat picks up where you were: the chats to continue, marking + * the ones with a pending draft. With none, one quiet line. */ +export function ChatEmptyState() { + const t = useT(); + const panel = useThreadsPanel(); + const recent = panel.threads + .filter((thread) => thread.id !== panel.activeTaskId) + .slice(0, RECENT_LIMIT); + + if (recent.length === 0) { + return ( +
+

+ {t("chat.emptyState.start")} +

+
+ ); + } + + return ( +
+
+

+ {t("chat.emptyState.continue")} +

+
    + {recent.map((thread) => ( +
  • + panel.selectTask(thread)} + /> +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ice-breakers.tsx b/apps/web/src/components/chat/ice-breakers.tsx deleted file mode 100644 index aba6c9a113a..00000000000 --- a/apps/web/src/components/chat/ice-breakers.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { - displayToolName, - getGatewayClientId, - stripToolNamespace, -} from "@decocms/mcp-utils/aggregate"; -import { Spinner } from "@decocms/ui/components/spinner.tsx"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@decocms/ui/components/tooltip.tsx"; -import { cn } from "@decocms/ui/lib/utils.ts"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@decocms/ui/components/dialog.tsx"; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "@decocms/ui/components/drawer.tsx"; -import { CollectionSearch } from "@/components/collections/collection-search.tsx"; -import { useIsMobile } from "@decocms/ui/hooks/use-mobile.ts"; -import { - getPrompt, - getWellKnownDecopilotVirtualMCP, - useMCPClient, - useMCPPromptsList, - useProjectContext, -} from "@/sdk"; -import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; -import { Suspense, useReducer, useState } from "react"; -import { toast } from "sonner"; -import { useT } from "@/i18n/use-t.ts"; -import { ErrorBoundary } from "../error-boundary"; -import { useChatStream, useChatPrefs } from "./context"; -import { - PromptArgsDialog, - type PromptArgumentValues, -} from "./dialog-prompt-arguments"; -import { createMentionDoc } from "./tiptap/mention/node"; - -// ---------- Types ---------- - -interface PromptItem { - prompt: Prompt; -} - -interface IceBreakersUIProps { - items: PromptItem[]; - onSelect: (prompt: Prompt) => void; - loadingPrompt?: Prompt | null; - className?: string; -} - -// ---------- UI ---------- - -const VISIBLE_COUNT = 3; - -const PILL_BASE = - "inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-muted text-muted-foreground text-sm font-medium transition-colors cursor-pointer"; - -const CARD_BASE = - "flex flex-col gap-1.5 p-4 rounded-xl bg-accent/40 text-sm leading-snug transition-colors cursor-pointer text-left"; - -function PromptCard({ - item, - onSelect, - isLoading, - isDisabled, - variant = "pill", -}: { - item: PromptItem; - onSelect: (prompt: Prompt) => void; - isLoading: boolean; - isDisabled: boolean; - variant?: "pill" | "card"; -}) { - const { prompt } = item; - const clientId = getGatewayClientId(prompt._meta); - const name = prompt.title ?? displayToolName(prompt.name, clientId); - const description = prompt.description || null; - - if (variant === "card") { - return ( - - ); - } - - return ( - - - - - -

{description ?? name}

-
-
- ); -} - -function AllPromptsModal({ - items, - open, - onOpenChange, - onSelect, - loadingPrompt, -}: { - items: PromptItem[]; - open: boolean; - onOpenChange: (open: boolean) => void; - onSelect: (prompt: Prompt) => void; - loadingPrompt: Prompt | null | undefined; -}) { - const t = useT(); - const [search, setSearch] = useState(""); - const isAnyLoading = !!loadingPrompt; - - const filtered = search.trim() - ? items.filter((item) => { - const q = search.toLowerCase(); - return ( - item.prompt.name.toLowerCase().includes(q) || - (item.prompt.title ?? "").toLowerCase().includes(q) || - (item.prompt.description ?? "").toLowerCase().includes(q) - ); - }) - : items; - - const isMobile = useIsMobile(); - - const gridContent = ( -
-
- {filtered.length === 0 && ( -

- {t("chat.iceBreakers.noPromptsMatch", { search })} -

- )} - {filtered.map((item) => ( - { - onOpenChange(false); - onSelect(prompt); - }} - isLoading={loadingPrompt?.name === item.prompt.name} - isDisabled={ - isAnyLoading && loadingPrompt?.name !== item.prompt.name - } - /> - ))} -
-
- ); - - const searchBar = ( - { - if (e.key === "Escape") setSearch(""); - }} - /> - ); - - if (isMobile) { - return ( - - - - - {t("chat.iceBreakers.allPromptsTitle")} - -
- - {t("chat.iceBreakers.promptsLabel")} - -
- {searchBar} - {gridContent} -
-
-
- ); - } - - return ( - - - - - {t("chat.iceBreakers.allPromptsTitle")} - -
- - {t("chat.iceBreakers.promptsLabel")} - -
- {searchBar} - {gridContent} -
-
-
- ); -} - -/** Compact icebreakers: up to 3 prompt cards (centered) or 3 cards + "+N" overflow. */ -function IceBreakersUI({ - items, - onSelect, - loadingPrompt, - className, -}: IceBreakersUIProps) { - const t = useT(); - const [modalOpen, setModalOpen] = useState(false); - - if (items.length === 0) return null; - - const visible = items.slice(0, VISIBLE_COUNT); - const hidden = items.slice(VISIBLE_COUNT); - const isAnyLoading = !!loadingPrompt; - - const cards = visible.map((item) => ( - - )); - - return ( - -
- {cards} - {hidden.length > 0 && ( - - )} -
- -
- ); -} - -// ---------- State machine ---------- - -interface IceBreakersProps { - className?: string; -} - -function IceBreakersFallback() { - return null; -} - -type IceBreakerState = - | { stage: "idle" } - | { - stage: "loading"; - prompt: Prompt; - arguments?: PromptArgumentValues; - }; - -type IceBreakerAction = - | { type: "SELECT_PROMPT"; prompt: Prompt } - | { - type: "START_LOADING"; - prompt: Prompt; - arguments?: PromptArgumentValues; - } - | { type: "RESET" }; - -function iceBreakerReducer( - state: IceBreakerState, - action: IceBreakerAction, -): IceBreakerState { - switch (action.type) { - case "SELECT_PROMPT": - if (!action.prompt.arguments || action.prompt.arguments.length === 0) { - return { stage: "loading", prompt: action.prompt }; - } - return { stage: "idle" }; - - case "START_LOADING": - return { - stage: "loading", - prompt: action.prompt, - arguments: action.arguments, - }; - - case "RESET": - return { stage: "idle" }; - - default: - return state; - } -} - -// ---------- Data fetching ---------- - -function IceBreakersContent({ connectionId }: { connectionId: string | null }) { - const t = useT(); - const { sendMessage } = useChatStream(); - const { org } = useProjectContext(); - - // Fetch prompts from the aggregated virtual MCP - const client = useMCPClient({ - connectionId, - orgId: org.id, - orgSlug: org.slug, - }); - const { data } = useMCPPromptsList({ client, staleTime: 60000 }); - const prompts = data?.prompts ?? []; - - const items: PromptItem[] = prompts.map((prompt) => ({ prompt })); - - const [state, dispatch] = useReducer(iceBreakerReducer, { stage: "idle" }); - const [dialogPrompt, setDialogPrompt] = useState(null); - - const loadPrompt = async (prompt: Prompt, args?: PromptArgumentValues) => { - if (!client) { - toast.error(t("chat.iceBreakers.mcpClientNotAvailable")); - dispatch({ type: "RESET" }); - return; - } - - try { - const result = await getPrompt(client, prompt.name, args); - - dispatch({ type: "RESET" }); - - // Build the tiptap doc the EXACT same way the slash-mention plugin - // does in the editor (`insertMention` in `tiptap/mention/node.tsx`): - // a single paragraph containing the mention atom followed by a - // trailing " " text node — no extra empty paragraphs around it. This - // keeps the icebreaker's `sendMessage` payload byte-identical to - // typing `/` + Enter so the sent user message renders with - // the same chip styling. - const newTiptapDoc = { - type: "doc" as const, - content: [ - { - type: "paragraph", - content: [ - createMentionDoc({ - id: prompt.name, - name: stripToolNamespace( - prompt.name, - getGatewayClientId(prompt._meta), - ), - metadata: result.messages, - char: "/", - kind: "prompt", - args, - }), - { type: "text" as const, text: " " }, - ], - }, - ], - }; - - await sendMessage(newTiptapDoc); - } catch (error) { - console.error("[ice-breakers] Failed to fetch prompt:", error); - toast.error(t("chat.iceBreakers.failedToLoadPrompt")); - dispatch({ type: "RESET" }); - } - }; - - const handlePromptSelection = async (prompt: Prompt) => { - if (prompt.arguments && prompt.arguments.length > 0) { - dispatch({ type: "SELECT_PROMPT", prompt }); - setDialogPrompt(prompt); - return; - } - - dispatch({ type: "START_LOADING", prompt }); - await loadPrompt(prompt); - }; - - const handleDialogSubmit = async (values: PromptArgumentValues) => { - if (!dialogPrompt) return; - - dispatch({ - type: "START_LOADING", - prompt: dialogPrompt, - arguments: values, - }); - setDialogPrompt(null); - await loadPrompt(dialogPrompt, values); - }; - - if (items.length === 0) { - return null; - } - - return ( - <> - - { - setDialogPrompt(null); - dispatch({ type: "RESET" }); - }} - onSubmit={handleDialogSubmit} - /> - - ); -} - -// ---------- Export ---------- - -export function IceBreakers({ className }: IceBreakersProps) { - const { selectedVirtualMcp } = useChatPrefs(); - const { org } = useProjectContext(); - const decopilotId = getWellKnownDecopilotVirtualMCP(org.id).id; - const connectionId = selectedVirtualMcp?.id ?? decopilotId; - - return ( -
- - } - > - - - -
- ); -} diff --git a/apps/web/src/components/chat/index.tsx b/apps/web/src/components/chat/index.tsx index b25ced1a9f3..e70b1594aab 100644 --- a/apps/web/src/components/chat/index.tsx +++ b/apps/web/src/components/chat/index.tsx @@ -2,6 +2,7 @@ import { Button } from "@decocms/ui/components/button.tsx"; import { cn } from "@decocms/ui/lib/utils.ts"; import { ArrowDown } from "@untitledui/icons"; import { + Fragment, useEffect, useRef, type PropsWithChildren, @@ -19,11 +20,12 @@ import { } from "./context"; export { useChatTask } from "./context"; -import { IceBreakers } from "./ice-breakers"; import { HIGHLIGHT_COLLAPSED_HEIGHT_PX } from "./highlight/collapsible-highlight"; import { useHighlightCount } from "./highlight/use-highlight-count"; import { ChatInput } from "./input"; import { MessagePair, useMessagePairs } from "./message/pair.tsx"; +import { ChatDraftCard } from "./drafts/draft-card"; +import { usedTool } from "./drafts/used-tool"; import { selectHiddenFromBody } from "./queue-items.ts"; import { LiveMessagesContext, placeLiveMessages } from "./live-messages.ts"; import { useMessageQueue } from "./use-message-queue.ts"; @@ -166,6 +168,10 @@ function ChatMessages() { const visibleMessages = liveMessages.visible; const messagePairs = useMessagePairs(visibleMessages); const lastMessagePair = messagePairs.at(-1); + // The draft card sits where the agent's work began; no work, no card. + const draftAnchor = messagePairs.findIndex( + (pair) => !!pair.assistant && usedTool(pair.assistant), + ); const highlightCount = useHighlightCount(); // Reserve `n × h + 16px` of bottom padding so that, when every highlight @@ -237,13 +243,9 @@ function ChatMessages() { `overflow-x-clip` carries the horizontal-clipping defense moved off the scroller above (see its comment). It MUST be `clip`, not `hidden`: any overflow value other than visible/clip makes this - wrapper a scroll container, and `position: sticky` latches onto - its NEAREST scroll-container ancestor — with `hidden` here, the - sticky user bubble (pair.tsx) sticks to this never-scrolling - wrapper instead of the real scroller and stops pinning entirely. - `clip` clips identically without creating a scroll container, and - stays invisible to the lib's wheel-escape ancestor walk (computed - shorthand is not "auto"/"scroll"). + wrapper a scroll container. `clip` clips identically without one, + and stays invisible to the lib's wheel-escape ancestor walk + (computed shorthand is not "auto"/"scroll"). `[container-type:size]` above (on the scroller) + `min-h-[100cqh]` below (on the last-pair wrapper) replace the old `min-h-full`: @@ -268,14 +270,16 @@ function ChatMessages() {
)} {messagePairs.slice(0, -1).map((pair, index) => ( - + + + {index === draftAnchor && } + ))} {lastMessagePair && ( @@ -286,6 +290,7 @@ function ChatMessages() { isLastPair={true} status={status} /> + {draftAnchor === messagePairs.length - 1 && } )} @@ -347,7 +352,6 @@ export const Chat = Object.assign(ChatRoot, { StreamHost: ChatStreamHost, ActiveTaskStreamSource: ActiveTaskStreamSource, ThreadlessStreamSource: ThreadlessStreamSource, - IceBreakers: IceBreakers, NoAiProviderEmptyState: NoAiProviderEmptyState, CreditsEmptyState: CreditsEmptyState, CreditsEyebrow: CreditsEyebrow, diff --git a/apps/web/src/components/chat/input.tsx b/apps/web/src/components/chat/input.tsx index 21e711ed0d9..283cbc9b561 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -3,7 +3,7 @@ import { navigateToTabRouteTarget, } from "@/layouts/main-panel-tabs/tab-route"; import { withTaskIntake } from "./task-intake"; -import { OpenFileChip } from "./open-file-chip"; +import { ContextChips } from "./context-chips"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { LOCALSTORAGE_KEYS } from "@/lib/localstorage-keys"; import { isModKey } from "@/lib/keyboard-shortcuts"; @@ -306,7 +306,7 @@ function ModeDismissPill({ {icon} @@ -797,7 +797,7 @@ export function ChatInput({ disabled={voice.status === "recording"} /> - +
} colorClass="text-brand-purple-dark dark:text-brand-purple hover:bg-brand-purple/10" - maxWidthClass="@[320px]/chat-bottom:max-w-32" + maxWidthClass="@[480px]/chat-bottom:max-w-32" /> )} {chatMode === "gen-image" && imageModel && ( @@ -883,7 +883,7 @@ export function ChatInput({ label={t("chat.input.createImage")} icon={} colorClass="text-pink-600 dark:text-pink-400 hover:bg-pink-500/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent" - maxWidthClass="@[320px]/chat-bottom:max-w-[120px]" + maxWidthClass="@[480px]/chat-bottom:max-w-[120px]" /> )} {chatMode === "web-search" && webSearchModel && ( @@ -892,7 +892,7 @@ export function ChatInput({ label={t("chat.input.webSearch")} icon={} colorClass="text-brand-blue-dark dark:text-brand-blue hover:bg-brand-blue/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent" - maxWidthClass="@[320px]/chat-bottom:max-w-[120px]" + maxWidthClass="@[480px]/chat-bottom:max-w-[120px]" /> )} {chatMode === "deep-research" && deepResearchModel && ( @@ -901,7 +901,7 @@ export function ChatInput({ label={t("chat.input.deepResearch")} icon={} colorClass="text-brand-blue-dark dark:text-brand-blue hover:bg-brand-blue/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent" - maxWidthClass="@[320px]/chat-bottom:max-w-[120px]" + maxWidthClass="@[480px]/chat-bottom:max-w-[120px]" /> )} {contextWindow && lastTotalTokens > 0 && ( diff --git a/apps/web/src/components/chat/message/assistant.tsx b/apps/web/src/components/chat/message/assistant.tsx index 8d5f37c342c..030df3ca836 100644 --- a/apps/web/src/components/chat/message/assistant.tsx +++ b/apps/web/src/components/chat/message/assistant.tsx @@ -13,6 +13,7 @@ import type { ChatMessage } from "../types.ts"; import { MessageStatsBar } from "../usage-stats.tsx"; import { MessageTextPart } from "./parts/text-part.tsx"; import { MessageTimestamp } from "./timestamp.tsx"; +import { createdTaskOf, TaskCreatedCard } from "./parts/task-created-card.tsx"; import { type ReasoningPart, GeneratingFooter, @@ -296,7 +297,11 @@ function renderItem({ id={message.id} usageStats={ shouldShowUsage && ( - + } + /> ) } dataParts={dataParts} @@ -408,6 +413,10 @@ function MessagePart({ /> ); } + if (fallback.type === "tool-TASK_BOARD_ITEM_CREATE") { + const createdTask = createdTaskOf(fallback); + if (createdTask) return ; + } if (fallback.type === "tool-COLLECTION_VIRTUAL_MCP_LIST") { return ( + message ? isVisiblePartItem(r, message.parts) : false, + ); + const endsInText = + !!message && + lastVisible?.kind === "part" && + message.parts[lastVisible.index]?.type === "text"; // Sent to this run and not read yet: under what it has produced so far. const { pending } = useLiveMessages(); @@ -700,7 +716,8 @@ export function MessageAssistant({ {isTerminallyDone && } )} - {!isLoading && } + {/* A reply ending in text shows its time beside the cost instead. */} + {!isLoading && !endsInText && }
) : ( diff --git a/apps/web/src/components/chat/message/pair.tsx b/apps/web/src/components/chat/message/pair.tsx index 2f13fd8d688..35d5cc0915d 100644 --- a/apps/web/src/components/chat/message/pair.tsx +++ b/apps/web/src/components/chat/message/pair.tsx @@ -63,8 +63,7 @@ export function MessagePair({ pair, isLastPair, status }: MessagePairProps) { * Initial-scroll once per MessagePair mount. The ref callback below fires * on every render (its identity is unstable by design), so this gate keeps * `scrollIntoView` / scroll-to-bottom from re-firing on every commit during - * streaming — the sticky `top-4` on MessageUser keeps the user message - * pinned visually without re-scrolling. + * streaming. */ const didInitialScroll = useRef(false); @@ -114,12 +113,9 @@ export function MessagePair({ pair, isLastPair, status }: MessagePairProps) { className={cn("flex flex-col pb-2 sm:pb-2", !pair.user && "pt-8 sm:pt-6")} > {pair.user && ( - <> -
-
- -
- +
+ +
)} ["item"]; + +/** The task a finished `TASK_BOARD_ITEM_CREATE` call filed. */ +export function createdTaskOf(part: { + state: string; + output?: unknown; +}): CreatedTask | null { + if (part.state !== "output-available") return null; + const parsed = CreatedTaskSchema.safeParse( + unwrapResult(part.output) ?? part.output, + ); + return parsed.success ? parsed.data.item : null; +} + +/** A task the chat filed, kept current from the board; the whole card opens it. */ +export function TaskCreatedCard({ task }: { task: CreatedTask }) { + const t = useT(); + const { org, locator } = useProjectContext(); + const queryClient = useQueryClient(); + const { items } = useTaskBoardItems(); + const loaded = items.find((item) => item.id === task.id); + const live = loaded ?? task; + // The board opens a task from its loaded list; one the chat just filed may not be in it yet. + const syncBoard = () => { + if (!loaded) { + void queryClient.invalidateQueries({ + queryKey: KEYS.taskBoardItems(locator), + }); + } + }; + const project = useVirtualMCPNonBlocking(live.virtualMcpId); + const lane = laneHeader(live.status, t); + const LaneIcon = lane.visual.icon; + const key = taskKey(org.slug, live.keySeq); + const taskSegment = taskRouteSegment(org.slug, live); + const className = + "group my-2 flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 shadow-xs transition-colors hover:bg-accent/50 focus-ring"; + const body = ( + <> + {project ? ( + + ) : ( + + + + )} + + + {live.title} + + + + {lane.label} + {[key, project?.title].filter(Boolean).map((part) => ( + + {part} + + ))} + + + + + ); + // A project's task opens on its own board, where the sidebar and breadcrumb belong. + return live.virtualMcpId ? ( + + {body} + + ) : ( + + {body} + + ); +} diff --git a/apps/web/src/components/chat/message/parts/tool-call-part/agent-list.tsx b/apps/web/src/components/chat/message/parts/tool-call-part/agent-list.tsx index 9525ce89a6d..05206623b29 100644 --- a/apps/web/src/components/chat/message/parts/tool-call-part/agent-list.tsx +++ b/apps/web/src/components/chat/message/parts/tool-call-part/agent-list.tsx @@ -5,6 +5,7 @@ import { Suspense } from "react"; import { UserCircle } from "@untitledui/icons"; import { useNavigate } from "@tanstack/react-router"; import { useProjectContext } from "@/sdk"; +import { isScopableProject } from "@decocms/shared/sdk"; import type { VirtualMCPEntity } from "@decocms/shared/sdk/types"; import { AgentAvatar } from "@/components/agent-icon"; import { useNavigateToAgent } from "@/hooks/use-navigate-to-agent"; @@ -72,7 +73,10 @@ export function AgentListPart({ part, latency }: AgentListPartProps) { const { org } = useProjectContext(); const state = getEffectiveState(part.state); const result = unwrapResult<{ items?: VirtualMCPEntity[] }>(part.output); - const items = Array.isArray(result?.items) ? result.items : []; + // The tool also returns Decopilot and the Studio Pack managers; only real projects are shown. + const items = Array.isArray(result?.items) + ? result.items.filter(isScopableProject) + : []; if (state === "loading") { return ( diff --git a/apps/web/src/components/chat/message/parts/tool-call-part/common.tsx b/apps/web/src/components/chat/message/parts/tool-call-part/common.tsx index b4792e3bb27..5154cde014a 100644 --- a/apps/web/src/components/chat/message/parts/tool-call-part/common.tsx +++ b/apps/web/src/components/chat/message/parts/tool-call-part/common.tsx @@ -103,8 +103,7 @@ export function ToolCallShell({ {title} - {/* Inline summary — pill with subtle bg */} - {summary ? ( - - - {/* Its own span: the shimmer clips its background to text, which would take the pill's fill with it. */} - {summary} - + {/* Inline summary, hidden once open: the detail below says it in full. */} + {summary && !(effectiveOpen && hasDetailString) ? ( + + {summary} ) : (
@@ -196,14 +201,14 @@ export function ToolCallShell({ {children ? ( children ) : detailVariant === "prose" ? ( -
+

{detail}

diff --git a/apps/web/src/components/chat/message/thinking-indicator.tsx b/apps/web/src/components/chat/message/thinking-indicator.tsx index a22abcc68de..ca1f3e2977a 100644 --- a/apps/web/src/components/chat/message/thinking-indicator.tsx +++ b/apps/web/src/components/chat/message/thinking-indicator.tsx @@ -217,7 +217,7 @@ export function ThoughtSummary({ : undefined; const fullText = parts.map((p) => p.text ?? "").join("\n\n"); - const detail = !allPartsRedacted && fullText.trim() ? fullText : null; + const detail = !allPartsRedacted && fullText.trim() ? fullText.trim() : null; return ( {date.toLocaleTimeString(undefined, { hour: "numeric", diff --git a/apps/web/src/components/chat/message/user.tsx b/apps/web/src/components/chat/message/user.tsx index f55df5ce1db..05821089b3b 100644 --- a/apps/web/src/components/chat/message/user.tsx +++ b/apps/web/src/components/chat/message/user.tsx @@ -44,7 +44,7 @@ function RichMessageContent({ editable: false, editorProps: { attributes: { - class: "prose prose-sm max-w-none focus:outline-none", + class: "prose prose-sm max-w-none text-[14px]! focus:outline-none", }, }, }); @@ -93,70 +93,67 @@ export function MessageUser({ <>
-
+
setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className="max-w-[85%] min-w-0 cursor-pointer rounded-2xl bg-muted text-sm wrap-break-word overflow-wrap-anywhere outline-none" + >
setIsFocused(true)} - onBlur={() => setIsFocused(false)} - className="w-full border border-border/60 min-w-0 shadow-xs rounded-lg text-sm wrap-break-word overflow-wrap-anywhere bg-background cursor-pointer transition-colors relative flex outline-none" + ref={setContentRef} + className={cn( + "px-4 py-2.5 transition-[max-height,opacity] duration-300 ease-out", + isFocused + ? "max-h-[50vh] overflow-auto opacity-100" + : cn( + "max-h-[168px] overflow-hidden opacity-99", + isOverflowing && "mask-b-from-1%", + ), + )} > -
-
+ {/* Trigger-event cards always render from `parts`, even when the + message also carries a tiptapDoc (automation runs do). */} + {parts.map((part, index) => + part.type === "data-trigger-event" ? ( + + ) : null, )} - > -
- {/* Trigger-event cards always render from `parts`, even when the - message also carries a tiptapDoc (automation runs do). */} - {parts.map((part, index) => - part.type === "data-trigger-event" ? ( - - ) : null, - )} - {hasTiptapDoc ? ( - - ) : ( - parts.map((part, index) => { - if (part.type === "text") { - // The guard-wrapped event text the model reads sits - // directly after its `data-trigger-event` card — render the - // card, not the raw JSON. The automation's own instruction - // text (no card before it) still renders. - if (parts[index - 1]?.type === "data-trigger-event") { - return null; - } - return ( - - ); + {hasTiptapDoc ? ( + + ) : ( + parts.map((part, index) => { + if (part.type === "text") { + // The guard-wrapped event text the model reads sits + // directly after its `data-trigger-event` card — render the + // card, not the raw JSON. The automation's own instruction + // text (no card before it) still renders. + if (parts[index - 1]?.type === "data-trigger-event") { + return null; } - return null; - }) - )} -
+ return ( + + ); + } + return null; + }) + )}
-
+
); diff --git a/apps/web/src/components/chat/open-file-chip.tsx b/apps/web/src/components/chat/open-file-chip.tsx deleted file mode 100644 index 0ea86cc4725..00000000000 --- a/apps/web/src/components/chat/open-file-chip.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** The composer's word that the agent will see the open Library file, and the - * way to leave it out of this question. */ - -import { XClose } from "@untitledui/icons"; -import { IconButton } from "@decocms/ui/components/icon-button.tsx"; -import { FileTypeIcon } from "@/components/file-type-icon"; -import { useOpenLibraryFile } from "@/hooks/use-open-library-file"; -import { useT } from "@/i18n/use-t.ts"; -import { basename } from "@/layouts/library/location"; - -export function OpenFileChip() { - const t = useT(); - const openFile = useOpenLibraryFile(); - if (!openFile) return null; - const name = basename(openFile.path); - return ( -
- - - {name} - - - - -
- ); -} diff --git a/apps/web/src/components/chat/queue-tray.tsx b/apps/web/src/components/chat/queue-tray.tsx index bfb9b3b6087..f63e51aacea 100644 --- a/apps/web/src/components/chat/queue-tray.tsx +++ b/apps/web/src/components/chat/queue-tray.tsx @@ -5,14 +5,12 @@ * in `input.tsx` whenever the active thread has queued items; renders * nothing otherwise. * - * Layout (V1, settled via visual companion 2026-07-08): quiet header — - * "N queued" left, an outlined "Send next" button right — above a flat - * numbered list. Rows are uniform (`number chip · text · ✕ on hover`); - * numbers make the FIFO order legible, and the single header button makes - * it honest that only the HEAD can be promoted. + * Layout: each queued message is a dashed bubble on the right, where it + * will land once sent, with its time and actions under it. Only the HEAD + * offers "Send now", since only it can be promoted. * * Actions: - * - Send next (header): cancels the current turn so the gate FIFO + * - Send now (head only): cancels the current turn so the gate FIFO * promotes the first queued message — same stop() as the composer's * stop button. * - Remove (per row): cancels the queued gate workflow, then drops the @@ -28,11 +26,6 @@ * (`editQueuedMessage` in chat-context still implements the composition). */ import { Button } from "@decocms/ui/components/button.tsx"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@decocms/ui/components/tooltip.tsx"; import { ArrowUp, XClose } from "@untitledui/icons"; import { useT } from "@/i18n/use-t.ts"; import { useChatStream } from "./context"; @@ -50,52 +43,39 @@ export function QueueTray({ taskId }: { taskId: string }) { if (queued.length === 0) return null; return ( -
-
- - {t( - queued.length === 1 - ? "chat.queueTray.queuedMessage" - : "chat.queueTray.queuedMessages", - { count: queued.length }, - )} - - - - - - -

{t("chat.queueTray.sendNow")}

-
-
-
+
{queued.map((item, index) => (
- {/* Chip surface must differ from the tray surface in BOTH modes — - the tray is bg-card light / bg-muted dark, so bg-muted would - vanish in dark mode. */} - - {index + 1} - - +
{item.text} - -
+
+
+ + {new Date(item.enqueuedAt).toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + })} + + {index === 0 && ( + + )} + )} {panel.searchDialog} diff --git a/apps/web/src/components/chat/tier-trigger.tsx b/apps/web/src/components/chat/tier-trigger.tsx index 8c67223eef8..77efb8ddc7c 100644 --- a/apps/web/src/components/chat/tier-trigger.tsx +++ b/apps/web/src/components/chat/tier-trigger.tsx @@ -128,21 +128,21 @@ export function TierTriggerPure({ tier, pillIcon, groups }: PureProps) { aria-label={pillLabel} className={cn( "text-muted-foreground hover:text-foreground transition-[gap] duration-200 shrink min-w-0", - "gap-0 @[320px]/chat-bottom:gap-1.5", + "gap-0 @[480px]/chat-bottom:gap-1.5", )} > {pillIcon} {tierLabels[tier]} diff --git a/apps/web/src/components/chat/tiptap/input.tsx b/apps/web/src/components/chat/tiptap/input.tsx index 71a48d2e8ff..dafc22d9307 100644 --- a/apps/web/src/components/chat/tiptap/input.tsx +++ b/apps/web/src/components/chat/tiptap/input.tsx @@ -94,7 +94,7 @@ export function TiptapProvider({ "aria-multiline": "true", "aria-label": placeholder ?? DEFAULT_PLACEHOLDER, class: - "prose prose-sm max-w-none focus:outline-none w-full h-full text-base p-[18px]", + "prose prose-sm max-w-none focus:outline-none w-full h-full text-[14px] max-md:text-base p-[18px]", }, handleKeyDown: (_view: EditorView, event: KeyboardEvent) => { if ( diff --git a/apps/web/src/components/chat/tools-popover.tsx b/apps/web/src/components/chat/tools-popover.tsx index e488457c2fb..815f2d7f43e 100644 --- a/apps/web/src/components/chat/tools-popover.tsx +++ b/apps/web/src/components/chat/tools-popover.tsx @@ -339,14 +339,14 @@ export function ToolsPopover({ aria-label={t("chat.toolsPopover.tools")} className={cn( "text-muted-foreground hover:text-foreground transition-[gap] duration-200 shrink min-w-0", - "gap-0 @[320px]/chat-bottom:gap-1.5", + "gap-0 @[480px]/chat-bottom:gap-1.5", )} > {t("chat.toolsPopover.tools")} diff --git a/apps/web/src/components/chat/usage-stats.tsx b/apps/web/src/components/chat/usage-stats.tsx index e943852ca9d..4bc8bcedfd5 100644 --- a/apps/web/src/components/chat/usage-stats.tsx +++ b/apps/web/src/components/chat/usage-stats.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Tooltip, TooltipTrigger, @@ -75,15 +76,21 @@ interface MessageStatsBarProps { usage: UsageStatsType | null | undefined; /** Reasoning duration in milliseconds */ duration?: number | null; + /** When the message was sent, kept on the same line as its cost. */ + timestamp?: ReactNode; } -export function MessageStatsBar({ usage, duration }: MessageStatsBarProps) { +export function MessageStatsBar({ + usage, + duration, + timestamp, +}: MessageStatsBarProps) { const showCost = useModelDisclosure(); const hasDuration = duration != null && duration > 0; const hasCost = showCost && usage != null && (usage.cost ?? 0) > 0; const hasTokens = usage != null && (usage.totalTokens ?? 0) > 0; - if (!hasDuration && !hasCost && !hasTokens) return null; + if (!hasDuration && !hasCost && !hasTokens) return timestamp ?? null; const durationLabel = hasDuration ? formatDuration(duration! / 1000) : null; @@ -155,6 +162,12 @@ export function MessageStatsBar({ usage, duration }: MessageStatsBarProps) { )} + {timestamp && ( + <> + · + {timestamp} + + )}
); } @@ -229,7 +242,7 @@ export function SessionStats({ )} /> - + {pct.toFixed(0)}%{cost > 0 ? ` · $${cost.toFixed(2)}` : ""} diff --git a/apps/web/src/components/chat/voice/index.tsx b/apps/web/src/components/chat/voice/index.tsx index b17fdef4ade..fefdf65204c 100644 --- a/apps/web/src/components/chat/voice/index.tsx +++ b/apps/web/src/components/chat/voice/index.tsx @@ -39,11 +39,8 @@ export function ChatVoiceProvider({ }: PropsWithChildren<{ layout: ChatLayoutState }>) { const isMobile = useIsMobile(); const visible = isMobile - ? resolveMobileSurface({ - visibility: layout, - threadVisibilityExplicit: layout.threadVisibilityExplicit, - }) === "chat" - : layout.threadOpen || !layout.contentOpen; + ? resolveMobileSurface(layout) === "chat" + : layout.threadOpen; return {children}; } diff --git a/apps/web/src/components/chat/work-card.tsx b/apps/web/src/components/chat/work-card.tsx new file mode 100644 index 00000000000..27fc208c703 --- /dev/null +++ b/apps/web/src/components/chat/work-card.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { cn } from "@decocms/ui/lib/utils.ts"; + +/** One frame for the work a chat did (a draft, a task): what, where, state. */ +export function WorkCard({ + icon, + title, + subtitle, + status, + detail, + action, + className, +}: { + icon: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + status?: ReactNode; + detail?: ReactNode; + action?: ReactNode; + className?: string; +}) { + return ( +
+
+
{icon}
+
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ {status && ( + + {status} + + )} +
+ {(detail || action) && ( +
+ {detail} + {action} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/connect/connect-key.ts b/apps/web/src/components/connect/connect-key.ts new file mode 100644 index 00000000000..0e7f71b3f67 --- /dev/null +++ b/apps/web/src/components/connect/connect-key.ts @@ -0,0 +1,3 @@ +/** Keys minted from Settings › Connect carry this prefix, so their presence + * tells the home an agent is already connected. */ +export const CONNECT_KEY_PREFIX = "Connect: "; diff --git a/apps/web/src/components/header/shell-breadcrumb.tsx b/apps/web/src/components/header/shell-breadcrumb.tsx index fa91db356f7..de30c0179e5 100644 --- a/apps/web/src/components/header/shell-breadcrumb.tsx +++ b/apps/web/src/components/header/shell-breadcrumb.tsx @@ -9,38 +9,19 @@ import { TooltipContent, TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; -import { useRouteThreadId, useRouteVirtualMcpId } from "@/layouts/thread-route"; -import { useThreads } from "@/components/chat/store/hooks"; +import { getWellKnownDecopilotVirtualMCP, useProjectContext } from "@/sdk"; import { usePanelActions } from "@/layouts/shell-layout"; import { useT } from "@/i18n/use-t.ts"; -/** - * New-chat button — starts a fresh chat with the active agent (reusing an - * existing empty "New chat" for it when there is one, so empties don't pile - * up). Lives in the chat panel header, so "new chat" stays reachable when the - * sidebar's own new-chat action is tucked away. - */ +/** A fresh chat with the org's assistant; the page you're on is only context. */ export function NewChatCrumb() { const t = useT(); - const { threads } = useThreads(); + const { org } = useProjectContext(); const { createNewTask } = usePanelActions(); - /** - * The scope of the page, not of the legacy grammar: on - * `/$org/agents/{-$project}` the project segment names the agent, so a new chat - * started there belongs to that project rather than to the Super Agent. - */ - const activeAgentId = useRouteVirtualMcpId(); - const routeThreadId = useRouteThreadId(); - - /** A new chat inherits the viewed thread's branch so it lands on the same - * sandbox; `null` where the route names no thread → server default. */ - const currentBranch = - threads.find((thread) => thread.id === routeThreadId)?.branch ?? null; - // ALWAYS create a fresh chat — never reuse/refocus an existing empty one. const handleNewChat = () => { - void createNewTask(activeAgentId, currentBranch); + void createNewTask(getWellKnownDecopilotVirtualMCP(org.id).id, null); }; return ( diff --git a/apps/web/src/components/org-home/connect-pill.tsx b/apps/web/src/components/org-home/connect-pill.tsx index 4165870468d..cec205f1648 100644 --- a/apps/web/src/components/org-home/connect-pill.tsx +++ b/apps/web/src/components/org-home/connect-pill.tsx @@ -16,6 +16,9 @@ import { ArrowRight } from "@untitledui/icons"; import type { ReactNode } from "react"; import { ClaudeCodeIcon, CodexIcon } from "@/components/chat/agent-icons"; import { CursorIcon } from "@/components/connect/client-icons"; +import { CONNECT_KEY_PREFIX } from "@/components/connect/connect-key"; +import { useApiKeysList } from "@/hooks/use-api-keys"; +import { useCapability } from "@/hooks/use-capability"; import { useProjectContext } from "@/sdk"; import { useT } from "@/i18n/use-t.ts"; import { track } from "@/lib/posthog-client"; @@ -90,3 +93,40 @@ export function ConnectPill({ dense = true }: { dense?: boolean }) { ); } + +/** The home's version: a slim row over the page that leaves once a key minted + * from Settings › Connect exists. */ +export function ConnectBanner() { + const t = useT(); + const { org } = useProjectContext(); + const { granted } = useCapability("api-keys:manage"); + const { data: keys } = useApiKeysList({ enabled: granted }); + // Wait for the list, so a connected org never flashes the banner. + if (granted && !keys) return null; + if (keys?.some((k) => k.name.startsWith(CONNECT_KEY_PREFIX))) return null; + + return ( + track("connect_clients_opened", { source: "org_home" })} + className="group hidden items-center gap-3 rounded-xl bg-card px-4 py-2.5 text-sm shadow-edge transition-colors hover:bg-accent md:flex" + > + + + {t("home.orgHome.connectPill")} + + + + ); +} diff --git a/apps/web/src/components/org-home/today-page.tsx b/apps/web/src/components/org-home/today-page.tsx index 44bfc2c4509..fb27dc40d9a 100644 --- a/apps/web/src/components/org-home/today-page.tsx +++ b/apps/web/src/components/org-home/today-page.tsx @@ -12,7 +12,7 @@ import { import { useOrgFlag } from "@/hooks/use-organization-settings"; import { Suspense } from "react"; import { Page } from "@/components/page"; -import { ConnectPill } from "./connect-pill"; +import { ConnectBanner } from "./connect-pill"; import { DailyBriefCard } from "./daily-brief-card"; import { runningAgents, shippedSince, tasksNeedingMe } from "./daily-pulse"; import { @@ -187,7 +187,7 @@ export function TodayPage() { return ( - }> + {canManageProjects && } @@ -196,6 +196,7 @@ export function TodayPage() { /** `min-h-full` gives the empty state a height to centre in. */ className="flex min-h-full flex-col gap-10" > + {taskIntakeEnabled && ( diff --git a/apps/web/src/components/page/breadcrumbs.tsx b/apps/web/src/components/page/breadcrumbs.tsx index 302baf8199f..67052a470c8 100644 --- a/apps/web/src/components/page/breadcrumbs.tsx +++ b/apps/web/src/components/page/breadcrumbs.tsx @@ -133,7 +133,7 @@ function BreadcrumbTrail({ items }: { items: readonly BreadcrumbItem[] }) { const [width, ref] = useElementWidth(); const entries = collapseBreadcrumbs( items, - width >= 0 && width < 320, + width >= 0 && width < 200, MAX_UNCOLLAPSED_ITEMS, ); return ( diff --git a/apps/web/src/components/sidebar/task-groups/group-threads.test.ts b/apps/web/src/components/sidebar/task-groups/group-threads.test.ts index ded5eac21dd..06a884ac48d 100644 --- a/apps/web/src/components/sidebar/task-groups/group-threads.test.ts +++ b/apps/web/src/components/sidebar/task-groups/group-threads.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { Task } from "@/components/chat/task/types"; import { + groupThreadsByDate, groupThreadsByVirtualMcp, groupThreadsByStatus, } from "./group-threads"; @@ -247,3 +248,41 @@ describe("groupThreadsByStatus", () => { ).toEqual(["a"]); }); }); + +describe("groupThreadsByDate", () => { + // Local noon, so the calendar-day math holds in any test timezone. + const now = new Date(2026, 4, 20, 12); + const at = (daysAgo: number) => + new Date(2026, 4, 20 - daysAgo, 9).toISOString(); + + it("buckets by calendar day and keeps the list order", () => { + const result = groupThreadsByDate( + [ + t({ id: "a", updated_at: at(0) }), + t({ id: "b", updated_at: at(0) }), + t({ id: "c", updated_at: at(1) }), + t({ id: "d", updated_at: at(3) }), + t({ id: "e", updated_at: at(12) }), + t({ id: "f", updated_at: at(90) }), + ], + now, + ); + expect(result.map((g) => [g.bucket, g.threads.map((th) => th.id)])).toEqual( + [ + ["today", ["a", "b"]], + ["yesterday", ["c"]], + ["week", ["d"]], + ["month", ["e"]], + ["older", ["f"]], + ], + ); + }); + + it("drops empty buckets", () => { + expect( + groupThreadsByDate([t({ id: "a", updated_at: at(40) })], now).map( + (g) => g.bucket, + ), + ).toEqual(["older"]); + }); +}); diff --git a/apps/web/src/components/sidebar/task-groups/group-threads.ts b/apps/web/src/components/sidebar/task-groups/group-threads.ts index dd156655f50..401d30356f5 100644 --- a/apps/web/src/components/sidebar/task-groups/group-threads.ts +++ b/apps/web/src/components/sidebar/task-groups/group-threads.ts @@ -106,3 +106,51 @@ export function groupThreadsByVirtualMcp( } return result; } + +export type DateBucket = "today" | "yesterday" | "week" | "month" | "older"; + +const DATE_BUCKETS: DateBucket[] = [ + "today", + "yesterday", + "week", + "month", + "older", +]; + +/** Calendar days between two local dates; 0 is the same day. */ +function daysBetween(earlier: Date, later: Date): number { + const start = new Date( + earlier.getFullYear(), + earlier.getMonth(), + earlier.getDate(), + ); + const end = new Date(later.getFullYear(), later.getMonth(), later.getDate()); + return Math.round((end.getTime() - start.getTime()) / 86_400_000); +} + +function dateBucket(updatedAt: string | undefined, now: Date): DateBucket { + if (!updatedAt) return "older"; + const days = daysBetween(new Date(updatedAt), now); + if (days <= 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return "week"; + if (days < 30) return "month"; + return "older"; +} + +/** Threads under date headings, keeping their order; empty buckets are dropped. */ +export function groupThreadsByDate( + threads: Task[], + now: Date, +): { bucket: DateBucket; threads: Task[] }[] { + const byBucket = new Map( + DATE_BUCKETS.map((bucket) => [bucket, []]), + ); + for (const thread of threads) { + byBucket.get(dateBucket(thread.updated_at, now))?.push(thread); + } + return DATE_BUCKETS.map((bucket) => ({ + bucket, + threads: byBucket.get(bucket) ?? [], + })).filter((group) => group.threads.length > 0); +} diff --git a/apps/web/src/components/sidebar/task-groups/my-threads-section.tsx b/apps/web/src/components/sidebar/task-groups/my-threads-section.tsx index 8dc84dd4101..7df4b49b302 100644 --- a/apps/web/src/components/sidebar/task-groups/my-threads-section.tsx +++ b/apps/web/src/components/sidebar/task-groups/my-threads-section.tsx @@ -1,16 +1,25 @@ import { Inbox01 } from "@untitledui/icons"; import { TaskRow } from "@/layouts/tasks-panel/task-row"; import type { Task } from "@/components/chat/task/types"; -import { groupThreadsByStatus } from "./group-threads"; +import { groupThreadsByDate, groupThreadsByStatus } from "./group-threads"; import { StatusGroup } from "./task-group"; import { ShowMoreButton } from "./show-more-button"; import type { SidebarFilters } from "./next-page-offset"; -import { useT } from "@/i18n/use-t.ts"; +import { useT, type TranslationKey } from "@/i18n/use-t.ts"; +import type { DateBucket } from "./group-threads"; + +const DATE_BUCKET_LABEL_KEYS: Record = { + today: "sidebar.myThreadsSection.today", + yesterday: "sidebar.myThreadsSection.yesterday", + week: "sidebar.myThreadsSection.previousWeek", + month: "sidebar.myThreadsSection.previousMonth", + older: "sidebar.myThreadsSection.older", +}; /** * The current user's threads, all agents mixed. Two renderings toggled by the * sidebar's group-by control: - * - "flat": a single chronological list (each row shows its agent icon). + * - "flat": a chronological list under date headings (Today, Yesterday, …). * - "status": collapsible groups by thread status. * * In flat mode, pagination piggybacks on the global thread store's @@ -80,20 +89,25 @@ export function MyThreadsSection({ return ( <> - {threads.map((task) => ( - onSelectTask(task)} - onArchive={ - canArchive && task.created_by === filters.currentUserId - ? () => onArchiveTask(task) - : undefined - } - showAutomationBadge={Boolean(task.trigger_id)} - showAgentIcon - /> + {groupThreadsByDate(threads, new Date()).map((group) => ( +
+

+ {t(DATE_BUCKET_LABEL_KEYS[group.bucket])} +

+ {group.threads.map((task) => ( + onSelectTask(task)} + onArchive={ + canArchive && task.created_by === filters.currentUserId + ? () => onArchiveTask(task) + : undefined + } + /> + ))} +
))} {(hasMore || isFetchingMore) && ( diff --git a/apps/web/src/components/sidebar/task-groups/thread-filters-popover.tsx b/apps/web/src/components/sidebar/task-groups/thread-filters-popover.tsx index 89b32243850..b334397e7e5 100644 --- a/apps/web/src/components/sidebar/task-groups/thread-filters-popover.tsx +++ b/apps/web/src/components/sidebar/task-groups/thread-filters-popover.tsx @@ -1,65 +1,38 @@ -/** - * The thread list's View / Type / Scope filter popover, shared by the sidebar - * list and the chat panel's threads menu. Reads and writes the state owned by - * `useThreadsPanel`. - */ +/** The chat list's menu for grouping and whose chats it shows. */ -import type { ReactNode } from "react"; -import { Activity, FilterLines, Rows01 } from "@untitledui/icons"; +import { Check, FilterLines } from "@untitledui/icons"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@decocms/ui/components/popover.tsx"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@decocms/ui/components/dropdown-menu.tsx"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@decocms/ui/components/tooltip.tsx"; -import { - ToggleGroup, - ToggleGroupItem, -} from "@decocms/ui/components/toggle-group.tsx"; import { ToolbarIconButton } from "@/components/toolbar-icon-button"; import { track } from "@/lib/posthog-client"; import { useT } from "@/i18n/use-t.ts"; -import type { ThreadsPanel } from "./use-threads-panel"; +import type { ThreadGroupBy, ThreadsPanel } from "./use-threads-panel"; -/** One labelled segmented control (View / Type / Scope) in the popover. */ -function FilterRow({ +function Choice({ label, - value, - onChange, - options, + selected, + onSelect, }: { label: string; - value: T; - onChange: (value: T) => void; - options: { value: T; label: string; icon?: ReactNode }[]; + selected: boolean; + onSelect: () => void; }) { return ( -
- {label} - v && onChange(v as T)} - aria-label={label} - className="gap-0.5 rounded-lg border border-border bg-muted/50 p-0.5" - > - {options.map((opt) => ( - - {opt.icon} - {opt.label} - - ))} - -
+ + {label} + {selected && } + ); } @@ -71,11 +44,15 @@ export function ThreadFiltersPopover({ className?: string; }) { const t = useT(); + const groupBy = (value: ThreadGroupBy) => { + track("tasks_panel_group_by_changed", { to_value: value }); + panel.setGroupBy(value); + }; return ( - + - + )} - + {t("sidebar.taskGroupsList.filterChats")} - - { - track("tasks_panel_group_by_changed", { to_value: v }); - panel.setGroupBy(v); - }} - options={[ - { - value: "flat", - label: t("sidebar.taskGroupsList.viewList"), - icon: , - }, - { - value: "status", - label: t("sidebar.taskGroupsList.viewStatus"), - icon: , - }, - ]} + + + {t("sidebar.taskGroupsList.filterView")} + + groupBy("flat")} + /> + groupBy("status")} /> - { - track("tasks_panel_filter_changed", { to_value: v }); - panel.setTypeFilter(v); - }} - options={[ - { value: "all", label: t("sidebar.taskGroupsList.typeAll") }, - { value: "manual", label: t("sidebar.taskGroupsList.typeChats") }, - { - value: "automation", - label: t("sidebar.taskGroupsList.typeAuto"), - }, - ]} + + + {t("sidebar.taskGroupsList.filterScope")} + + panel.setShowAll(false)} /> - panel.setShowAll(v === "team")} - options={[ - { value: "mine", label: t("sidebar.taskGroupsList.scopeMine") }, - { value: "team", label: t("sidebar.taskGroupsList.scopeTeam") }, - ]} + panel.setShowAll(true)} /> - - + + ); } diff --git a/apps/web/src/components/sidebar/task-groups/use-threads-panel.tsx b/apps/web/src/components/sidebar/task-groups/use-threads-panel.tsx index 9a60da4d5a8..f513e6f7c8c 100644 --- a/apps/web/src/components/sidebar/task-groups/use-threads-panel.tsx +++ b/apps/web/src/components/sidebar/task-groups/use-threads-panel.tsx @@ -13,11 +13,7 @@ import { getWellKnownDecopilotVirtualMCP, useProjectContext } from "@/sdk"; import { authClient } from "@/lib/auth-client"; import { useThreadActions, useThreads } from "@/components/chat/store/hooks"; import { usePanelActions } from "@/layouts/shell-layout"; -import { - resolveActiveAgentId, - useRouteAgentId, - useRouteThreadId, -} from "@/layouts/thread-route"; +import { useRouteThreadId } from "@/layouts/thread-route"; import { track } from "@/lib/posthog-client"; import type { Task } from "@/components/chat/task/types"; import { findReusableNewChat } from "@/lib/reusable-new-chat"; @@ -28,12 +24,12 @@ import { forgetThreadLayout } from "@/lib/thread-layout-memory"; import { GlobalSearchDialog } from "@/layouts/tasks-panel/global-search-dialog"; import { findArchiveFallback } from "./archive-fallback"; import { MyThreadsSection } from "./my-threads-section"; -import type { SidebarFilters, SidebarTypeFilter } from "./next-page-offset"; +import type { SidebarFilters } from "./next-page-offset"; export type ThreadGroupBy = "flat" | "status"; export interface ThreadsPanel { - /** Threads to render, already scope- and type-filtered, newest first. */ + /** Threads to render, already scope-filtered, newest first. Automation runs are left out. */ threads: Task[]; activeTaskId: string | null; filters: SidebarFilters; @@ -42,8 +38,6 @@ export interface ThreadsPanel { loadMore: () => void; groupBy: ThreadGroupBy; setGroupBy: (value: ThreadGroupBy) => void; - typeFilter: SidebarTypeFilter; - setTypeFilter: (value: SidebarTypeFilter) => void; showAll: boolean; setShowAll: (value: boolean) => void; /** True when any filter is off its default — drives the filter button's dot. */ @@ -51,7 +45,8 @@ export interface ThreadsPanel { selectTask: (task: Task) => void; archiveTask: (task: Task) => void; newThread: () => void; - openSearch: () => void; + /** Opens the org-wide search, starting from `query` when given. */ + openSearch: (query?: string) => void; /** The global search dialog, mounted once `openSearch` has fired at least once. */ searchDialog: React.ReactNode; } @@ -98,20 +93,8 @@ export function useThreadsPanel({ const navigate = useNavigate(); const { setTaskId, createNewTask } = usePanelActions(); - const routeAgentId = useRouteAgentId(); /** Route-aware: `$taskId` on the legacy route, `?thread=` on a destination. */ const activeTaskId = useRouteThreadId(); - /** - * The recipient is the agent the ROUTE names — its `{-$project}` segment — - * falling back to the open thread's own agent on an org-level destination, - * which names none. Reading `?virtualmcpid=` made this control hand a new - * chat to the Super Agent on every `/$org/agents/`. - */ - const activeAgentId = resolveActiveAgentId({ - routeAgentId, - threadVirtualMcpId: allThreads.find((thread) => thread.id === activeTaskId) - ?.virtual_mcp_id, - }); const closeAfterNavigation = () => { onNavigate?.(); }; @@ -120,7 +103,6 @@ export function useThreadsPanel({ (b.updated_at ?? "").localeCompare(a.updated_at ?? ""), ); - const [typeFilter, setTypeFilter] = useState("all"); const [groupBy, setGroupBy] = useState("flat"); const [showAll, setShowAll] = useLocalStorage( "sidebar-threads-scope-all", @@ -128,8 +110,10 @@ export function useThreadsPanel({ ); const [searchOpen, setSearchOpen] = useState(false); const [searchEverOpened, setSearchEverOpened] = useState(false); - const openSearch = () => { + const [searchSeed, setSearchSeed] = useState({ query: "", count: 0 }); + const openSearch = (query = "") => { track("tasks_panel_search_opened"); + setSearchSeed((seed) => ({ query, count: seed.count + 1 })); setSearchEverOpened(true); setSearchOpen(true); }; @@ -138,7 +122,8 @@ export function useThreadsPanel({ * `member: "mine"` scopes the query to the current user. */ const filters: SidebarFilters = { - type: typeFilter, + // Automation runs live with their automations; the chat list is people's own conversations. + type: "manual", member: "mine", currentUserId: currentUserId ?? null, }; @@ -166,15 +151,8 @@ export function useThreadsPanel({ ? threads.filter((thread) => thread.created_by === currentUserId) : []; - const typeFiltered = (threads: Task[]) => { - if (typeFilter === "automation") { - return threads.filter((thread) => Boolean(thread.trigger_id)); - } - if (typeFilter === "manual") { - return threads.filter((thread) => !thread.trigger_id); - } - return threads; - }; + const conversations = (threads: Task[]) => + threads.filter((thread) => !thread.trigger_id); /** * Every agent's threads, mixed — an agent's own list lives on its home view. @@ -183,8 +161,8 @@ export function useThreadsPanel({ */ const visibleScopedThreads = hideAbandonedNewChats( showAll - ? typeFiltered(sortedThreads) - : typeFiltered(mineFiltered(sortedThreads)), + ? conversations(sortedThreads) + : conversations(mineFiltered(sortedThreads)), activeTaskId, ); @@ -219,9 +197,9 @@ export function useThreadsPanel({ setTaskId(task.id, task.virtual_mcp_id); }; - /** New chat on the current agent — reuse its empty one if any, else create. */ + /** New chat with the org's assistant — reuse its empty one if any, else create. */ const newThread = () => { - const currentAgentId = activeAgentId ?? decopilotId; + const currentAgentId = decopilotId; track("sidebar_new_thread_clicked", { virtual_mcp_id: currentAgentId }); closeAfterNavigation(); const existing = findReusableNewChat( @@ -251,17 +229,21 @@ export function useThreadsPanel({ loadMore: () => void fetchNextPage(), groupBy, setGroupBy, - typeFilter, - setTypeFilter, showAll, setShowAll, - filtersActive: groupBy !== "flat" || typeFilter !== "all" || showAll, + filtersActive: groupBy !== "flat" || showAll, selectTask, archiveTask, newThread, openSearch, searchDialog: searchEverOpened ? ( - + // Remounts per opening, so each search starts from what was typed. + ) : null, }; } diff --git a/apps/web/src/components/sidebar/thread-button.tsx b/apps/web/src/components/sidebar/thread-button.tsx index 169abf2e89e..962de785fe9 100644 --- a/apps/web/src/components/sidebar/thread-button.tsx +++ b/apps/web/src/components/sidebar/thread-button.tsx @@ -12,7 +12,6 @@ import { import { ToolbarIconButton } from "@/components/toolbar-icon-button"; import { useInSettings } from "@/hooks/use-in-settings"; import { useT } from "@/i18n/use-t"; -import { usePanelActions } from "@/layouts/shell-layout"; import { useProjectContext } from "@/sdk"; const ThreadButtonSlotContext = createContext<{ @@ -39,6 +38,8 @@ function useThreadButtonSlot() { return slot; } +/** Mobile only: it swaps between the chat and the main panel. On desktop + * the chat never closes, and the main panel's header brings it back. */ function ThreadButton({ open, onToggle, @@ -47,7 +48,7 @@ function ThreadButton({ onToggle: () => void; }) { const t = useT(); - const { isMobile, setOpenMobile } = useSidebar(); + const { setOpenMobile } = useSidebar(); const label = t(open ? "page.closeThread" : "page.openThread"); return ( @@ -59,7 +60,7 @@ function ThreadButton({ className="size-7 shrink-0 group-data-[state=collapsed]/sidebar:mx-auto group-data-[state=collapsed]/sidebar:size-8" onClick={() => { onToggle(); - if (isMobile) setOpenMobile(false); + setOpenMobile(false); }} > @@ -88,7 +89,7 @@ export function SidebarThreadButton() { const { isMobile } = useSidebar(); const navigate = useNavigate(); const { org } = useProjectContext(); - const { openSidePanel } = usePanelActions(); + if (!isMobile) return null; return ( <>
({ ...prev, ...mobileSurfaceSearch("chat") }), replace: true, }); - } else { - void openSidePanel(); } }} /> diff --git a/apps/web/src/hooks/use-agent-entry-threads.ts b/apps/web/src/hooks/use-agent-entry-threads.ts deleted file mode 100644 index f3762ee0fbe..00000000000 --- a/apps/web/src/hooks/use-agent-entry-threads.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * useAgentEntryThreads — the current user's most recent threads on one agent, - * read straight from the server. - * - * The shared thread feed is the user's latest page across the whole org, so a - * project's last draft drops out of it once enough chats elsewhere are newer. - * Entering the project then resolved no entry thread and minted a fresh draft - * next to the existing one. This scoped read is the fallback the entry resolver - * consults before minting. - * - * Fail-open: an error resolves to no threads, so entry falls back to minting as - * it did before rather than blocking the project. - */ - -import { useQuery } from "@tanstack/react-query"; -import type { Task } from "@/components/chat/task/types"; -import { authClient } from "@/lib/auth-client"; -import { KEYS } from "@/lib/query-keys"; -import { useStudioTools } from "@/lib/studio-tools"; -import { useProjectContext } from "@/sdk"; - -/** Covers the production and other-runtime threads the resolver skips. */ -const ENTRY_THREADS_LIMIT = 50; - -export type AgentEntryThreads = - | { status: "idle" } - | { status: "loading" } - | { status: "ready"; threads: Task[] }; - -export function useAgentEntryThreads( - virtualMcpId: string, - enabled: boolean, -): AgentEntryThreads { - const { org } = useProjectContext(); - const studio = useStudioTools(); - const { data: session } = authClient.useSession(); - const userId = session?.user?.id ?? ""; - - const query = useQuery({ - queryKey: KEYS.agentEntryThreads(org.id, userId, virtualMcpId), - enabled: enabled && !!userId, - // Overrides the app's 1-minute default: every entry re-reads the drafts. - staleTime: 0, - // Entry resolution is a one-shot decision on entering the project. While a - // refetch runs the route shows its "resolving" spinner in place of the - // open view, so a focus or reconnect refetch would unmount whatever the - // user has open (an MCP app reloads from scratch). - refetchOnWindowFocus: false, - refetchOnReconnect: false, - queryFn: async (): Promise => { - try { - const result = await studio.call("COLLECTION_THREADS_LIST", { - limit: ENTRY_THREADS_LIMIT, - offset: 0, - orderBy: [{ field: ["updated_at"], direction: "desc" }], - where: { - hidden: false, - created_by: "me", - virtual_mcp_id: virtualMcpId, - }, - }); - return result.items; - } catch { - return []; - } - }, - }); - - if (!enabled || !userId) return { status: "idle" }; - // A list cached by an earlier entry can predate drafts made since. Enabling - // over stale data reports `isFetching` on that same render, so wait for it. - if (!query.data || query.isFetching) return { status: "loading" }; - return { status: "ready", threads: query.data }; -} diff --git a/apps/web/src/hooks/use-chat-layout-state.test.ts b/apps/web/src/hooks/use-chat-layout-state.test.ts index 58abf1db925..ae6d77c98a8 100644 --- a/apps/web/src/hooks/use-chat-layout-state.test.ts +++ b/apps/web/src/hooks/use-chat-layout-state.test.ts @@ -4,298 +4,115 @@ import { mobileSurfaceSearch, resolveDefaultPanelState, resolveMobileSurface, - resolveChatLayoutPanelAction, } from "./use-chat-layout-state"; describe("resolveDefaultPanelState", () => { - const absentSearch = { + const base = { + entityMetadata: null, panelNamed: false, - threadVisibilityExplicit: false, + contentFullscreen: false, }; - test("defaults to Chat when no layout metadata exists", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: null, - ...absentSearch, - }), - ).toEqual({ threadOpen: true, contentOpen: false }); - }); - - test("a Chat default opens Chat and closes Main", () => { + test("defaults to the chat alone when no view is named", () => { + expect(resolveDefaultPanelState(base)).toEqual({ + threadOpen: true, + contentOpen: false, + }); expect( resolveDefaultPanelState({ + ...base, entityMetadata: { defaultMainView: { type: "chat" } }, - ...absentSearch, }), ).toEqual({ threadOpen: true, contentOpen: false }); }); - test("a non-Chat default opens Main without a side panel", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "settings" } }, - ...absentSearch, - }), - ).toEqual({ threadOpen: false, contentOpen: true }); - }); - - test("a Content default with chatDefaultOpen:false keeps the chat closed", () => { - // The reported bug: an agent whose main view is Content and that opted out - // of the chat panel must land Main-only when no `sidepanel` param is present - // (the switch/new-chat paths now omit it — see resolve-task-switch-search). - expect( - resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "content" }, - chatDefaultOpen: false, - }, - ...absentSearch, - }), - ).toEqual({ threadOpen: false, contentOpen: true }); - }); - - test("a thread that already has messages opens the chat despite chatDefaultOpen:false", () => { - // Returning to a chat you've talked in reopens it even when the agent opts out of the chat panel. - expect( - resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "content" }, - chatDefaultOpen: false, - }, - ...absentSearch, - threadHasMessages: true, - }), - ).toEqual({ threadOpen: true, contentOpen: true }); - }); - - test("an empty thread keeps a chatDefaultOpen:false agent chat closed", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "content" }, - chatDefaultOpen: false, - }, - ...absentSearch, - threadHasMessages: false, - }), - ).toEqual({ threadOpen: false, contentOpen: true }); - }); - - test("an explicit sidepanel=false still hides the chat even with messages", () => { - // The user's URL param beats the messages-present default. - expect( - resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "content" }, - chatDefaultOpen: false, - }, - panelNamed: false, - threadVisibilityExplicit: true, - sidePanelParamValue: false, - threadHasMessages: true, - }), - ).toEqual({ threadOpen: false, contentOpen: true }); + test.each([ + ["a path segment", { panelNamed: true }], + ["a route default", { routeDefaultMain: "board" }], + [ + "an agent default", + { entityMetadata: { defaultMainView: { type: "preview" } } }, + ], + ])("keeps the chat beside a view named by %s", (_, named) => { + expect(resolveDefaultPanelState({ ...base, ...named })).toEqual({ + threadOpen: true, + contentOpen: true, + }); }); - test("chatDefaultOpen maps to the Chat side panel", () => { + test("a project that opens with the chat closed starts full screen", () => { + const closed = { + ...base, + entityMetadata: { + defaultMainView: { type: "preview" }, + chatDefaultOpen: false, + }, + }; + expect(resolveDefaultPanelState(closed)).toEqual({ + threadOpen: false, + contentOpen: true, + }); + // ?sidepanel=true still brings the chat back there. expect( - resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "overview" }, - chatDefaultOpen: true, - }, - ...absentSearch, - }), + resolveDefaultPanelState({ ...closed, sidePanelParam: true }), ).toEqual({ threadOpen: true, contentOpen: true }); }); - test("an explicit side panel overrides the configured side-panel default", () => { + test("an unset setting opens the chat", () => { expect( resolveDefaultPanelState({ + ...base, entityMetadata: { - defaultMainView: { type: "overview" }, - chatDefaultOpen: false, + defaultMainView: { type: "preview" }, + chatDefaultOpen: null, }, - panelNamed: false, - threadVisibilityExplicit: true, - sidePanelParamValue: true, }), ).toEqual({ threadOpen: true, contentOpen: true }); }); - test("sidepanel=false keeps a non-Chat default Main-only", () => { + test("the full-screen preference hides the chat beside an open view", () => { expect( resolveDefaultPanelState({ - entityMetadata: { - defaultMainView: { type: "settings" }, - chatDefaultOpen: true, - }, - panelNamed: false, - threadVisibilityExplicit: true, - sidePanelParamValue: false, - }), - ).toEqual({ threadOpen: false, contentOpen: true }); - }); - - /** - * INVERTED: a destination route used to open its main view AND the chat - * beside it. Going to Tasks now shows Tasks alone — a route that names its - * own `defaultMain` collapses the side panel, and `/$org/agents` gets its open - * panel for free by declaring no `defaultMain` at all. - */ - test("a route default opens Main alone, collapsing the chat", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, - ...absentSearch, - routeDefaultMain: "board", + ...base, + panelNamed: true, + contentFullscreen: true, }), ).toEqual({ threadOpen: false, contentOpen: true }); }); - test("an explicit sidepanel=true reopens the chat on a route default", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, - panelNamed: false, - threadVisibilityExplicit: true, - sidePanelParamValue: true, - routeDefaultMain: "board", - }), - ).toEqual({ threadOpen: true, contentOpen: true }); - }); - - test("?mainpanel=false on a route default leaves the chat as the last open panel", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, - mainPanelParam: false, - panelNamed: false, - threadVisibilityExplicit: false, - routeDefaultMain: "board", - }), - /** The route default would collapse the chat, but the "at least one panel - * open" fallback keeps it showing once the main panel closes. */ - ).toEqual({ threadOpen: true, contentOpen: false }); - }); - - /** INVERTED: this was `?main=`. The view is a path segment now, so what - * opens the panel is the segment naming one — `panelNamed`. */ - test("a named view opens Main alongside a Chat default", () => { + test("an explicit ?sidepanel=true shows the chat over full screen", () => { expect( resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, + ...base, panelNamed: true, - threadVisibilityExplicit: false, + contentFullscreen: true, + sidePanelParam: true, }), ).toEqual({ threadOpen: true, contentOpen: true }); }); - /** The split's payoff: the view stays in the path while the panel is shut, so - * `?mainpanel=false` closes it without forgetting where it was. */ - test("?mainpanel=false closes the panel even when the path names a view", () => { + test("?sidepanel=false does not close the chat on desktop", () => { expect( resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, + ...base, panelNamed: true, - mainPanelParam: false, - threadVisibilityExplicit: false, - }), - ).toEqual({ threadOpen: true, contentOpen: false }); - }); - - test("?mainpanel=true opens a panel the agent default would leave closed", () => { - expect( - resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "chat" } }, - panelNamed: false, - mainPanelParam: true, - threadVisibilityExplicit: false, + sidePanelParam: false, }), ).toEqual({ threadOpen: true, contentOpen: true }); }); - test("an all-closed state falls back to Chat", () => { + test("full screen with no main panel still shows the chat", () => { expect( resolveDefaultPanelState({ - entityMetadata: { defaultMainView: { type: "settings" } }, + ...base, mainPanelParam: false, - panelNamed: false, - threadVisibilityExplicit: true, - sidePanelParamValue: false, + panelNamed: true, + contentFullscreen: true, }), ).toEqual({ threadOpen: true, contentOpen: false }); }); }); -describe("resolveChatLayoutPanelAction", () => { - test("opens Chat when only Main is visible", () => { - expect( - resolveChatLayoutPanelAction( - { type: "toggleThread" }, - { threadOpen: false, contentOpen: true }, - ), - ).toEqual({ sidepanel: true }); - }); - - test("closes the active side panel when Main remains open", () => { - expect( - resolveChatLayoutPanelAction( - { type: "toggleThread" }, - { threadOpen: true, contentOpen: true }, - ), - ).toEqual({ sidepanel: false }); - }); - - test("refuses to close the active side panel when it is the final panel", () => { - expect( - resolveChatLayoutPanelAction( - { type: "toggleThread" }, - { threadOpen: true, contentOpen: false }, - ), - ).toBeNull(); - }); - - /** INVERTED: opening Main used to have to NAME a view (`main=`), which - * is why closing it erased one. Both directions are the boolean now. */ - test("closing Main opens Chat even when Main was the only visible panel", () => { - expect( - resolveChatLayoutPanelAction( - { type: "toggleContent" }, - { threadOpen: true, contentOpen: false }, - ), - ).toEqual({ mainpanel: true }); - expect( - resolveChatLayoutPanelAction( - { type: "toggleContent" }, - { threadOpen: true, contentOpen: true }, - ), - ).toEqual({ mainpanel: false, sidepanel: true }); - expect( - resolveChatLayoutPanelAction( - { type: "toggleContent" }, - { threadOpen: false, contentOpen: true }, - ), - ).toEqual({ mainpanel: false, sidepanel: true }); - }); - - test("openThread is idempotent and opens Chat when it is closed", () => { - expect( - resolveChatLayoutPanelAction( - { type: "openThread" }, - { threadOpen: true, contentOpen: true }, - ), - ).toBeNull(); - expect( - resolveChatLayoutPanelAction( - { type: "openThread" }, - { threadOpen: false, contentOpen: true }, - ), - ).toEqual({ sidepanel: true }); - }); -}); - describe("computeChatLayoutPanelSizes", () => { test.each([ [ @@ -331,34 +148,20 @@ describe("mobileSurfaceSearch", () => { describe("resolveMobileSurface", () => { test("an explicit ?sidepanel=true wins over an open main panel", () => { expect( - resolveMobileSurface({ - visibility: { threadOpen: true, contentOpen: true }, - threadVisibilityExplicit: true, - }), + resolveMobileSurface({ contentOpen: true, sidePanelParam: true }), ).toBe("chat"); }); - test("the default main view still wins when ?sidepanel is absent", () => { + test("an open main view wins when ?sidepanel is absent or false", () => { + expect(resolveMobileSurface({ contentOpen: true })).toBe("main"); expect( - resolveMobileSurface({ - visibility: { threadOpen: true, contentOpen: true }, - threadVisibilityExplicit: false, - }), + resolveMobileSurface({ contentOpen: true, sidePanelParam: false }), ).toBe("main"); }); - test("falls back to main / chat when only one panel is open", () => { + test("shows the chat when there is no main panel", () => { expect( - resolveMobileSurface({ - visibility: { threadOpen: false, contentOpen: true }, - threadVisibilityExplicit: true, - }), - ).toBe("main"); - expect( - resolveMobileSurface({ - visibility: { threadOpen: true, contentOpen: false }, - threadVisibilityExplicit: false, - }), + resolveMobileSurface({ contentOpen: false, sidePanelParam: false }), ).toBe("chat"); }); }); diff --git a/apps/web/src/hooks/use-chat-layout-state.ts b/apps/web/src/hooks/use-chat-layout-state.ts index b00dc32593e..7fb9545018c 100644 --- a/apps/web/src/hooks/use-chat-layout-state.ts +++ b/apps/web/src/hooks/use-chat-layout-state.ts @@ -1,30 +1,10 @@ -/** - * useChatLayoutState — URL-driven state for the shared side panel and the - * tabbed main panel. - * - * URL model: - * ?sidepanel=true chat side panel open - * ?sidepanel=false side panel closed - * ?sidepanel absent route default, then agent-configured default - * ?sidepanel=chat|0 legacy links, parsed to the same boolean - * ?mainpanel=true|false main panel open — the mirror of ?sidepanel - * ?mainpanel absent open whenever the URL names a view (path segment, - * route default, then agent-configured default) - * ?virtualmcpid the agent, on the legacy `/$org/$taskId` alone - * ?thread the open thread on a destination route - * - * WHICH view the main panel shows is the `{-$panel}` path segment, not search — - * see `main-panel-tabs/panel-route.ts`. So the two panel toggles here are pure - * visibility: they navigate `to: "."` and never touch the path, which means a - * closed panel still remembers its view and can never fabricate a thread id. - */ +/** The chat never closes; full screen hides it, either the person's persisted + * preference or a project that opens with the chat closed. */ import { useNavigate, useSearch } from "@tanstack/react-router"; -import { useRouteThreadId } from "@/layouts/thread-route"; import { useActivePanelTabId } from "@/layouts/main-panel-tabs/use-panel-navigate"; import { useRouteDefaultMain } from "@/hooks/use-route-default-main"; -import { useThreads } from "@/components/chat/store/hooks"; -import { threadHasMessages } from "@/lib/thread-has-messages"; +import { usePreferences } from "@/hooks/use-preferences"; // --------------------------------------------------------------------------- // Types @@ -36,7 +16,7 @@ export interface EntityLayoutMetadata { id?: string; toolName?: string; } | null; - /** Open Chat in the side panel alongside a non-chat default main view. */ + /** Open the chat beside the main view on arrival. Unset means open. */ chatDefaultOpen?: boolean | null; tabs?: Array<{ id: string }>; } @@ -44,13 +24,14 @@ export interface EntityLayoutMetadata { export interface ChatLayoutState { threadOpen: boolean; contentOpen: boolean; - /** Whether ?sidepanel was in the URL (vs. the agent-configured default). */ - threadVisibilityExplicit: boolean; + /** `?sidepanel` as written in the URL, for the mobile surface choice. */ + sidePanelParam?: boolean; } export interface ChatLayoutActions { toggleContent: () => void; - toggleThread: () => void; + /** Expands the main panel over the chat, or brings the chat back. */ + toggleFullscreen: () => void; } // --------------------------------------------------------------------------- @@ -62,39 +43,24 @@ export interface ChatLayoutVisibility { contentOpen: boolean; } -export type ChatLayoutPanelAction = - | { type: "toggleThread" } - | { type: "toggleContent" } - | { type: "openThread" }; - export type ChatLayoutPanelSearchUpdate = { sidepanel?: boolean; mainpanel?: boolean; }; -function withChatLayoutFallback( - visibility: ChatLayoutVisibility, -): ChatLayoutVisibility { - if (visibility.threadOpen || visibility.contentOpen) return visibility; - return { ...visibility, threadOpen: true }; -} - export function resolveDefaultPanelState(ctx: { entityMetadata: EntityLayoutMetadata | null; /** `?mainpanel`, when the URL carries one. */ mainPanelParam?: boolean; /** Whether the `{-$panel}` path segment names a view. */ panelNamed: boolean; - threadVisibilityExplicit: boolean; - sidePanelParamValue?: boolean; + /** `?sidepanel`, when the URL carries one. */ + sidePanelParam?: boolean; /** The destination route's default view (e.g. `board` on `/$org/tasks`). * Wins over the agent's `defaultMainView`, loses to the path segment. */ routeDefaultMain?: string | null; - /** The current thread already holds a conversation. Forces the chat panel - * open even when the agent opts out of it (`chatDefaultOpen: false`), so - * returning to a chat you've been talking in never drops you on a closed - * panel. An empty composer (no thread / empty thread) leaves this false. */ - threadHasMessages?: boolean; + /** The person keeps the main panel expanded over the chat. */ + contentFullscreen: boolean; }): ChatLayoutVisibility { const defaultView = ctx.entityMetadata?.defaultMainView ?? null; const defaultIsChat = defaultView == null || defaultView.type === "chat"; @@ -103,43 +69,13 @@ export function resolveDefaultPanelState(ctx: { const contentOpen = ctx.mainPanelParam ?? (ctx.panelNamed || !!ctx.routeDefaultMain || !defaultIsChat); - /** - * A destination route that names its own main view IS that view's page — - * going to Tasks shows Tasks — so the chat starts collapsed beside it. - * `/$org/agents` declares no `defaultMain`, which is exactly why chat keeps its - * panel open without needing an exception here. - */ - const defaultSidePanelOpen = ctx.routeDefaultMain - ? false - : defaultIsChat || - ctx.entityMetadata?.chatDefaultOpen === true || - ctx.threadHasMessages === true; - const threadOpen = ctx.threadVisibilityExplicit - ? ctx.sidePanelParamValue === true - : defaultSidePanelOpen; + // Without a main panel there is nothing to expand: the chat fills the space. + const threadOpen = + !contentOpen || + ctx.sidePanelParam === true || + (!ctx.contentFullscreen && ctx.entityMetadata?.chatDefaultOpen !== false); - return withChatLayoutFallback({ threadOpen, contentOpen }); -} - -export function resolveChatLayoutPanelAction( - action: ChatLayoutPanelAction, - visibility: ChatLayoutVisibility, -): ChatLayoutPanelSearchUpdate | null { - switch (action.type) { - case "toggleThread": - if (visibility.threadOpen) { - if (!visibility.contentOpen) return null; - return { sidepanel: false }; - } - return { sidepanel: true }; - case "toggleContent": - if (visibility.contentOpen) { - return { mainpanel: false, sidepanel: true }; - } - return { mainpanel: true }; - case "openThread": - return visibility.threadOpen ? null : { sidepanel: true }; - } + return { threadOpen, contentOpen }; } export interface ChatLayoutPanelSizes { @@ -160,23 +96,13 @@ export function computeChatLayoutPanelSizes( export type MobileChatLayoutSurface = "chat" | "main"; -/** - * Mobile shows ONE surface at a time, so `?sidepanel` and `?mainpanel` can't - * both win. An explicit `?sidepanel=true` does: it only ever gets written by an - * intentional "open the chat" action (openThread, the mobile view select), - * and before this it was a silent no-op whenever the main panel happened to be - * open — tapping Chat left you on the Preview view, booting a sandbox. - * With no `?sidepanel` in the URL the panel state is the agent-configured - * default, and there the main view keeps precedence. - */ +/** Mobile shows one surface; only an explicit `?sidepanel=true` beats an open main view. */ export function resolveMobileSurface(ctx: { - visibility: ChatLayoutVisibility; - threadVisibilityExplicit: boolean; + contentOpen: boolean; + sidePanelParam?: boolean; }): MobileChatLayoutSurface { - const { threadOpen, contentOpen } = ctx.visibility; - if (threadOpen && (ctx.threadVisibilityExplicit || !contentOpen)) - return "chat"; - return contentOpen ? "main" : "chat"; + if (ctx.sidePanelParam === true || !ctx.contentOpen) return "chat"; + return "main"; } export function mobileSurfaceSearch( @@ -201,64 +127,52 @@ export function useChatLayoutState( ): ChatLayoutState & ChatLayoutActions { const navigate = useNavigate(); const search = useSearch({ strict: false }) satisfies PanelSearchParams; - const { threads } = useThreads(); + const [preferences, setPreferences] = usePreferences(); const routeDefaultMain = useRouteDefaultMain(); const panelTabId = useActivePanelTabId(); - const threadId = useRouteThreadId(); - - // Reopen the chat for a thread that already holds a conversation (threadHasMessages). - const currentThread = - threadId != null ? threads.find((t) => t.id === threadId) : undefined; - const { threadOpen, contentOpen } = resolveDefaultPanelState({ entityMetadata, mainPanelParam: search.mainpanel, panelNamed: panelTabId !== undefined, - threadVisibilityExplicit: search.sidepanel !== undefined, - sidePanelParamValue: search.sidepanel, + sidePanelParam: search.sidepanel, routeDefaultMain, - threadHasMessages: currentThread ? threadHasMessages(currentThread) : false, + contentFullscreen: preferences.contentFullscreen, }); - const visibility = { threadOpen, contentOpen }; /** * Panel state is search, never path: `to: "."` re-interpolates the matched * route's own params, so every toggle below stays on the current page. */ - const navigateSearch = ( - updates: Record, - options?: { replace?: boolean }, - ) => { + const navigateSearch = (updates: ChatLayoutPanelSearchUpdate) => { navigate({ to: ".", search: (prev: Record) => ({ ...prev, ...updates }), - replace: options?.replace ?? false, + replace: true, }); }; const toggleContent = () => { - const update = resolveChatLayoutPanelAction( - { type: "toggleContent" }, - visibility, - ); - if (update) navigateSearch(update, { replace: true }); + navigateSearch({ mainpanel: !contentOpen }); }; - const toggleThread = () => { - const update = resolveChatLayoutPanelAction( - { type: "toggleThread" }, - visibility, - ); - if (update) navigateSearch(update, { replace: true }); + const toggleFullscreen = () => { + setPreferences((prev) => ({ ...prev, contentFullscreen: threadOpen })); + // A project that opens with the chat closed still hides it after the + // preference clears, so showing it there needs `?sidepanel` too. + const sidepanel = + !threadOpen && entityMetadata?.chatDefaultOpen === false + ? true + : undefined; + if (search.sidepanel !== sidepanel) navigateSearch({ sidepanel }); }; return { threadOpen, contentOpen, - threadVisibilityExplicit: search.sidepanel !== undefined, + sidePanelParam: search.sidepanel, toggleContent, - toggleThread, + toggleFullscreen, }; } diff --git a/apps/web/src/hooks/use-context.ts b/apps/web/src/hooks/use-context.ts index 117d837a47e..9e52fc4b022 100644 --- a/apps/web/src/hooks/use-context.ts +++ b/apps/web/src/hooks/use-context.ts @@ -12,6 +12,7 @@ */ import { useMatch } from "@tanstack/react-router"; +import { useChatViewContext } from "@/hooks/use-current-view"; import { useOpenLibraryFile } from "@/hooks/use-open-library-file"; import { basename, orgFsMountPath } from "@/layouts/library/location"; @@ -29,6 +30,7 @@ export function useContext(virtualMcpId?: string | null): string { }); const openFilePath = useOpenLibraryFile()?.path ?? null; + const view = useChatViewContext(); const contextParts: string[] = []; @@ -38,6 +40,19 @@ export function useContext(virtualMcpId?: string | null): string { - ID: ${virtualMcpId}`); } + // The page in front of the user, so "this" and "here" resolve to it. + if (view) { + contextParts.push(`### Current Page +The user is looking at: ${view.title}${ + view.project + ? ` +- Project: ${view.project.title} (ID: ${view.project.id})` + : "" + } + +When a request is ambiguous about what it refers to ("this", "here", "this page"), assume it means what is on this page.`); + } + // Point the assistant at the file the user is looking at, so an ambiguous // request ("change the h1") resolves to it instead of guessing. We give the // sandbox mount path so the agent can read/edit it directly with its file diff --git a/apps/web/src/hooks/use-current-view.ts b/apps/web/src/hooks/use-current-view.ts new file mode 100644 index 00000000000..56157c63555 --- /dev/null +++ b/apps/web/src/hooks/use-current-view.ts @@ -0,0 +1,79 @@ +import { useSyncExternalStore } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { useInSettings } from "@/hooks/use-in-settings"; +import { useScopeId } from "@/hooks/use-project-scope"; +import { useT } from "@/i18n/use-t"; +import { useRouteThreadId } from "@/layouts/thread-route"; +import { useProjectContext, useVirtualMCPNonBlocking } from "@/sdk"; +import type { VirtualMCPEntity } from "@/sdk"; + +export interface CurrentView { + /** The project the page belongs to, when it belongs to one. */ + project: VirtualMCPEntity | null; + /** The page's own name: "Site Editor", "Tasks", or the project on its home. */ + title: string; + isHome: boolean; + inSettings: boolean; +} + +/** What the person is looking at, named the way the page header names it. */ +export function useCurrentView(): CurrentView { + const t = useT(); + const scopeId = useScopeId(); + const project = useVirtualMCPNonBlocking(scopeId); + const inSettings = useInSettings(); + const page = useRouterState({ + select: (state) => + state.matches.findLast((match) => match.staticData.pageTitle)?.staticData, + }); + const isHome = page?.mainView === "overview"; + const projectTitle = project?.title ?? t("common.loading"); + const title = isHome + ? // A project's home names the PROJECT; the org's home is just Home. + scopeId + ? projectTitle + : t("sidebar.navDestinations.home") + : page?.mainView === "files" + ? t("sidebar.navDestinations.context") + : t(page?.pageTitle ?? "page.view"); + return { project: scopeId ? project : null, title, isHome, inSettings }; +} + +/** `org:thread:view` of the view the person took out of the chat's context. */ +let dismissedKey: string | null = null; +const listeners = new Set<() => void>(); +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * The view the chat tells the agent about, or null once dismissed. Dismissal + * holds for that view in that chat: moving to another page brings it back. + */ +export function useChatViewContext(): + | (CurrentView & { + dismiss: () => void; + }) + | null { + const view = useCurrentView(); + const { org } = useProjectContext(); + const threadId = useRouteThreadId(); + const dismissed = useSyncExternalStore( + subscribe, + () => dismissedKey, + () => dismissedKey, + ); + if (view.inSettings) return null; + const key = `${org.id}:${threadId ?? ""}:${view.project?.id ?? ""}:${view.title}`; + if (key === dismissed) return null; + return { + ...view, + dismiss: () => { + dismissedKey = key; + for (const listener of listeners) listener(); + }, + }; +} diff --git a/apps/web/src/hooks/use-preferences.ts b/apps/web/src/hooks/use-preferences.ts index 55f7b13bd42..d5f5e365393 100644 --- a/apps/web/src/hooks/use-preferences.ts +++ b/apps/web/src/hooks/use-preferences.ts @@ -21,6 +21,8 @@ interface Preferences { /** Task-board lanes this person collapsed to a narrow strip. Statuses, for * the same reason as `taskBoardLaneVisibility`. */ collapsedTaskBoardLanes: string[]; + /** The main panel stays expanded over the chat. */ + contentFullscreen: boolean; } const DEFAULT_PREFERENCES: Preferences = { @@ -31,6 +33,7 @@ const DEFAULT_PREFERENCES: Preferences = { language: detectLocale(), taskBoardLaneVisibility: {}, collapsedTaskBoardLanes: [], + contentFullscreen: false, }; const VALID_TOOL_APPROVAL_LEVELS: ToolApprovalLevel[] = ["auto", "readonly"]; @@ -136,6 +139,9 @@ export function usePreferences() { if (!Array.isArray(merged.collapsedTaskBoardLanes)) { merged.collapsedTaskBoardLanes = []; } + if (typeof merged.contentFullscreen !== "boolean") { + merged.contentFullscreen = false; + } return merged; }, ); diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index bcd64b93b2e..5866e5002dc 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -23,6 +23,8 @@ export const chat = { "chat.voice.sendFailed": "Could not send or complete this request. Return to the chat to check its status.", "chat.input.openFileContext": "The agent sees {name}, the file you have open", + "chat.input.viewContext": "The agent sees that you are on {name}", + "chat.input.viewDismiss": "Leave this page out", "chat.input.openFileDismiss": "Leave this file out", "chat.input.taskMode": "Task", "chat.input.startTask": "Start task", @@ -219,14 +221,14 @@ export const chat = { "Response reached the model's output limit. Different models have different limits. Try switching models or asking it to continue.", "chat.highlight.warningDescriptionToolCalls": "Response paused after tool execution to prevent infinite loops and save costs. Click continue to keep working.", - "chat.iceBreakers.allPromptsTitle": "All prompts", - "chat.iceBreakers.failedToLoadPrompt": - "Failed to load prompt. Please try again.", - "chat.iceBreakers.mcpClientNotAvailable": "MCP client not available", - "chat.iceBreakers.moreButton": "+{count} more", - "chat.iceBreakers.noPromptsMatch": 'No prompts match "{search}"', - "chat.iceBreakers.promptsLabel": "Prompts", - "chat.iceBreakers.searchPromptsPlaceholder": "Search prompts...", + "chat.draft.editing": "Editing", + "chat.draft.ready": "Ready to publish", + "chat.draft.change": "{count} change", + "chat.draft.changes": "{count} changes", + "chat.draft.open": "Open", + "chat.emptyState.draftPending": "Draft pending", + "chat.emptyState.continue": "Continue", + "chat.emptyState.start": "Ask anything or hand off a task", "chat.imageLightbox.downloadImage": "Download image", "chat.imageLightbox.zoomIn": "Zoom in", "chat.imageLightbox.zoomOut": "Zoom out", @@ -309,8 +311,6 @@ export const chat = { "chat.proposePlan.rejected": "Rejected", "chat.proposePlan.viewPlan": "View plan", "chat.queueTray.cancelFailed": "Failed to cancel", - "chat.queueTray.queuedMessage": "{count} queued message", - "chat.queueTray.queuedMessages": "{count} queued messages", "chat.queueTray.removeFromQueue": "Remove from queue", "chat.queueTray.sendNow": "Send now", "chat.runStatus.analyzingScopeDetail": "Working out how to respond", @@ -418,6 +418,8 @@ export const chat = { "chat.tierTrigger.tierSmart": "Smart", "chat.tierTrigger.tierThinking": "Thinking", "chat.threadsMenu.chats": "Chats", + "chat.threadsMenu.searchPlaceholder": "Search chats", + "chat.threadsMenu.searchEverywhere": "Search all chats for “{query}”", "chat.toolsPopover.addFile": "Add file", "chat.toolsPopover.approval": "Approval", "chat.toolsPopover.connections": "Connections", diff --git a/apps/web/src/i18n/en/page.ts b/apps/web/src/i18n/en/page.ts index ff33cf484e7..91167c01144 100644 --- a/apps/web/src/i18n/en/page.ts +++ b/apps/web/src/i18n/en/page.ts @@ -10,6 +10,10 @@ export const page = { "page.views": "Page views", "page.openThread": "Open chat", "page.closeThread": "Close chat", + "page.enterFullscreen": "Expand", + "page.exitFullscreen": "Show chat", + "page.openMainPanel": "Open panel", + "page.closeMainPanel": "Close", "page.collapseSidebar": "Collapse sidebar", "page.expandSidebar": "Expand sidebar", } as const; diff --git a/apps/web/src/i18n/en/sidebar.ts b/apps/web/src/i18n/en/sidebar.ts index 8e86790c5dd..65439052cf2 100644 --- a/apps/web/src/i18n/en/sidebar.ts +++ b/apps/web/src/i18n/en/sidebar.ts @@ -15,6 +15,11 @@ export const sidebar = { "sidebar.navDestinations.threadAnalytics": "Chats analytics", "sidebar.myThreadsSection.noChatsYet": "No chats yet", "sidebar.myThreadsSection.noChatsMatchFilters": "No chats match your filters", + "sidebar.myThreadsSection.today": "Today", + "sidebar.myThreadsSection.yesterday": "Yesterday", + "sidebar.myThreadsSection.previousWeek": "Previous 7 days", + "sidebar.myThreadsSection.previousMonth": "Previous 30 days", + "sidebar.myThreadsSection.older": "Older", "sidebar.projectNav.automations": "Automations", "sidebar.projectNav.apps": "Apps", "sidebar.projectNav.siteEditor": "Site Editor", @@ -92,17 +97,13 @@ export const sidebar = { "sidebar.sidebarResizeHandle.ariaLabel": "Resize sidebar", "sidebar.sidebarResizeHandle.title": "Drag to resize, double-click to reset", "sidebar.taskGroupsList.filterChats": "Filter chats", - "sidebar.taskGroupsList.filterScope": "Scope", - "sidebar.taskGroupsList.filterType": "Type", - "sidebar.taskGroupsList.filterView": "View", + "sidebar.taskGroupsList.filterScope": "Show", + "sidebar.taskGroupsList.filterView": "Group by", "sidebar.taskGroupsList.newChat": "New chat", - "sidebar.taskGroupsList.scopeMine": "Mine", - "sidebar.taskGroupsList.scopeTeam": "Team", + "sidebar.taskGroupsList.scopeMine": "My chats", + "sidebar.taskGroupsList.scopeTeam": "Everyone's chats", "sidebar.taskGroupsList.searchChats": "Search chats", - "sidebar.taskGroupsList.typeAll": "All", - "sidebar.taskGroupsList.typeAuto": "Auto", - "sidebar.taskGroupsList.typeChats": "Chats", - "sidebar.taskGroupsList.viewList": "List", + "sidebar.taskGroupsList.viewList": "Date", "sidebar.taskGroupsList.viewStatus": "Status", "sidebar.taskGroupsList.toggleStatusGroup": "Toggle {status} group", "sidebar.inbox.title": "Inbox", diff --git a/apps/web/src/i18n/en/task-board.ts b/apps/web/src/i18n/en/task-board.ts index ad222768273..797552d0ce7 100644 --- a/apps/web/src/i18n/en/task-board.ts +++ b/apps/web/src/i18n/en/task-board.ts @@ -124,7 +124,6 @@ export const taskBoard = { "taskBoard.taskBoard.noTasksYet": "No tasks yet. Start one with New task.", "taskBoard.taskBoard.laneMenuAriaLabel": "More actions for {lane}", "taskBoard.taskBoard.selectAllInLane": "Select all", - "taskBoard.taskBoard.columnsLabel": "Columns", "taskBoard.taskBoard.hideColumn": "Hide", "taskBoard.taskBoard.collapseColumn": "Collapse", "taskBoard.taskBoard.collapseColumnAriaLabel": "Collapse {lane}", @@ -481,8 +480,6 @@ export const taskBoard = { "taskBoard.feed.composerSubmit": "Add", "taskBoard.sprints.label": "Sprint", "taskBoard.sprints.backlog": "Backlog", - "taskBoard.sprints.manage": "Sprints", - "taskBoard.sprints.all": "All sprints", "taskBoard.sprints.manageItem": "Manage sprints…", "taskBoard.sprints.title": "Sprints", "taskBoard.sprints.description": diff --git a/apps/web/src/i18n/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index dc1c995db8f..ab535d05b08 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -27,6 +27,8 @@ export const chat = { "Não foi possível enviar ou concluir este pedido. Volte ao chat para conferir o estado dele.", "chat.input.openFileContext": "O agente vê {name}, o arquivo que você tem aberto", + "chat.input.viewContext": "O agente sabe que você está em {name}", + "chat.input.viewDismiss": "Deixar esta página de fora", "chat.input.openFileDismiss": "Deixar este arquivo de fora", "chat.input.taskMode": "Tarefa", "chat.input.startTask": "Iniciar tarefa", @@ -227,15 +229,14 @@ export const chat = { "A resposta atingiu o limite de saída do modelo. Diferentes modelos têm limites diferentes. Tente trocar de modelo ou peça para continuar.", "chat.highlight.warningDescriptionToolCalls": "A resposta foi pausada após a execução da ferramenta para evitar loops infinitos e economizar custos. Clique continuar para continuar trabalhando.", - "chat.iceBreakers.allPromptsTitle": "Todos os prompts", - "chat.iceBreakers.failedToLoadPrompt": - "Falha ao carregar prompt. Tente novamente.", - "chat.iceBreakers.mcpClientNotAvailable": "Cliente MCP não disponível", - "chat.iceBreakers.moreButton": "+{count} mais", - "chat.iceBreakers.noPromptsMatch": - 'Nenhum prompt correspondente a "{search}"', - "chat.iceBreakers.promptsLabel": "Prompts", - "chat.iceBreakers.searchPromptsPlaceholder": "Pesquisar prompts...", + "chat.draft.editing": "Editando", + "chat.draft.ready": "Pronto pra publicar", + "chat.draft.change": "{count} alteração", + "chat.draft.changes": "{count} alterações", + "chat.draft.open": "Abrir", + "chat.emptyState.draftPending": "Rascunho pendente", + "chat.emptyState.continue": "Continuar", + "chat.emptyState.start": "Pergunte qualquer coisa ou peça uma tarefa", "chat.imageLightbox.downloadImage": "Baixar imagem", "chat.imageLightbox.zoomIn": "Aumentar zoom", "chat.imageLightbox.zoomOut": "Diminuir zoom", @@ -321,8 +322,6 @@ export const chat = { "chat.proposePlan.rejected": "Rejeitado", "chat.proposePlan.viewPlan": "Ver plano", "chat.queueTray.cancelFailed": "Falha ao cancelar", - "chat.queueTray.queuedMessage": "{count} mensagem na fila", - "chat.queueTray.queuedMessages": "{count} mensagens na fila", "chat.queueTray.removeFromQueue": "Remover da fila", "chat.queueTray.sendNow": "Enviar agora", "chat.runStatus.analyzingScopeDetail": "Descobrindo como responder", @@ -434,6 +433,8 @@ export const chat = { "chat.tierTrigger.tierSmart": "Inteligente", "chat.tierTrigger.tierThinking": "Pensamento", "chat.threadsMenu.chats": "Chats", + "chat.threadsMenu.searchPlaceholder": "Buscar chats", + "chat.threadsMenu.searchEverywhere": "Buscar “{query}” em todos os chats", "chat.toolsPopover.addFile": "Adicionar arquivo", "chat.toolsPopover.approval": "Aprovação", "chat.toolsPopover.connections": "Conexões", diff --git a/apps/web/src/i18n/pt-br/page.ts b/apps/web/src/i18n/pt-br/page.ts index 5541ec5e902..a325969fe4b 100644 --- a/apps/web/src/i18n/pt-br/page.ts +++ b/apps/web/src/i18n/pt-br/page.ts @@ -12,6 +12,10 @@ export const page = { "page.views": "Visualizações da página", "page.openThread": "Abrir chat", "page.closeThread": "Fechar chat", + "page.enterFullscreen": "Expandir", + "page.exitFullscreen": "Mostrar chat", + "page.openMainPanel": "Abrir painel", + "page.closeMainPanel": "Fechar", "page.collapseSidebar": "Recolher barra lateral", "page.expandSidebar": "Expandir barra lateral", } satisfies Record; diff --git a/apps/web/src/i18n/pt-br/sidebar.ts b/apps/web/src/i18n/pt-br/sidebar.ts index c1450a54839..12a83a2b67a 100644 --- a/apps/web/src/i18n/pt-br/sidebar.ts +++ b/apps/web/src/i18n/pt-br/sidebar.ts @@ -18,6 +18,11 @@ export const sidebar = { "sidebar.myThreadsSection.noChatsYet": "Nenhum chat ainda", "sidebar.myThreadsSection.noChatsMatchFilters": "Nenhum chat corresponde aos seus filtros", + "sidebar.myThreadsSection.today": "Hoje", + "sidebar.myThreadsSection.yesterday": "Ontem", + "sidebar.myThreadsSection.previousWeek": "Últimos 7 dias", + "sidebar.myThreadsSection.previousMonth": "Últimos 30 dias", + "sidebar.myThreadsSection.older": "Mais antigos", "sidebar.projectNav.automations": "Automações", "sidebar.projectNav.apps": "Apps", "sidebar.projectNav.siteEditor": "Editor do site", @@ -98,17 +103,13 @@ export const sidebar = { "sidebar.sidebarResizeHandle.title": "Arraste para redimensionar, clique duplo para redefinir", "sidebar.taskGroupsList.filterChats": "Filtrar chats", - "sidebar.taskGroupsList.filterScope": "Escopo", - "sidebar.taskGroupsList.filterType": "Tipo", - "sidebar.taskGroupsList.filterView": "Visualização", + "sidebar.taskGroupsList.filterScope": "Mostrar", + "sidebar.taskGroupsList.filterView": "Agrupar por", "sidebar.taskGroupsList.newChat": "Novo chat", - "sidebar.taskGroupsList.scopeMine": "Meus", - "sidebar.taskGroupsList.scopeTeam": "Equipe", + "sidebar.taskGroupsList.scopeMine": "Meus chats", + "sidebar.taskGroupsList.scopeTeam": "Chats de todos", "sidebar.taskGroupsList.searchChats": "Pesquisar chats", - "sidebar.taskGroupsList.typeAll": "Todos", - "sidebar.taskGroupsList.typeAuto": "Auto", - "sidebar.taskGroupsList.typeChats": "Chats", - "sidebar.taskGroupsList.viewList": "Lista", + "sidebar.taskGroupsList.viewList": "Data", "sidebar.taskGroupsList.viewStatus": "Status", "sidebar.taskGroupsList.toggleStatusGroup": "Alternar grupo {status}", "sidebar.inbox.title": "Caixa de entrada", diff --git a/apps/web/src/i18n/pt-br/task-board.ts b/apps/web/src/i18n/pt-br/task-board.ts index 117bcf8b0f8..6c16487afd5 100644 --- a/apps/web/src/i18n/pt-br/task-board.ts +++ b/apps/web/src/i18n/pt-br/task-board.ts @@ -128,7 +128,6 @@ export const taskBoard = { "Nenhuma tarefa ainda. Comece uma com Nova tarefa.", "taskBoard.taskBoard.laneMenuAriaLabel": "Mais ações para {lane}", "taskBoard.taskBoard.selectAllInLane": "Selecionar todos", - "taskBoard.taskBoard.columnsLabel": "Colunas", "taskBoard.taskBoard.hideColumn": "Ocultar", "taskBoard.taskBoard.collapseColumn": "Recolher", "taskBoard.taskBoard.collapseColumnAriaLabel": "Recolher {lane}", @@ -490,8 +489,6 @@ export const taskBoard = { "taskBoard.feed.composerSubmit": "Adicionar", "taskBoard.sprints.label": "Sprint", "taskBoard.sprints.backlog": "Backlog", - "taskBoard.sprints.manage": "Sprints", - "taskBoard.sprints.all": "Todas as sprints", "taskBoard.sprints.manageItem": "Gerenciar sprints…", "taskBoard.sprints.title": "Sprints", "taskBoard.sprints.description": diff --git a/apps/web/src/layouts/library/index.tsx b/apps/web/src/layouts/library/index.tsx index a2e49f8a7fb..9fc44dde8ae 100644 --- a/apps/web/src/layouts/library/index.tsx +++ b/apps/web/src/layouts/library/index.tsx @@ -523,7 +523,7 @@ export function LibraryPage({ {browseVolume && ( + + + ); } diff --git a/apps/web/src/layouts/task-board/filter-fields.test.ts b/apps/web/src/layouts/task-board/filter-fields.test.ts index ef3dbddad3d..6e841ca24ee 100644 --- a/apps/web/src/layouts/task-board/filter-fields.test.ts +++ b/apps/web/src/layouts/task-board/filter-fields.test.ts @@ -21,16 +21,12 @@ const SITE_INDEX = buildProjectIndex([], ["acme/site"]); describe("activeFilterFieldIds", () => { test("no filters set means no chips", () => { - expect(activeFilterFieldIds(EMPTY_FILTERS, EMPTY_INDEX, null)).toEqual([]); + expect(activeFilterFieldIds(EMPTY_FILTERS, EMPTY_INDEX)).toEqual([]); }); test("free-text search is not a chip — it has its own control", () => { expect( - activeFilterFieldIds( - { ...EMPTY_FILTERS, search: "login" }, - EMPTY_INDEX, - null, - ), + activeFilterFieldIds({ ...EMPTY_FILTERS, search: "login" }, EMPTY_INDEX), ).toEqual([]); }); @@ -39,14 +35,13 @@ describe("activeFilterFieldIds", () => { activeFilterFieldIds( { ...EMPTY_FILTERS, tags: ["t1"], priority: "high" }, EMPTY_INDEX, - null, ), ).toEqual(["priority", "tags"]); }); test("an empty tag list is not active", () => { expect( - activeFilterFieldIds({ ...EMPTY_FILTERS, tags: [] }, EMPTY_INDEX, null), + activeFilterFieldIds({ ...EMPTY_FILTERS, tags: [] }, EMPTY_INDEX), ).toEqual([]); }); @@ -57,7 +52,6 @@ describe("activeFilterFieldIds", () => { activeFilterFieldIds( { ...EMPTY_FILTERS, project: "vir_gone" }, EMPTY_INDEX, - null, ), ).toEqual([]); }); @@ -67,7 +61,6 @@ describe("activeFilterFieldIds", () => { activeFilterFieldIds( { ...EMPTY_FILTERS, project: "acme/site" }, SITE_INDEX, - null, ), ).toEqual(["project"]); }); @@ -77,7 +70,6 @@ describe("activeFilterFieldIds", () => { activeFilterFieldIds( { ...EMPTY_FILTERS, project: NO_PROJECT_FILTER }, EMPTY_INDEX, - null, ), ).toEqual(["project"]); }); @@ -93,11 +85,11 @@ describe("withFieldCleared", () => { search: "login", }; - expect(withFieldCleared(filters, "priority", null)).toEqual({ + expect(withFieldCleared(filters, "priority")).toEqual({ ...filters, priority: null, }); - expect(withFieldCleared(filters, "tags", null)).toEqual({ + expect(withFieldCleared(filters, "tags")).toEqual({ ...filters, tags: [], }); @@ -105,7 +97,7 @@ describe("withFieldCleared", () => { test("leaves the free-text search alone", () => { expect( - withFieldCleared({ ...EMPTY_FILTERS, search: "login" }, "assignee", null) + withFieldCleared({ ...EMPTY_FILTERS, search: "login" }, "assignee") .search, ).toBe("login"); }); @@ -114,38 +106,25 @@ describe("withFieldCleared", () => { describe("the sprint chip", () => { const withSprint = (sprint: string | null) => ({ ...EMPTY_FILTERS, sprint }); - test("the running sprint is the board's scope, not a chip", () => { - expect( - activeFilterFieldIds(withSprint("sprint_now"), EMPTY_INDEX, "sprint_now"), - ).toEqual([]); - }); - - test("another sprint, or the backlog, narrows the board", () => { + test("any sprint, the running one included, or the backlog is a chip", () => { + expect(activeFilterFieldIds(withSprint("sprint_now"), EMPTY_INDEX)).toEqual( + ["sprint"], + ); expect( - activeFilterFieldIds(withSprint("sprint_old"), EMPTY_INDEX, "sprint_now"), - ).toEqual(["sprint"]); - expect( - activeFilterFieldIds( - withSprint(BACKLOG_SPRINT_FILTER), - EMPTY_INDEX, - "sprint_now", - ), + activeFilterFieldIds(withSprint(BACKLOG_SPRINT_FILTER), EMPTY_INDEX), ).toEqual(["sprint"]); }); - test("with nothing running, any sprint is a chip", () => { - expect( - activeFilterFieldIds(withSprint("sprint_old"), EMPTY_INDEX, null), - ).toEqual(["sprint"]); + test("every sprint is no chip", () => { + expect(activeFilterFieldIds(withSprint(null), EMPTY_INDEX)).toEqual([]); }); - test("clearing it goes back to the running sprint", () => { + test("clearing it shows every sprint", () => { expect( - withFieldCleared(withSprint("sprint_old"), "sprint", "sprint_now").sprint, - ).toBe("sprint_now"); + withFieldCleared(withSprint("sprint_old"), "sprint").sprint, + ).toBeNull(); expect( - withFieldCleared(withSprint(BACKLOG_SPRINT_FILTER), "sprint", null) - .sprint, + withFieldCleared(withSprint(BACKLOG_SPRINT_FILTER), "sprint").sprint, ).toBeNull(); }); }); diff --git a/apps/web/src/layouts/task-board/filter-fields.ts b/apps/web/src/layouts/task-board/filter-fields.ts index b9c97e95e83..54ad073c8e2 100644 --- a/apps/web/src/layouts/task-board/filter-fields.ts +++ b/apps/web/src/layouts/task-board/filter-fields.ts @@ -28,8 +28,6 @@ export type FilterFieldId = (typeof FILTER_FIELD_IDS)[number]; export function activeFilterFieldIds( filters: TaskFilters, index: ProjectIndex, - /** The running sprint's id, or null when none is running. */ - currentSprint: string | null, ): FilterFieldId[] { return FILTER_FIELD_IDS.filter((id) => { switch (id) { @@ -43,10 +41,9 @@ export function activeFilterFieldIds( return filters.tags.length > 0; case "project": return projectFilterNarrows(filters.project, index); - // The running sprint is where the board opens, named by the view row's - // switcher; any other sprint, or the backlog, reads as a narrowing. + // The running sprint is where the board opens; its chip says so, since nothing else in the header does. case "sprint": - return filters.sprint !== null && filters.sprint !== currentSprint; + return filters.sprint !== null; default: { const exhaustive: never = id; return exhaustive; @@ -55,12 +52,10 @@ export function activeFilterFieldIds( }); } -/** Clearing one chip leaves the others alone. A cleared sprint goes back to - * the running one, where the board opens. */ +/** Clearing one chip leaves the others alone. A cleared sprint shows every sprint. */ export function withFieldCleared( filters: TaskFilters, id: FilterFieldId, - currentSprint: string | null, ): TaskFilters { switch (id) { case "assignee": @@ -74,7 +69,7 @@ export function withFieldCleared( case "project": return { ...filters, project: null }; case "sprint": - return { ...filters, sprint: currentSprint }; + return { ...filters, sprint: null }; default: { const exhaustive: never = id; return exhaustive; diff --git a/apps/web/src/layouts/task-board/folded-label-tooltip.tsx b/apps/web/src/layouts/task-board/folded-label-tooltip.tsx new file mode 100644 index 00000000000..979c7c2aba4 --- /dev/null +++ b/apps/web/src/layouts/task-board/folded-label-tooltip.tsx @@ -0,0 +1,35 @@ +import { useRef, useState, type ReactNode } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@decocms/ui/components/tooltip.tsx"; + +/** True once the header folded the button's `data-folding-label` down to sr-only. */ +function labelFolded(trigger: HTMLElement | null): boolean { + const label = trigger?.querySelector("[data-folding-label]"); + return !!label && label.getBoundingClientRect().width <= 1; +} + +/** Names a header action whose label folded away; quiet while the label shows. */ +export function FoldedLabelTooltip({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + const trigger = useRef(null); + const [open, setOpen] = useState(false); + return ( + setOpen(next && labelFolded(trigger.current))} + > + + {children} + + {label} + + ); +} diff --git a/apps/web/src/layouts/task-board/index.tsx b/apps/web/src/layouts/task-board/index.tsx index 58a5df51fe0..b4511db3f06 100644 --- a/apps/web/src/layouts/task-board/index.tsx +++ b/apps/web/src/layouts/task-board/index.tsx @@ -38,6 +38,7 @@ import { import { DemoBoardResetButton } from "./demo-reset-button"; import { BoardOrgProvider } from "./board-org"; import { authClient } from "@/lib/auth-client"; +import { FoldedLabelTooltip } from "./folded-label-tooltip"; import { ColumnRulesSheet, useEntryAutomations } from "./column-rules"; import { columnSentence } from "./column-guide"; import { useTaskBoardRulesLive } from "@/hooks/use-task-board-prompts"; @@ -114,7 +115,7 @@ import { useTaskBoardSprintsLive, } from "@/hooks/use-task-board-sprints"; import { currentSprintId } from "@decocms/shared/sprints"; -import { SprintSwitcher } from "./sprint-controls"; +import { SprintsDialog } from "./sprint-controls"; import { sprintLabel } from "./sprint-label"; import { formatTimeAgo } from "@/lib/format-time"; import { @@ -204,7 +205,6 @@ import { import { AppliedFiltersBar, BoardSettingsButton, - ColumnsButton, GroupByButton, SortByButton, TaskFilterButton, @@ -1024,6 +1024,7 @@ function TaskBoardBody() { const hasRepo = usableRepos.length > 0; const repos = usableRepos.map((repository) => repository.path); const [repoPickerOpen, setRepoPickerOpen] = useState(false); + const [managingSprints, setManagingSprints] = useState(false); // Returns true if the assignment was blocked (connect prompt opened) so the // caller stops before dispatching. const blockSuperAgentWithoutRepository = ( @@ -1155,7 +1156,6 @@ function TaskBoardBody() { : null, ); const [preferences] = usePreferences(); - const columnVisibility = useLaneVisibility(); const [selection, setSelection] = useState>(new Set()); const toggleSelect = (id: string) => setSelection((prev) => { @@ -1549,13 +1549,6 @@ function TaskBoardBody() { - - handleFiltersChange({ ...filters, sprint }) - } - /> {/* Epics lists every epic; the board's filters don't apply to it. */} {items.length > 0 && layout !== "epics" && ( <> @@ -1583,22 +1576,15 @@ function TaskBoardBody() { index={projectIndex} scoped={scopeId !== null} onChange={handleFiltersChange} + onManageSprints={() => setManagingSprints(true)} /> {/* A phone's header fits search, filter and settings; view tuning stays on wider screens. */} -
- {layout === "list" ? ( - <> - - - - ) : ( -
+ {layout === "list" && ( +
+ + +
+ )}
)} @@ -1606,6 +1592,12 @@ function TaskBoardBody() { onClick={openBoardSettings} label={boardSettingsLabel} /> + {managingSprints && ( + setManagingSprints(false)} + /> + )} } > @@ -1618,12 +1610,21 @@ function TaskBoardBody() { {t("taskBoard.epics.newEpic")} ) : ( - + + + )}
{layoutTabs} @@ -2263,8 +2264,7 @@ function bySortOrder(a: TaskBoardItem, b: TaskBoardItem) { return a.sortOrder - b.sortOrder; } -/** Lane visibility persists per person, so the choice survives a reload. The - * toolbar menu and the board read the same preference. */ +/** Lane visibility persists per person, so the choice survives a reload. */ function useLaneVisibility() { const [preferences, setPreferences] = usePreferences(); const { lanes, hidden } = laneVisibility(preferences.taskBoardLaneVisibility); diff --git a/apps/web/src/layouts/task-board/sprint-controls.tsx b/apps/web/src/layouts/task-board/sprint-controls.tsx index f38c33f61d6..6eb39c84dc8 100644 --- a/apps/web/src/layouts/task-board/sprint-controls.tsx +++ b/apps/web/src/layouts/task-board/sprint-controls.tsx @@ -1,20 +1,9 @@ /** - * The board's sprint controls: which sprint the board shows, and the dialog - * that plans, starts, completes, renames and deletes sprints. + * The dialog that plans, starts, completes, renames and deletes sprints. */ import { useState } from "react"; -import { - Check, - ChevronDown, - DotsHorizontal, - Edit03, - Inbox01, - Plus, - Settings02, - Trash01, - Zap, -} from "@untitledui/icons"; +import { DotsHorizontal, Edit03, Plus, Trash01, Zap } from "@untitledui/icons"; import { Badge } from "@decocms/ui/components/badge.tsx"; import { Button } from "@decocms/ui/components/button.tsx"; import { @@ -29,10 +18,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@decocms/ui/components/dropdown-menu.tsx"; import { IconButton } from "@decocms/ui/components/icon-button.tsx"; @@ -57,8 +42,7 @@ import { useTaskBoardItems } from "@/hooks/use-task-board-items"; import { useSprintActions } from "@/hooks/use-task-board-sprints"; import { useT, type TranslationKey } from "@/i18n/use-t.ts"; import { localToday, suggestNextSprint } from "./sprint-draft"; -import { formatSprintDays, sprintLabel } from "./sprint-label"; -import { BACKLOG_SPRINT_FILTER } from "./task-filters-core"; +import { formatSprintDays } from "./sprint-label"; /** Radix Select has no empty value, so the backlog needs a stand-in. */ const BACKLOG_VALUE = "__backlog__"; @@ -77,103 +61,8 @@ const SECTIONS: { state: Sprint["state"]; labelKey: TranslationKey }[] = [ { state: "closed", labelKey: "taskBoard.sprints.sectionClosed" }, ]; -/** - * The sprint the board shows, picked from the view row like search and the - * filter menu rather than as one more chip: on a sprint board it is the - * board's scope, not a narrowing. Managing sprints sits at the menu's end. - */ -export function SprintSwitcher({ - sprints, - value, - onChange, -}: { - sprints: readonly Sprint[]; - /** A sprint id, BACKLOG_SPRINT_FILTER, or null for every card. */ - value: string | null; - onChange: (next: string | null) => void; -}) { - const t = useT(); - const [managing, setManaging] = useState(false); - const selected = sprints.find((sprint) => sprint.id === value); - const open = sprints.filter((sprint) => sprint.state !== "closed"); - const closed = sprints.filter((sprint) => sprint.state === "closed"); - - const option = (key: string, next: string | null, label: string) => ( - onChange(next)}> - {label} - {value === next && } - - ); - - return ( - <> - {sprints.length === 0 ? ( - setManaging(true)} - > - - - ) : ( - - - - - - {open.map((sprint) => - option(sprint.id, sprint.id, sprintLabel(sprint, t)), - )} - {open.length > 0 && } - {option( - BACKLOG_SPRINT_FILTER, - BACKLOG_SPRINT_FILTER, - t("taskBoard.sprints.backlog"), - )} - {option("all", null, t("taskBoard.sprints.all"))} - {closed.length > 0 && ( - - - {t("taskBoard.sprints.sectionClosed")} - - - {closed.map((sprint) => - option(sprint.id, sprint.id, sprint.name), - )} - - - )} - - setManaging(true)}> - - {t("taskBoard.sprints.manageItem")} - - - - )} - {managing && ( - setManaging(false)} /> - )} - - ); -} - -function SprintsDialog({ +/** Plans, starts, completes, renames and deletes sprints; opened from the filter menu's Sprint field. */ +export function SprintsDialog({ sprints, onClose, }: { diff --git a/apps/web/src/layouts/task-board/task-dialog.tsx b/apps/web/src/layouts/task-board/task-dialog.tsx index 3506ee0c9c7..918710fedc0 100644 --- a/apps/web/src/layouts/task-board/task-dialog.tsx +++ b/apps/web/src/layouts/task-board/task-dialog.tsx @@ -789,7 +789,7 @@ function TaskBoardItemEditor({ prompt — it is a link for a person, so it lives here. */ @@ -800,7 +800,7 @@ function TaskBoardItemEditor({ {item && ( <> { copyLink( @@ -821,7 +821,7 @@ function TaskBoardItemEditor({ button itself, and IconButton would put a Tooltip root in between, which silently swallows the trigger props. */} + + )} ); @@ -544,9 +566,8 @@ function FilterMenu({ ).length; // An org with no tags has no tag filter: the field would open onto nothing. - // The sprint is picked from the view row's switcher, not from here. const offered = fields.filter( - (field) => field.options.length > 0 && field.id !== "sprint", + (field) => field.options.length > 0 || field.action !== undefined, ); return ( @@ -576,6 +597,10 @@ function FilterMenu({ open={browsing && active === field.label} countFor={countFor} onSelect={(option) => select(field, option)} + onAction={() => { + onClose(); + field.action?.onSelect(); + }} /> ))} @@ -632,6 +657,7 @@ export function TaskFilterButton({ index, scoped, onChange, + onManageSprints, }: { filters: TaskFilters; items: TaskBoardItem[]; @@ -642,11 +668,18 @@ export function TaskFilterButton({ /** A project's own board offers no Project filter. */ scoped: boolean; onChange: (next: TaskFilters) => void; + onManageSprints: () => void; }) { const t = useT(); - const fields = useFilterFields({ members, tags, sprints, index, scoped }); - const active = - activeFilterFieldIds(filters, index, currentSprintId(sprints)).length > 0; + const fields = useFilterFields({ + members, + tags, + sprints, + index, + scoped, + onManageSprints, + }); + const active = activeFilterFieldIds(filters, index).length > 0; return ( void; -}) { - const t = useT(); - return ( - - - 0} - > - - - - - {all.map((status) => { - const shown = !hidden.includes(status); - return ( - e.preventDefault()} - onCheckedChange={(next) => onToggle(status, next)} - > - {laneHeader(status, t).label} - - ); - })} - - - ); -} - /** The list view's "Sort" button. Sorting orders the tasks inside every * group, so it composes with grouping instead of replacing it. */ export function SortByButton(props: SortingProps) { @@ -1179,8 +1166,7 @@ export function AppliedFiltersBar({ }) { const t = useT(); const fields = useFilterFields({ members, tags, sprints, index }); - const currentSprint = currentSprintId(sprints); - const activeIds = activeFilterFieldIds(filters, index, currentSprint); + const activeIds = activeFilterFieldIds(filters, index); const arranged = view !== undefined && (view.grouping.groupBy !== null || view.sorting.sortBy !== null); @@ -1222,9 +1208,7 @@ export function AppliedFiltersBar({ field: field.label, })} size="icon-sm" - onClick={() => - onChange(withFieldCleared(filters, id, currentSprint)) - } + onClick={() => onChange(withFieldCleared(filters, id))} > @@ -1252,7 +1236,6 @@ export function AppliedFiltersBar({ onChange({ ...EMPTY_FILTERS, search: filters.search, - sprint: currentSprint, }) } > diff --git a/apps/web/src/layouts/tasks-panel/global-search-dialog.tsx b/apps/web/src/layouts/tasks-panel/global-search-dialog.tsx index ea59bd59f18..348dd321ffa 100644 --- a/apps/web/src/layouts/tasks-panel/global-search-dialog.tsx +++ b/apps/web/src/layouts/tasks-panel/global-search-dialog.tsx @@ -45,11 +45,14 @@ type SearchResponse = { export function GlobalSearchDialog({ open, onOpenChange, + initialQuery = "", }: { open: boolean; onOpenChange: (open: boolean) => void; + /** What the person already typed in the chat list, so the search picks up from there. */ + initialQuery?: string; }) { - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(initialQuery); const { org } = useProjectContext(); const { setTaskId } = usePanelActions(); const studio = useStudioTools(); diff --git a/apps/web/src/layouts/thread-route.test.ts b/apps/web/src/layouts/thread-route.test.ts index e83e99da9f0..28505ec53b5 100644 --- a/apps/web/src/layouts/thread-route.test.ts +++ b/apps/web/src/layouts/thread-route.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - resolveActiveAgentId, resolveDestinationThreadSearch, - routeThreadMatchesAgent, resolveRouteAgentId, resolveRouteThreadId, } from "./thread-route"; @@ -93,33 +91,6 @@ describe("resolveRouteAgentId", () => { }); }); -describe("routeThreadMatchesAgent", () => { - test("accepts a thread owned by the route agent", () => { - expect( - routeThreadMatchesAgent({ - routeAgentId: "agent-a", - threadAgentId: "agent-a", - }), - ).toBe(true); - }); - - test("rejects a concrete cross-agent mismatch", () => { - expect( - routeThreadMatchesAgent({ - routeAgentId: "agent-a", - threadAgentId: "agent-b", - }), - ).toBe(false); - }); - - test("tolerates legacy rows whose ownership is not yet available", () => { - expect( - routeThreadMatchesAgent({ routeAgentId: "agent-a", threadAgentId: null }), - ).toBe(true); - expect(routeThreadMatchesAgent({ routeAgentId: "agent-a" })).toBe(true); - }); -}); - describe("resolveDestinationThreadSearch", () => { test("the page's own search survives a thread switch", () => { expect( @@ -154,33 +125,3 @@ describe("resolveDestinationThreadSearch", () => { ).toEqual({ sidepanel: true, thread: "thread-3" }); }); }); - -describe("resolveActiveAgentId", () => { - /** - * THE BUG, second site: the thread list's "New chat" read only - * `?virtualmcpid=`, so on `/$org/projects/` it found nothing and handed - * the new chat to the Super Agent. The route answers first. - */ - test("the route's agent wins", () => { - expect( - resolveActiveAgentId({ - routeAgentId: "project-1", - threadVirtualMcpId: "agent-9", - }), - ).toBe("project-1"); - }); - - /** Home, Reports and Library name no agent, so the open thread's own is the - * only source left. */ - test("falls back to the open thread's agent", () => { - expect(resolveActiveAgentId({ threadVirtualMcpId: "agent-9" })).toBe( - "agent-9", - ); - }); - - /** `null`, never the Super Agent: the caller decides what "no agent" means. */ - test("a route and a thread that name none answer null", () => { - expect(resolveActiveAgentId({})).toBeNull(); - expect(resolveActiveAgentId({ threadVirtualMcpId: null })).toBeNull(); - }); -}); diff --git a/apps/web/src/layouts/thread-route.ts b/apps/web/src/layouts/thread-route.ts index 647dc80fe29..e2dc2eef0d4 100644 --- a/apps/web/src/layouts/thread-route.ts +++ b/apps/web/src/layouts/thread-route.ts @@ -36,18 +36,6 @@ export function resolveRouteThreadId(input: { return input.taskIdParam ?? input.threadSearch ?? null; } -/** A canonical agent URL may only mount a thread owned by that same agent. - * Missing ownership is tolerated for legacy/partial records; a concrete - * mismatch must be removed before chat or runtime providers see the row. */ -export function routeThreadMatchesAgent(input: { - routeAgentId: string; - threadAgentId?: string | null; -}): boolean { - return ( - input.threadAgentId == null || input.threadAgentId === input.routeAgentId - ); -} - /** The thread id for the matched route, from either the path param or `?thread=`. */ export function useRouteThreadId(): string | null { const params = useParams({ strict: false }); @@ -124,22 +112,6 @@ export function resolveDestinationThreadSearch(input: { return { ...previous, ...changes, thread: input.threadId }; } -/** - * The agent a surface should act on, where "no agent named anywhere" is a real - * answer (`null`) rather than the Super Agent — the thread list, whose new-chat - * button and active-agent highlight both read it. - * - * The route answers first (see {@link resolveRouteAgentId}), with the open - * thread's own agent behind it, which is the only source left on an org-level - * destination. - */ -export function resolveActiveAgentId(input: { - routeAgentId?: string; - threadVirtualMcpId?: string | null; -}): string | null { - return input.routeAgentId ?? input.threadVirtualMcpId ?? null; -} - /** * Navigate to another thread. A same-agent switch preserves the current * route-owned page; a cross-agent switch moves to that agent's canonical root. diff --git a/apps/web/src/lib/query-keys.ts b/apps/web/src/lib/query-keys.ts index 4e4d45a47a6..61aa8f437af 100644 --- a/apps/web/src/lib/query-keys.ts +++ b/apps/web/src/lib/query-keys.ts @@ -223,10 +223,6 @@ export const KEYS = { // e.g. after an MCP connection re-authenticates. mcpClientPrefix: () => ["mcp", "client"] as const, - // The user's recent threads on one agent, for resolving its entry thread. - agentEntryThreads: (orgId: string, userId: string, virtualMcpId: string) => - ["agent-entry-threads", orgId, userId, virtualMcpId] as const, - // Most recent thread per agent. virtualMcpLastUsed: (orgId: string, ids: string[]) => ["virtual-mcp", "last-used", orgId, ids] as const, @@ -913,6 +909,9 @@ export const KEYS = { // A protocol project's changes against its base (`changes.list`); the // prefix of its sides too, so one invalidation refreshes both. contentChanges: (cacheKey: string) => ["content-changes", cacheKey] as const, + /** A chat's hosted draft changes, read without the editor's backend. */ + chatDraftChanges: (orgSlug: string, virtualMcpId: string, branch: string) => + ["chat-draft-changes", orgSlug, virtualMcpId, branch] as const, // A hosted v8 draft's deletions of blocks main doesn't have (its changes // don't list them), by spelling key: their guard is the tombstone's. contentDraftTombstones: (cacheKey: string) => diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index ba4ae1d08ba..5cf43e5b9d0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -444,11 +444,9 @@ const threadSessionRoute = createRoute({ getParentRoute: () => threadRoute, id: "agent-shell", validateSearch: chatLayoutSearchSchema, - /** Chat visibility follows the person across workspace destinations. Agent - * identity never does: canonical agent routes own it in `$agentId`, while - * `virtualmcpid` remains an input/filter only on the legacy and org-level - * routes that explicitly declare it. */ - search: { middlewares: [retainSearchParams(["sidepanel"])] }, + /** The chat, and whether it shows, follow the person across workspace + * destinations; the page's project is context for it, not its owner. */ + search: { middlewares: [retainSearchParams(["sidepanel", "thread"])] }, // Render the centered panel-area loader (matches the shell's own Suspense // fallbacks) while this route loads, instead of the full-screen SplashScreen. // The sidebar is already mounted by orgRoute, so the pending state diff --git a/apps/web/src/routes/site-editor.tsx b/apps/web/src/routes/site-editor.tsx index 11261ba8f11..f82b782756a 100644 --- a/apps/web/src/routes/site-editor.tsx +++ b/apps/web/src/routes/site-editor.tsx @@ -214,8 +214,7 @@ function ServeLostWatcher({ onLost }: { onLost: () => void }) { const NO_CHAT = { threadOpen: false, contentOpen: true, - threadVisibilityExplicit: false, - toggleThread: () => {}, + toggleFullscreen: () => {}, toggleContent: () => {}, }; @@ -234,8 +233,8 @@ function LocalSiteEditor() { contentKey={view ?? "preview"} contentNavigation={} > - {null} + {null} @@ -249,8 +248,8 @@ function LocalSiteEditorGuide({ children }: { children: ReactNode }) { - {null} {children} + {null} diff --git a/apps/web/src/routes/thread-session/route.tsx b/apps/web/src/routes/thread-session/route.tsx index 5d6a94e72ca..8fd68a5fff9 100644 --- a/apps/web/src/routes/thread-session/route.tsx +++ b/apps/web/src/routes/thread-session/route.tsx @@ -1,9 +1,4 @@ import type { ReactNode } from "react"; -import { LayoutLeft } from "@untitledui/icons"; -import { useSidebar } from "@decocms/ui/components/sidebar.tsx"; -import { ToolbarIconButton } from "@/components/toolbar-icon-button"; -import { useSidebarCollapsed } from "@/hooks/use-sidebar-collapsed"; -import { cn } from "@decocms/ui/lib/utils.ts"; /** Binds project and thread runtime to ChatLayout and its routed content. */ import { @@ -24,18 +19,15 @@ import { useProjectContext, useVirtualMCP, parseBranchMap } from "@/sdk"; import type { SandboxMap } from "@decocms/shared/sdk/types"; import { agentHasClonableSource } from "@/lib/agent-capabilities"; import { generateBranchName } from "@decocms/shared/branch-name"; -import { defaultThreadRuntime } from "@decocms/shared/thread/session-runtime"; import { useThreadActions, useThreadManager, } from "@/components/chat/store/hooks"; -import { findAgentEntryThread } from "@/lib/reusable-new-chat"; import { Navigate, Outlet, useNavigate, useParams, - useSearch, } from "@tanstack/react-router"; import { useIsSandboxStartPending } from "@/components/sandbox/hooks/use-sandbox-start"; import { authClient } from "@/lib/auth-client"; @@ -58,7 +50,12 @@ import { blocksV8Project } from "@/hooks/use-blocks-v8-project"; import { useT } from "@/i18n/use-t.ts"; import { Panel } from "@/components/panel"; -import { ChatLayout } from "@/components/chat-layout"; +import { + ChatLayout, + OpenMainPanelButton, + useOptionalChatLayout, +} from "@/components/chat-layout"; +import { SidebarToggleButton } from "@/layouts/route-page-header"; import { ThreadsMenu } from "@/components/chat/threads-menu"; import { NewChatCrumb } from "@/components/header/shell-breadcrumb"; import { MainPanelTabsBar } from "@/layouts/main-panel-tabs/main-panel-tabs-bar"; @@ -74,20 +71,12 @@ import { shouldAdoptBranch, } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { useEnsureTask } from "@/hooks/use-ensure-task"; -import { useAgentEntryThreads } from "@/hooks/use-agent-entry-threads"; -import type { Task } from "@/components/chat/task/types"; -import { MainPanelBoundary, PanelLoading } from "@/layouts/main-panel-boundary"; +import { MainPanelBoundary } from "@/layouts/main-panel-boundary"; import { LegacyAgentWorkspaceRedirect } from "@/layouts/legacy-agent-workspace-redirect"; -import { canonicalThreadRouteTarget } from "@/layouts/main-panel-tabs/tab-route"; import { getWellKnownDecopilotVirtualMCP } from "@/sdk"; -import { - LegacyCanonicalNavigate, - LegacyMainRedirect, -} from "@/layouts/legacy-main-redirect"; +import { LegacyMainRedirect } from "@/layouts/legacy-main-redirect"; import { LegacyThreadRedirect } from "@/layouts/legacy-thread-redirect"; import { - routeThreadMatchesAgent, - useRouteAgentId, useRouteThreadId, useRouteVirtualMcpId, useThreadNavigate, @@ -434,21 +423,14 @@ function VmEventsBridge({ // --------------------------------------------------------------------------- function ThreadTopbar() { - const t = useT(); - const { toggleSidebar } = useSidebar(); - const collapsed = useSidebarCollapsed(); const { virtualMcpId, taskId } = useChatTask(); + const mainPanelClosed = useOptionalChatLayout()?.contentOpen === false; return ( - + - {/* The open chat is the leftmost panel, so it holds the collapsed sidebar's toggle. */} - - - + {/* With the main panel closed, the chat is the leftmost panel. */} + +
@@ -473,16 +455,16 @@ function ThreadSessionContent({ const { virtualMcpId, taskId } = useChatTask(); const contentKey = useActivePanelTabId() ?? "overview"; - /** Takeover apps default the chat panel closed, in local state so - * `?sidepanel` cannot reopen it on arrival. Keyed on the view, so opening - * the chat in one app does not carry into the next one launched. */ + /** Takeover apps open full screen, in local state so neither `?sidepanel` + * nor the person's full-screen preference changes it. Keyed on the view, + * so showing the chat in one app does not carry into the next one. */ const takeover = useAppTakeover(); const [threadOpenIn, setThreadOpenIn] = useState(null); const takeoverThreadOpen = threadOpenIn === contentKey; const threadOpen = takeover ? takeoverThreadOpen : layout.threadOpen; - const toggleThread = takeover + const toggleFullscreen = takeover ? () => setThreadOpenIn(takeoverThreadOpen ? null : contentKey) - : layout.toggleThread; + : layout.toggleFullscreen; return ( <> @@ -493,17 +475,17 @@ function ThreadSessionContent({ } > + }> {/* The conversation is per thread; the app beside it is not. */} - ); @@ -522,28 +504,15 @@ function ThreadSessionProvider() { const params = useParams({ strict: false }); const orgSlug = params.org ?? ""; - const sessionSearch = useSearch({ strict: false }); const routeThreadId = useRouteThreadId(); - /** The canonical project path owns agent identity; organization routes use the Super Agent. */ + /** The page's project: it sets the layout and is context for the chat, never the chat's owner. */ const virtualMcpId = useRouteVirtualMcpId(); - /** Truthy only when the route names a SCOPED agent; `undefined` at org level - * (where `virtualMcpId` falls back to the Super Agent). Gates the threadless - * entry-thread resolver so the org home keeps its fresh composer. */ - const routeAgentId = useRouteAgentId(); - /** A stable thread id to mint when a repo-backed editor arrives with none and - * the user has no idle empty chat to reuse, so a re-render before the URL - * catches up reuses it instead of looping through fresh ones. Generated once - * per mount; only used by the redirect below. */ + /** One chat follows the person across pages and projects, run by the org's assistant. */ + const superAgentId = getWellKnownDecopilotVirtualMCP(org.id).id; + /** A stable id for the composer's next chat; generated once per mount. */ const [generatedThreadId] = useState(() => crypto.randomUUID()); const { data: session } = authClient.useSession(); - /** Drafts-mode entry lands a fresh thread on an editable draft, never on - * read-only production. Generated once per mount, like the thread id above. */ - const [generatedDraftBranch] = useState(() => - generateBranchName( - session?.user?.name || session?.user?.email?.split("@")[0], - ), - ); const threadManager = useThreadManager(); const threads = useSyncExternalStore( threadManager.threads.subscribe, @@ -554,66 +523,30 @@ function ThreadSessionProvider() { threadManager.threadsStatus.get, ); - // Fetch entity (Suspense-based — resolved before render) const entity = useVirtualMCP(virtualMcpId); - const hasActiveRepository = !!(entity && getActiveRepository(entity)); - const baseBranch = useBaseBranch(entity, null); - - /** Set once an arrival lands on the empty composer. A thread minted from that - * composer reaches the store before the URL names it; resolving the entry - * again then swapped the open app and the chat for a redirect. */ - const [composerSettledFor, setComposerSettledFor] = useState( - null, - ); - if (routeThreadId !== null && composerSettledFor !== null) { - setComposerSettledFor(null); + /** Set once an arrival lands on the empty composer, so a chat minted from it + * doesn't send the person back to their previous one. */ + const [composerSettled, setComposerSettled] = useState(false); + if (routeThreadId !== null && composerSettled) { + setComposerSettled(false); } - const resolvingEntry = - routeThreadId === null && - !!routeAgentId && - !!entity && - composerSettledFor !== virtualMcpId; - const findEntry = (candidates: Task[]) => - findAgentEntryThread( - candidates, - virtualMcpId, - session?.user?.id, - defaultThreadRuntime(entity?.metadata), - hasActiveRepository, - { baseBranch, releases: entity?.metadata?.releases ?? undefined }, - ); - const localEntry = - resolvingEntry && threadsStatus.kind !== "loading" - ? findEntry(threads) - : undefined; - // The shared feed is the user's latest page across the org, so this agent's - // last draft can be missing from it; read the agent's own threads before - // minting a duplicate draft. - const scopedEntryThreads = useAgentEntryThreads( - virtualMcpId, - resolvingEntry && threadsStatus.kind !== "loading" && !localEntry, - ); - // Ensure the thread row exists for this URL before rendering the chat. On - // 404 the hook fires COLLECTION_THREADS_CREATE (idempotent) and surfaces a - // "Creating task…" state until the row is persisted. Without this the - // chat renders with branch=null because the thread never existed. - const ensureState = useEnsureTask( - routeThreadId, - virtualMcpId, - // A freshly minted thread lands on an editable draft, never production. - generatedDraftBranch, - ); + // Ensure the thread row exists for this URL before rendering the chat. + const ensureState = useEnsureTask(routeThreadId, superAgentId); - // Read-only teammate threads: pull the current metadata (repository / - // sandboxMap bound by load_repo after the panel snapshot) so the preview - // doesn't render "no source" / miss the owner's sandbox. No-op for own - // threads. Must run before the early returns (Rules of Hooks). + // Read-only teammate threads: pull the current metadata before the early returns. useRefreshViewedThreadMetadata( ensureState.status === "ready" ? ensureState.task : null, ); + /** The chat keeps the agent it started with; older project chats still open as that project. */ + const chatAgentId = + (ensureState.status === "ready" + ? ensureState.task?.virtual_mcp_id + : undefined) ?? superAgentId; + const chatEntity = useVirtualMCP(chatAgentId); + const layoutMetadata = entity?.metadata?.ui?.layout ?? null; const entityMetadata = layoutMetadata ? { @@ -630,14 +563,8 @@ function ThreadSessionProvider() { const createNewTask = async () => { const newTaskId = crypto.randomUUID(); - const branch = - threads.find((thread) => thread.id === threadId)?.branch ?? null; try { - await create({ - id: newTaskId, - virtual_mcp_id: virtualMcpId, - ...(branch ? { branch } : {}), - }); + await create({ id: newTaskId, virtual_mcp_id: superAgentId }); } catch { // The collection action reports errors; the route retries a missing row. } @@ -658,73 +585,34 @@ function ThreadSessionProvider() { return () => document.removeEventListener("keydown", handler); }, []); - const ensuredTask = ensureState.status === "ready" ? ensureState.task : null; - const ensuredTaskAgentId = ensuredTask?.virtual_mcp_id; + // Every page resumes the person's latest chat; a new chat has its own id. if ( - ensuredTask && - ensuredTaskAgentId && - !routeThreadMatchesAgent({ - routeAgentId: virtualMcpId, - threadAgentId: ensuredTaskAgentId, - }) + routeThreadId === null && + !composerSettled && + threadsStatus.kind !== "loading" ) { - const route = canonicalThreadRouteTarget({ - org: orgSlug, - agentId: ensuredTaskAgentId, - superAgentId: getWellKnownDecopilotVirtualMCP(org.id).id, - }); - return ( - - ); - } - - // Resolve a scoped agent's entry thread HERE, in project scope once loaded — useNavigateToAgent's cross-project manager can't see these threads (#6667); repo agents mint one if none resolves, branchless fall through to the lazy composer, org home (no routeAgentId) stays fresh. - if (resolvingEntry) { - // Wait for the thread lists: resolving against an empty list would mint a fresh thread and drop the user off their last version/conversation. - if ( - threadsStatus.kind === "loading" || - scopedEntryThreads.status === "loading" - ) { - // Reading, not creating: the panel's one loader, not "Creating task…". - return ( -
-
- -
-
- ); - } - // Resume the last version/conversation for this agent; a repo editor mints a fresh thread when none resolves, a branchless agent falls through to its lazy composer. - const entry = - localEntry ?? - (scopedEntryThreads.status === "ready" - ? findEntry(scopedEntryThreads.threads) - : undefined); - const threadId = - entry?.id ?? (hasActiveRepository ? generatedThreadId : null); - if (threadId) { + const latest = threads + .filter( + (thread) => + !thread.hidden && + thread.virtual_mcp_id === superAgentId && + thread.created_by === session?.user?.id, + ) + .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")) + .at(0); + if (latest) { return ( ) => ({ ...prev, - thread: threadId, + thread: latest.id, })} /> ); } - setComposerSettledFor(virtualMcpId); + setComposerSettled(true); } if (ensureState.status === "creating" || ensureState.status === "loading") { @@ -769,7 +657,9 @@ function ThreadSessionProvider() { title={t("agentShellLayout.agentShellLayout.agentNotFound")} description={t( "agentShellLayout.agentShellLayout.agentNotFoundDescription", - { virtualMcpId }, + { + virtualMcpId, + }, )} actions={