From 153606bb7723734edb8ae566f747fddf6b16876f Mon Sep 17 00:00:00 2001 From: rafavalls Date: Mon, 9 Nov 2026 13:20:33 -0300 Subject: [PATCH] feat(chat): chat-first layout with in-chat draft cards The chat is the constant surface: it stays open beside every page and only hides when the main panel expands over it. Layout controls live on the main panel (expand, close), the chat reopens a closed panel, and a project's "open the chat" setting is honored again, defaulting to open. The empty chat no longer presents an agent identity: a project is context, not someone you talk to. A chat that changed a site shows its draft as a card in the conversation, with a strip above the composer while it is pending. Also restyles messages (right-aligned user bubbles, dashed queued messages, time beside the cost) and moves the connect prompt to a home banner that leaves once an agent is connected. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat-layout/index.tsx | 159 ++++-- apps/web/src/components/chat/agent-home.tsx | 54 -- .../src/components/chat/drafts/draft-card.tsx | 115 ++++ .../components/chat/drafts/use-chat-draft.ts | 67 +++ apps/web/src/components/chat/ice-breakers.tsx | 496 ------------------ apps/web/src/components/chat/index.tsx | 42 +- apps/web/src/components/chat/input.tsx | 2 + .../src/components/chat/message/assistant.tsx | 16 +- apps/web/src/components/chat/message/pair.tsx | 12 +- .../src/components/chat/message/timestamp.tsx | 2 +- apps/web/src/components/chat/message/user.tsx | 107 ++-- apps/web/src/components/chat/queue-tray.tsx | 76 +-- .../src/components/chat/side-panel-chat.tsx | 15 +- apps/web/src/components/chat/threads-menu.tsx | 8 +- apps/web/src/components/chat/usage-stats.tsx | 17 +- apps/web/src/components/chat/voice/index.tsx | 7 +- .../web/src/components/connect/connect-key.ts | 3 + .../components/import-from-deco-dialog.tsx | 2 +- .../src/components/org-home/connect-pill.tsx | 40 ++ .../src/components/org-home/today-page.tsx | 5 +- .../src/components/sidebar/thread-button.tsx | 15 +- .../src/hooks/use-chat-layout-state.test.ts | 317 +++-------- apps/web/src/hooks/use-chat-layout-state.ts | 166 ++---- apps/web/src/hooks/use-preferences.ts | 6 + apps/web/src/i18n/en/chat.ts | 16 +- apps/web/src/i18n/en/page.ts | 4 + apps/web/src/i18n/pt-br/chat.ts | 17 +- apps/web/src/i18n/pt-br/page.ts | 4 + apps/web/src/layouts/route-page-header.tsx | 100 ++-- apps/web/src/lib/query-keys.ts | 3 + apps/web/src/routes/site-editor.tsx | 7 +- apps/web/src/routes/thread-session/route.tsx | 39 +- apps/web/src/views/settings/org-connect.tsx | 3 +- .../virtual-mcp/settings/use-project-views.ts | 2 +- 34 files changed, 708 insertions(+), 1236 deletions(-) delete mode 100644 apps/web/src/components/chat/agent-home.tsx create mode 100644 apps/web/src/components/chat/drafts/draft-card.tsx create mode 100644 apps/web/src/components/chat/drafts/use-chat-draft.ts delete mode 100644 apps/web/src/components/chat/ice-breakers.tsx create mode 100644 apps/web/src/components/connect/connect-key.ts diff --git a/apps/web/src/components/chat-layout/index.tsx b/apps/web/src/components/chat-layout/index.tsx index eb2312f4dfa..45dec3336eb 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 { Browser, 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,112 @@ 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 ( +
+ {/* 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 ( + + + + ); +} + function ContentBody({ children, routeId, @@ -277,6 +355,7 @@ function ChatLayoutContent({ } />
@@ -294,16 +373,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/drafts/draft-card.tsx b/apps/web/src/components/chat/drafts/draft-card.tsx new file mode 100644 index 00000000000..d8511f4cdf6 --- /dev/null +++ b/apps/web/src/components/chat/drafts/draft-card.tsx @@ -0,0 +1,115 @@ +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 { useOptionalChatStream, useOptionalChatTask } from "../chat-context"; +import { type ChatDraft, useChatDraft } from "./use-chat-draft"; + +const CARD_ID = "chat-draft-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 ( +
+
+ +
+

+ {draft.projectTitle} +

+

+ {draft.branch} +

+
+ +
+
+ + {t( + draft.changeCount === 1 + ? "chat.draft.change" + : "chat.draft.changes", + { count: draft.changeCount }, + )} + + +
+
+ ); +} + +/** Keeps a pending draft in reach once its card scrolls away. */ +export function PendingDraftStrip() { + const t = useT(); + const draft = useChatDraft(); + const messages = useOptionalChatStream()?.messages ?? []; + // An empty chat shows the card itself right above the composer. + if (!draft || messages.length === 0) return null; + return ( + + ); +} 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..d5863008dd7 --- /dev/null +++ b/apps/web/src/components/chat/drafts/use-chat-draft.ts @@ -0,0 +1,67 @@ +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; + +/** + * The draft this chat changed, when it changed one: the thread's branch on a + * site project, with at least one change against main. Reads the hosted + * (Blocks v8) draft directly, so it doesn't wait on the editor's runtime. + */ +export function useChatDraft(): ChatDraft | null { + const { org } = useProjectContext(); + const task = useOptionalChatTask(); + const stream = useOptionalChatStream(); + const virtualMcpId = task?.virtualMcpId ?? null; + const branch = task?.currentBranch ?? null; + 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: POLL_MS, + // A site that isn't v8, or not hosted, simply has no draft to show. + retry: false, + }); + + if (!enabled || !project || changeCount === 0) return null; + return { + virtualMcpId: virtualMcpId!, + projectTitle: project.title, + projectIcon: (project.icon as string | null | undefined) ?? null, + branch: branch!, + changeCount, + editing: + !!stream && + (stream.isRunInProgress || + stream.status === "streaming" || + stream.status === "submitted"), + }; +} 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..8adfe3704ec 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,11 @@ 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 { selectHiddenFromBody } from "./queue-items.ts"; import { LiveMessagesContext, placeLiveMessages } from "./live-messages.ts"; import { useMessageQueue } from "./use-message-queue.ts"; @@ -166,6 +167,15 @@ function ChatMessages() { const visibleMessages = liveMessages.visible; const messagePairs = useMessagePairs(visibleMessages); const lastMessagePair = messagePairs.at(-1); + // The draft card sits after the first answer that used a tool, where the + // work began; with none, after the last answer. + const firstWorkIndex = messagePairs.findIndex((pair) => + pair.assistant?.parts.some( + (part) => part.type === "dynamic-tool" || part.type.startsWith("tool-"), + ), + ); + const draftAnchor = + firstWorkIndex === -1 ? messagePairs.length - 1 : firstWorkIndex; const highlightCount = useHighlightCount(); // Reserve `n × h + 16px` of bottom padding so that, when every highlight @@ -237,13 +247,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 +274,16 @@ function ChatMessages() {
)} {messagePairs.slice(0, -1).map((pair, index) => ( - + + + {index === draftAnchor && } + ))} {lastMessagePair && ( @@ -286,6 +294,7 @@ function ChatMessages() { isLastPair={true} status={status} /> + {draftAnchor === messagePairs.length - 1 && } )} @@ -347,7 +356,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..7479ef61eb1 100644 --- a/apps/web/src/components/chat/input.tsx +++ b/apps/web/src/components/chat/input.tsx @@ -55,6 +55,7 @@ import { useThreadActions } from "./store/hooks"; import type { VirtualMCPInfo } from "./select-virtual-mcp"; import { ChatHighlight } from "./highlight"; import { QueueTray } from "./queue-tray"; +import { PendingDraftStrip } from "./drafts/draft-card"; import { getSupportedFileTypesLabel, modelSupportsFiles } from "./select-model"; import { TierTrigger } from "./tier-trigger"; import type { AiProviderModel } from "@/hooks/collections/use-ai-providers"; @@ -770,6 +771,7 @@ export function ChatInput({ {stream && taskCtx && } {stream && taskCtx && taskId ? : null} + {taskCtx && taskId ? : null} + } + /> ) } dataParts={dataParts} @@ -635,6 +639,13 @@ export function MessageAssistant({ const { collapsed, tail } = shouldCollapse ? splitCollapsible(renderOrder, message!.parts) : { collapsed: [] as RenderItem[], tail: renderOrder }; + const lastVisible = tail.findLast((r) => + 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 +711,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 && ( - <> -
-
- -
- +
+ +
)} {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/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 && ( + + )}
); } 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/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/import-from-deco-dialog.tsx b/apps/web/src/components/import-from-deco-dialog.tsx index 5eab3de23ea..8024d514e30 100644 --- a/apps/web/src/components/import-from-deco-dialog.tsx +++ b/apps/web/src/components/import-from-deco-dialog.tsx @@ -285,7 +285,7 @@ export function ImportFromDecoDialog({ pinnedViews: null, layout: { defaultMainView: { type: "site-editor" }, - chatDefaultOpen: false, + chatDefaultOpen: true, }, }, }, diff --git a/apps/web/src/components/org-home/connect-pill.tsx b/apps/web/src/components/org-home/connect-pill.tsx index 3ab426f0d5e..d7a01a58896 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 6a9a43aeac5..6f3c6536016 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/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-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-preferences.ts b/apps/web/src/hooks/use-preferences.ts index f1793749634..3568f4559ed 100644 --- a/apps/web/src/hooks/use-preferences.ts +++ b/apps/web/src/hooks/use-preferences.ts @@ -26,6 +26,8 @@ interface Preferences { /** Project-first navigation: the org rail, project launcher, project tree and * Today/Agents home. Unset until the person picks; see `projectFirstNavEnabled`. */ projectFirstNav?: boolean; + /** The main panel stays expanded over the chat. */ + contentFullscreen: boolean; } const DEFAULT_PREFERENCES: Preferences = { @@ -36,6 +38,7 @@ const DEFAULT_PREFERENCES: Preferences = { language: detectLocale(), taskBoardLaneVisibility: {}, collapsedTaskBoardLanes: [], + contentFullscreen: false, }; const VALID_TOOL_APPROVAL_LEVELS: ToolApprovalLevel[] = ["auto", "readonly"]; @@ -137,6 +140,9 @@ export function usePreferences() { if (!Array.isArray(merged.collapsedTaskBoardLanes)) { merged.collapsedTaskBoardLanes = []; } + if (typeof merged.contentFullscreen !== "boolean") { + merged.contentFullscreen = false; + } if (typeof merged.projectFirstNav !== "boolean") { delete merged.projectFirstNav; } diff --git a/apps/web/src/i18n/en/chat.ts b/apps/web/src/i18n/en/chat.ts index bcd64b93b2e..852f7d8c27c 100644 --- a/apps/web/src/i18n/en/chat.ts +++ b/apps/web/src/i18n/en/chat.ts @@ -219,14 +219,12 @@ 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.draft.pending": "Draft pending · {project}", "chat.imageLightbox.downloadImage": "Download image", "chat.imageLightbox.zoomIn": "Zoom in", "chat.imageLightbox.zoomOut": "Zoom out", @@ -309,8 +307,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", 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/pt-br/chat.ts b/apps/web/src/i18n/pt-br/chat.ts index dc1c995db8f..f15d33f4aaa 100644 --- a/apps/web/src/i18n/pt-br/chat.ts +++ b/apps/web/src/i18n/pt-br/chat.ts @@ -227,15 +227,12 @@ 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.draft.pending": "Rascunho pendente · {project}", "chat.imageLightbox.downloadImage": "Baixar imagem", "chat.imageLightbox.zoomIn": "Aumentar zoom", "chat.imageLightbox.zoomOut": "Diminuir zoom", @@ -321,8 +318,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", 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/layouts/route-page-header.tsx b/apps/web/src/layouts/route-page-header.tsx index f51b2d1874d..737a89912f7 100644 --- a/apps/web/src/layouts/route-page-header.tsx +++ b/apps/web/src/layouts/route-page-header.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; import type { BreadcrumbItem } from "@/components/page/breadcrumb-model"; import { useRouterState } from "@tanstack/react-router"; -import { LayoutLeft, MessageCircle01 } from "@untitledui/icons"; +import { LayoutLeft } from "@untitledui/icons"; import { useSidebar } from "@decocms/ui/components/sidebar.tsx"; import { Tooltip, @@ -14,7 +14,6 @@ import { Page } from "@/components/page"; import { ToolbarIconButton } from "@/components/toolbar-icon-button"; import { useSidebarCollapsed } from "@/hooks/use-sidebar-collapsed"; import { useInSettings } from "@/hooks/use-in-settings"; -import { useOptionalChatLayout } from "@/components/chat-layout"; import { useProjectFirstNav } from "@/hooks/use-preferences"; import { useScopeId } from "@/hooks/use-project-scope"; @@ -25,18 +24,17 @@ import { useProjectContext, useVirtualMCPNonBlocking } from "@/sdk"; export function RoutePageHeader({ actions, navigation, + trailingActions, }: { actions?: ReactNode; navigation?: ReactNode; + trailingActions?: ReactNode; }) { const t = useT(); const { org } = useProjectContext(); const scopeId = useScopeId(); const project = useVirtualMCPNonBlocking(scopeId); const inSettings = useInSettings(); - const { toggleSidebar } = useSidebar(); - const collapsed = useSidebarCollapsed(); - const chatLayout = useOptionalChatLayout(); const projectFirstNav = useProjectFirstNav(); const page = useRouterState({ select: (state) => @@ -75,63 +73,53 @@ export function RoutePageHeader({ }); } breadcrumbs.push({ key: "page", label: title }); - /** Project-first collapses the sidebar away, and its toggles with it: they - * move here, beside the breadcrumb. */ - const sidebarHidden = projectFirstNav && collapsed; - const chatToggle = sidebarHidden && chatLayout && !chatLayout.threadless && ( - - - - - - - - {t(chatLayout.threadOpen ? "page.closeThread" : "page.openThread")} - - - ); return ( - {projectFirstNav ? ( - - - - - - - - {t("page.expandSidebar")} - - - ) : ( - - - - )} - {chatToggle} - - } + leading={} breadcrumbs={breadcrumbs} scope={org.name} actions={actions} navigation={navigation} + trailingActions={trailingActions} /> ); } + +/** Brings a collapsed sidebar back from the leftmost visible panel. Mobile + * always shows it; desktop only once project-first nav hid the sidebar. */ +export function SidebarToggleButton({ + showOnDesktop = true, +}: { + showOnDesktop?: boolean; +}) { + const t = useT(); + const { toggleSidebar } = useSidebar(); + const collapsed = useSidebarCollapsed(); + const projectFirstNav = useProjectFirstNav(); + if (!projectFirstNav) { + return ( + + + + ); + } + const sidebarHidden = showOnDesktop && collapsed; + return ( + + + + + + + {t("page.expandSidebar")} + + ); +} diff --git a/apps/web/src/lib/query-keys.ts b/apps/web/src/lib/query-keys.ts index 6abcf35b86c..dd476cb2ede 100644 --- a/apps/web/src/lib/query-keys.ts +++ b/apps/web/src/lib/query-keys.ts @@ -859,6 +859,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/routes/site-editor.tsx b/apps/web/src/routes/site-editor.tsx index dbd076fa744..393d3e232b6 100644 --- a/apps/web/src/routes/site-editor.tsx +++ b/apps/web/src/routes/site-editor.tsx @@ -217,8 +217,7 @@ function ServeLostWatcher({ onLost }: { onLost: () => void }) { const NO_CHAT = { threadOpen: false, contentOpen: true, - threadVisibilityExplicit: false, - toggleThread: () => {}, + toggleFullscreen: () => {}, toggleContent: () => {}, }; @@ -237,8 +236,8 @@ function LocalSiteEditor() { contentKey={view ?? "preview"} contentNavigation={} > - {null} + {null} @@ -252,8 +251,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 e960f560bcf..3b560def2bb 100644 --- a/apps/web/src/routes/thread-session/route.tsx +++ b/apps/web/src/routes/thread-session/route.tsx @@ -1,7 +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"; /** Binds project and thread runtime to ChatLayout and its routed content. */ import { @@ -51,7 +48,12 @@ import { 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"; @@ -382,19 +384,14 @@ function VmEventsBridge({ // --------------------------------------------------------------------------- function ThreadTopbar() { - const t = useT(); - const { toggleSidebar } = useSidebar(); const { virtualMcpId, taskId } = useChatTask(); + const mainPanelClosed = useOptionalChatLayout()?.contentOpen === false; return ( - + - - - + {/* With the main panel closed, the chat is the leftmost panel. */} + +
@@ -419,16 +416,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 ( <> @@ -439,17 +436,17 @@ function ThreadSessionContent({ } > + }> {/* The conversation is per thread; the app beside it is not. */} - ); diff --git a/apps/web/src/views/settings/org-connect.tsx b/apps/web/src/views/settings/org-connect.tsx index 33e5d414dc4..89ae1d7f76f 100644 --- a/apps/web/src/views/settings/org-connect.tsx +++ b/apps/web/src/views/settings/org-connect.tsx @@ -60,6 +60,7 @@ import { InstallSnippet, } from "@/components/connect/install-snippet"; import { mcpUrl } from "@/components/connect/mcp-url"; +import { CONNECT_KEY_PREFIX } from "@/components/connect/connect-key"; import { cliLoginCommand, skillInstallCommand, @@ -72,7 +73,7 @@ import { RequireCapability } from "@/components/require-capability"; import { useCapability } from "@/hooks/use-capability"; import { ApiKeysSection } from "@/views/settings/api-keys"; -const KEY_NAME_PREFIX = "Connect: "; +const KEY_NAME_PREFIX = CONNECT_KEY_PREFIX; type ConnectVia = "skill" | "mcp"; diff --git a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts index 623c01c9b6e..87935bcce7a 100644 --- a/apps/web/src/views/virtual-mcp/settings/use-project-views.ts +++ b/apps/web/src/views/virtual-mcp/settings/use-project-views.ts @@ -163,7 +163,7 @@ export function useProjectViews({ const pinnedViews = form.watch("metadata.ui.pinnedViews") ?? []; const layoutMeta = form.watch("metadata.ui.layout") ?? null; const currentDefaultMain = layoutMeta?.defaultMainView ?? null; - const chatDefaultOpen = layoutMeta?.chatDefaultOpen ?? false; + const chatDefaultOpen = layoutMeta?.chatDefaultOpen ?? true; // A Blocks (v8) site: Experiments and Calendar are offered once on top of // a list saved before they were defaults, until the next save. const blocksSite = nativeViews.presence.calendar;