From e4727075678369d6d0fd523010454d98e7d47955 Mon Sep 17 00:00:00 2001 From: ojowwalker77 Date: Thu, 23 Jul 2026 09:25:47 -0300 Subject: [PATCH 1/2] Drag-to-split, Worker channel cards, and a composer that gets out of the way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six strands, kept in one commit because ChatView sits in the middle of most of them and a path-based split would be arbitrary. Worker channel card. A cross-Worker request rendered as its raw protocol prompt — channel id, tool instructions, "never edit the requesting repository" — in the middle of the transcript. That is model-facing text. It is now a card carrying the subject, peer Worker, status, the ask, replies, and controls to open the peer Thread or close the channel. Channel traffic is recognised by the message id the reactor already minted, moved to packages/shared so both ends use one scheme; matching on `dispatchOrigin: "automation"` instead would have swallowed scheduled automations. Card status is deliberately not Task status: a Task is in_progress from the moment the responder starts, but the reader wants to know whether an answer came back. Drag-to-split. Drag a Thread from the sidebar onto a pane; the outer thirds split, the middle replaces. Panes form a tree, not a list — a list has one axis, so splitting a pane inside a column had to reorient every other pane. Each branch owns its axis and divides evenly at every depth. Same-axis drops join the existing branch rather than nesting, which is what keeps siblings equal. Structure collapses on removal so repeated split/close leaves no invisible single-child branches. Splits persist, keyed by the Thread that owns the route, and prune when their Threads are deleted. Composer. ⌘J collapses it into a provider-icon button, taking its gutter and the branch row with it. It floats rather than sitting in flow, so scrolling surfaces run full height underneath instead of being cut short. Model and effort pickers moved into the branch bar — Model, Effort, Local, Branch — and the footer's existing overflow tiering follows them, dropping the effort label then the model name as the pane narrows. Local/Worktree disappears once the Thread has its first message and the environment stops being changeable. Sidebar edge peek, ported from phibrowser-mac: a 10px strip armed 350ms after collapse, opening immediately and closing on a 120ms delay with a 500ms floor, re-sampling hover at fire time because a panel appearing under a stationary cursor never emits an enter. Settings regrouped. General was a dumping ground of eight unrelated groups while shortcuts and worktrees hid under Advanced. Now eleven sections in four nav groups, with every retired section id still resolving so existing deep links work. Eleven dead search entries pointing at rows that no longer render are gone, and Profile — previously unreachable from search — has entries. File tagging removed from `@`. Agents read the repository themselves. Workers, Tasks and plugins stay; only the file and folder paths go, and existing `@path` chips still render so old Threads are unchanged. Fixes a stuck sidebar resize: the drag ended only via handlers on the rail, so a lost pointer capture or a release outside the window left it running and the next pointer move resized again. It now ends from window listeners, including lostpointercapture and blur. --- apps/server/src/keybindings.ts | 1 + .../Layers/WorkerInboxReactor.ts | 3 +- .../src/orchestration/workerToolsMcp.ts | 7 +- apps/web/src/components/BranchToolbar.tsx | 14 +- .../BranchToolbarBranchSelector.tsx | 6 +- apps/web/src/components/ChatView.tsx | 563 ++++++++++-------- apps/web/src/components/Sidebar.tsx | 15 + apps/web/src/components/chat/ChatHeader.tsx | 24 + .../src/components/chat/ChatSplitSurface.tsx | 192 ++++++ .../components/chat/ChatTranscriptPane.tsx | 10 + .../chat/ComposerCommandMenu.test.ts | 23 +- .../components/chat/ComposerCommandMenu.tsx | 70 +-- .../components/chat/MessagesTimeline.logic.ts | 54 ++ .../src/components/chat/MessagesTimeline.tsx | 20 + .../src/components/chat/WorkerChannelCard.tsx | 179 ++++++ .../src/components/chat/workerChannel.test.ts | 173 ++++++ apps/web/src/components/chat/workerChannel.ts | 153 +++++ .../research/ResearchDocumentView.tsx | 12 +- apps/web/src/components/threadDragSource.ts | 32 + apps/web/src/components/ui/sidebar.tsx | 212 +++++-- .../src/hooks/useComposerCommandMenuItems.ts | 42 +- apps/web/src/hooks/useMeasuredHeight.ts | 39 ++ apps/web/src/hooks/useSidebarEdgePeek.ts | 153 +++++ apps/web/src/index.css | 15 + apps/web/src/keybindings.ts | 4 + apps/web/src/lib/disclosureMotion.ts | 44 ++ apps/web/src/lib/sidebarPeek.test.ts | 52 ++ apps/web/src/lib/sidebarPeek.ts | 70 +++ apps/web/src/routes/_chat.$threadId.tsx | 34 +- .../_chat.research.$researchId.$threadId.tsx | 5 +- apps/web/src/routes/_chat.settings.tsx | 448 +++++++------- apps/web/src/settingsNavigation.test.ts | 68 +++ apps/web/src/settingsNavigation.ts | 107 +++- apps/web/src/settingsSearchIndex.ts | 120 +--- apps/web/src/shortcutsSheet.ts | 5 + apps/web/src/splitPaneStore.ts | 138 +++++ apps/web/src/splitPanes.test.ts | 271 +++++++++ apps/web/src/splitPanes.ts | 259 ++++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/package.json | 4 + .../shared/src/workerChannelMessages.test.ts | 39 ++ packages/shared/src/workerChannelMessages.ts | 62 ++ 42 files changed, 2906 insertions(+), 837 deletions(-) create mode 100644 apps/web/src/components/chat/ChatSplitSurface.tsx create mode 100644 apps/web/src/components/chat/WorkerChannelCard.tsx create mode 100644 apps/web/src/components/chat/workerChannel.test.ts create mode 100644 apps/web/src/components/chat/workerChannel.ts create mode 100644 apps/web/src/components/threadDragSource.ts create mode 100644 apps/web/src/hooks/useMeasuredHeight.ts create mode 100644 apps/web/src/hooks/useSidebarEdgePeek.ts create mode 100644 apps/web/src/lib/sidebarPeek.test.ts create mode 100644 apps/web/src/lib/sidebarPeek.ts create mode 100644 apps/web/src/settingsNavigation.test.ts create mode 100644 apps/web/src/splitPaneStore.ts create mode 100644 apps/web/src/splitPanes.test.ts create mode 100644 apps/web/src/splitPanes.ts create mode 100644 packages/shared/src/workerChannelMessages.test.ts create mode 100644 packages/shared/src/workerChannelMessages.ts diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index a52f6f30..df043496 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -74,6 +74,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+d", command: "diff.toggle" }, // Cmd-only instead of mod so Ctrl+L remains available to shells on non-macOS. { key: "cmd+l", command: "composer.focus.toggle" }, + { key: "mod+j", command: "composer.collapse.toggle" }, { key: "mod+shift+m", command: "modelPicker.toggle" }, { key: "mod+shift+e", command: "traitsPicker.toggle" }, { key: "mod+shift+u", command: "settings.usage" }, diff --git a/apps/server/src/orchestration/Layers/WorkerInboxReactor.ts b/apps/server/src/orchestration/Layers/WorkerInboxReactor.ts index bc4a6b4e..4602a708 100644 --- a/apps/server/src/orchestration/Layers/WorkerInboxReactor.ts +++ b/apps/server/src/orchestration/Layers/WorkerInboxReactor.ts @@ -11,6 +11,7 @@ import { type OrchestrationTaskShell, } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { workerChannelRequestMessageId } from "@t3tools/shared/workerChannelMessages"; import { Cause, Effect, Layer, Stream } from "effect"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -29,7 +30,7 @@ import { // partial failure replays the same commands instead of spawning a second Thread. const spawnCommandIds = (taskId: TaskId) => ({ threadId: ThreadId.makeUnsafe(`worker-inbox:${taskId}`), - messageId: MessageId.makeUnsafe(`worker-inbox:${taskId}:request`), + messageId: MessageId.makeUnsafe(workerChannelRequestMessageId(taskId)), threadCreate: CommandId.makeUnsafe(`worker-inbox:${taskId}:thread-create`), taskInProgress: CommandId.makeUnsafe(`worker-inbox:${taskId}:in-progress`), turnStart: CommandId.makeUnsafe(`worker-inbox:${taskId}:turn-start`), diff --git a/apps/server/src/orchestration/workerToolsMcp.ts b/apps/server/src/orchestration/workerToolsMcp.ts index ed3553f6..7c201d86 100644 --- a/apps/server/src/orchestration/workerToolsMcp.ts +++ b/apps/server/src/orchestration/workerToolsMcp.ts @@ -18,6 +18,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import type { ServerConfigShape } from "../config.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { workerChannelReplyMessageId } from "@t3tools/shared/workerChannelMessages"; import { buildDelegationReplyPrompt, peerThreadFor, @@ -416,7 +417,11 @@ export function runWorkerTool(input: { commandId: commandId(), threadId: peer.peerThreadId, message: { - messageId: MessageId.makeUnsafe(crypto.randomUUID()), + // Minted through the shared scheme so the transcript folds this into the + // channel card instead of rendering it as a typed user message. + messageId: MessageId.makeUnsafe( + workerChannelReplyMessageId(task.id, crypto.randomUUID()), + ), role: "user", text: buildDelegationReplyPrompt({ task, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1490fa6a..1bfcb46f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -247,7 +247,12 @@ export default function BranchToolbar({ !envLocked && !activeWorktreePath && effectiveEnvMode === "local", ); const canSwitchToLocal = Boolean(!envLocked && effectiveEnvMode === "worktree"); - const showEnvPicker = effectiveEnvMode === "local" || canSwitchToLocal; + // Once a Thread has its first message the environment is fixed, so naming it + // every time is noise — the branch alone identifies where the work is going. + // The panel variant still shows it, because that surface is the place you go to + // read the environment rather than to compose. + const showEnvPicker = + (isPanel || !envLocked) && (effectiveEnvMode === "local" || canSwitchToLocal); const usageSummary = useProviderUsageSummary({ provider: activeProvider, @@ -398,12 +403,7 @@ export default function BranchToolbar({ label={environmentPresentation.shortLabel} /> - ) : ( - - - {environmentPresentation.shortLabel} - - )} + ) : null} {showBranchSelector ? ( @@ -837,7 +837,9 @@ export function BranchToolbarBranchSelector({ ) : ( <> - {triggerLabel} + {/* Shrinkable, not a fixed max-width: at 240px a narrower row clipped + the label with a hard edge instead of letting it ellipsise. */} + {triggerLabel} )} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7c0fc95..9780392b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9,7 +9,6 @@ import { type ProjectScript, type ModelSlug, type ProviderKind, - type ProjectEntry, type ProjectId, type ProviderApprovalDecision, type ProviderAgentDescriptor, @@ -60,9 +59,10 @@ import { useState, type MouseEvent, type ReactNode, + type CSSProperties, } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Debouncer, useDebouncedValue } from "@tanstack/react-pacer"; +import { Debouncer } from "@tanstack/react-pacer"; import { useNavigate } from "@tanstack/react-router"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -82,7 +82,6 @@ import { supportsSkillDiscovery, supportsThreadCompaction, } from "~/lib/providerDiscoveryReactQuery"; -import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { useRefreshProviderStatusesNow } from "~/hooks/useProviderStatusRefresh"; import { SINGLE_CHAT_PANE_SCOPE_ID } from "~/lib/chatPaneScope"; @@ -94,7 +93,6 @@ import { providerSkillReferencesEqual, skillMentionPrefix, } from "~/lib/composerMentions"; -import { getLocalFolderBrowseRootPath, isLocalFolderMentionQuery } from "~/lib/localFolderMentions"; import { findProviderStatus, isProviderUsable, @@ -230,16 +228,16 @@ import { ChevronLeftIcon, ChevronRightIcon, ComposerSendArrowIcon, - LayoutSidebarIcon, PencilIcon, + LayoutSidebarIcon, RefreshCwIcon, XIcon, } from "~/lib/icons"; import { ComposerQueuedHeader } from "./chat/ComposerQueuedHeader"; import { ComposerLiveChangesHeader } from "./chat/ComposerLiveChangesHeader"; +import { ProviderIcon } from "./ProviderIcon"; import { Button } from "./ui/button"; -import { DisclosureChevron } from "./ui/DisclosureChevron"; -import { DisclosureRegion } from "./ui/DisclosureRegion"; +import { IconButton } from "./ui/icon-button"; import { Skeleton } from "./ui/skeleton"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; // terminalSession removed @@ -257,7 +255,10 @@ import { type ProjectScriptRunOptions, type ProjectScriptRunResult, } from "~/projectScripts"; +import { disclosurePopClassName } from "~/lib/disclosureMotion"; +import { useMeasuredHeight } from "~/hooks/useMeasuredHeight"; import { launchProjectRun } from "~/projectRunLauncher"; +import { deriveWorkerChannels, type WorkerChannelView } from "./chat/workerChannel"; import { newCommandId, newMessageId, newProjectId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; // terminalCloseConfirmation removed @@ -358,10 +359,6 @@ import { import { ComposerModelEffortPicker } from "./chat/ComposerModelEffortPicker"; import { resolveTraitsTriggerSummary, TraitsPicker } from "./chat/TraitsPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; -import { - ComposerLocalDirectoryMenu, - type ComposerLocalDirectoryMenuHandle, -} from "./chat/ComposerLocalDirectoryMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerInputBanners } from "./chat/ComposerInputBanners"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; @@ -455,7 +452,6 @@ const EMPTY_PINNED_MESSAGES: readonly PinnedMessage[] = []; const EMPTY_THREAD_MARKERS: readonly ThreadMarker[] = []; const EMPTY_PINNED_TEXT: ReadonlyMap = new Map(); const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDER_NATIVE_COMMANDS: ProviderNativeCommandDescriptor[] = []; const EMPTY_PROVIDER_SKILLS: ProviderSkillDescriptor[] = []; const LOCAL_PROJECT_DRAFT_CONTEXT = { @@ -657,8 +653,6 @@ function formatPastedTextTitleSeed(pastedTexts: ReadonlyArray): : `${pastedTexts.length} pasted texts`; } -const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; - function ComposerControlSkeleton(props: { widthClassName: string }) { return (
void; } | null; onChangeThreadInSplitPane?: () => void; + /** Provided for a pane in a split; the only surface that can be closed. */ + onClosePane?: (() => void) | undefined; onCloseThreadPane?: () => void; /** Replaces the transcript while preserving TeaCode's real composer and thread runtime. */ transcriptContent?: ReactNode; @@ -711,11 +707,13 @@ interface ChatViewProps { surfaceTitle?: string; /** Adds surface-specific context to provider messages without changing the visible draft. */ transformOutgoingPrompt?: (prompt: string) => string; - /** Keeps a secondary surface's agent composer available without competing with its content. */ - composerDisclosure?: { - label: string; - description: string; - }; + /** + * Starts the Thread with the composer collapsed, for surfaces whose own content + * is the point (research documents). A boolean, not a copy object: an inline + * object prop changes identity every render, which made the reset effect below + * re-run continuously and flicker the composer open and shut. + */ + composerCollapsedByDefault?: boolean; } function normalizeRestoredQueuedPrompt(value: string): string { @@ -756,15 +754,17 @@ export default function ChatView({ onMaximizeSurface, viewModeAction = null, onChangeThreadInSplitPane, + onClosePane, onCloseThreadPane, transcriptContent, surfaceTitle, transformOutgoingPrompt, - composerDisclosure, + composerCollapsedByDefault = false, }: ChatViewProps) { const markThreadVisited = useStore((store) => store.markThreadVisited); const workerProjects = useStore((store) => store.projects); const workerTasks = useStore((store) => store.tasks); + const allThreads = useStore((store) => store.threads); const syncServerShellSnapshot = useStore((store) => store.syncServerShellSnapshot); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadWorkspace = useStore((store) => store.setThreadWorkspace); @@ -785,10 +785,10 @@ export default function ChatView({ const queryClient = useQueryClient(); const isEditorRail = presentationMode === "editor"; const isInactiveSplitPane = surfaceMode === "split" && !isFocusedPane; - const [composerDisclosureRequestedOpen, setComposerDisclosureRequestedOpen] = useState(false); + const [composerCollapsed, setComposerCollapsed] = useState(composerCollapsedByDefault); useEffect(() => { - setComposerDisclosureRequestedOpen(false); - }, [composerDisclosure?.label, threadId]); + setComposerCollapsed(composerCollapsedByDefault); + }, [composerCollapsedByDefault, threadId]); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerPromptHistorySavedDraft = composerDraft.promptHistorySavedDraft; @@ -922,6 +922,10 @@ export default function ChatView({ // can re-plan without re-subscribing; the sync function is exposed via ref // so label changes can re-plan without a resize. const [composerFooterTier, setComposerFooterTier] = useState(0); + // The picker cluster moved out of the composer footer and into the branch + // underbar, so overflow has to be measured there — the footer no longer holds + // the controls the tiering degrades. + const composerUnderbarRef = useRef(null); const composerFooterTierRef = useRef(0); const composerFooterDemotionWidthsRef = useRef>([]); const composerFooterLayoutSyncRef = useRef<(() => void) | null>(null); @@ -1060,7 +1064,6 @@ export default function ChatView({ // blocked it; nothing else re-triggers the effect once they reset. const [queuedAutoDispatchTick, setQueuedAutoDispatchTick] = useState(0); const activeComposerMenuItemRef = useRef(null); - const localDirectoryMenuRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); @@ -2071,8 +2074,8 @@ export default function ChatView({ // (live file changes, active task list, queued follow-ups). The composer overlaps the // scrolling transcript, so the transcript reserves matching bottom space to keep its // last rows clear of this chrome instead of letting them slide underneath and clip. - const [composerStackedChromeHeight, setComposerStackedChromeHeight] = useState(0); - const composerStackedChromeObserverRef = useRef(null); + const [composerStackedChromeHeight, measureComposerStackedChrome] = useMeasuredHeight(); + const [composerFloatingHeight, measureComposerFloating] = useMeasuredHeight(); const previousComposerStackedChromeHeightRef = useRef(0); const activeTaskList = useMemo((): ActiveTaskListState | null => { if (showDebugTaskBanner) { @@ -2125,44 +2128,18 @@ export default function ChatView({ // the composer mounts/unmounts, and the observer catches every panel appearing, // resizing, or collapsing. Measuring the wrapper (rather than each panel) keeps one // source of truth as panels are added or removed. - const measureComposerStackedChrome = useCallback((element: HTMLDivElement | null) => { - composerStackedChromeObserverRef.current?.disconnect(); - composerStackedChromeObserverRef.current = null; - if (!element) { - setComposerStackedChromeHeight(0); - return; - } - - const updateHeight = () => { - setComposerStackedChromeHeight(Math.ceil(element.getBoundingClientRect().height)); - }; - - updateHeight(); - if (typeof ResizeObserver === "undefined") { - return; - } - - const observer = new ResizeObserver(updateHeight); - observer.observe(element); - composerStackedChromeObserverRef.current = observer; - // React invokes this callback ref with null on unmount (and re-attaches if the node - // changes), so the disconnect at the top of this function is the single teardown path. - }, []); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; + // Forced open only for states that need the composer in view: a running turn to + // watch or stop, and prompts that need answering. Connecting is not one of them + // — a research Thread connects its session on open, which kept the composer + // expanded on arrival even though the disclosure defaults to closed. const composerDisclosureForcedOpen = - composerDisclosure !== undefined && - (isConnecting || - phase === "running" || - activePendingApproval !== null || - pendingUserInputs.length > 0); - const composerDisclosureOpen = - composerDisclosure === undefined || - composerDisclosureRequestedOpen || - composerDisclosureForcedOpen; + phase === "running" || activePendingApproval !== null || pendingUserInputs.length > 0; + const composerDisclosureOpen = !composerCollapsed || composerDisclosureForcedOpen; const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ @@ -2642,25 +2619,8 @@ export default function ChatView({ }) : null; const composerTriggerKind = composerTrigger?.kind ?? null; - const mentionTriggerQuery = composerTrigger?.kind === "mention" ? composerTrigger.query : ""; - const isMentionTrigger = composerTriggerKind === "mention"; - const platform = typeof navigator === "undefined" ? "" : navigator.platform; const branchesQuery = useQuery(gitBranchesQueryOptions(gitBranchSourceCwd)); - const localFolderBrowseRootPath = getLocalFolderBrowseRootPath( - serverConfigQuery.data?.homeDir ?? null, - isMacPlatform(platform), - ); - const isLocalFolderBrowserOpen = - composerCommandPicker === null && - isMentionTrigger && - isLocalFolderMentionQuery(mentionTriggerQuery); const isSkillTrigger = composerTriggerKind === "skill"; - const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( - mentionTriggerQuery, - { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, - (debouncerState) => ({ isPending: debouncerState.isPending }), - ); - const effectiveMentionQuery = mentionTriggerQuery.length > 0 ? debouncedPathQuery : ""; const composerSkillCwd = providerModelDiscoveryCwd; const providerComposerCapabilitiesQuery = useQuery( providerComposerCapabilitiesQueryOptions(selectedProvider), @@ -2723,15 +2683,6 @@ export default function ChatView({ composerSkillCwd !== null, }), ); - const workspaceEntriesQuery = useQuery( - projectSearchEntriesQueryOptions({ - cwd: gitCwd, - query: effectiveMentionQuery, - enabled: isMentionTrigger && !isLocalFolderBrowserOpen, - limit: 80, - }), - ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const activeRootBranch = useMemo( () => resolveComposerSlashRootBranch({ @@ -2842,7 +2793,6 @@ export default function ChatView({ providerPlugins, providerNativeCommands, providerSkills, - workspaceEntries, workers: workerProjects.filter((project) => project.kind === "project"), tasks: workerTasks, currentWorkerId: activeThread?.projectId ?? "", @@ -3084,6 +3034,10 @@ export default function ChatView({ () => shortcutLabelForCommand(keybindings, "chat.split"), [keybindings], ); + const composerCollapseShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "composer.collapse.toggle"), + [keybindings], + ); const modelPickerShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "modelPicker.toggle") ?? @@ -3204,24 +3158,36 @@ export default function ChatView({ [setStoreThreadError], ); - const focusComposer = useCallback(() => { - if (composerDisclosure && !composerDisclosureOpen) { - pendingComposerFocusRef.current = true; - setComposerDisclosureRequestedOpen(true); + // `expand` separates an explicit "put me in the composer" (⌘L, the collapsed + // bar) from the ambient refocus the app does on thread mount and after a send. + // Without it, any ambient focus reopened a composer the user had just hidden — + // which read as ⌘J collapsing and instantly uncollapsing. + const focusComposer = useCallback( + (options?: { expand?: boolean }) => { + if (!composerDisclosureOpen) { + if (!options?.expand) return; + pendingComposerFocusRef.current = true; + setComposerCollapsed(false); + return; + } + // Secondary chrome is deferred during thread switches; replay focus once it + // mounts. A disabled editor (dispatch connecting, pending approval) cannot + // take focus either, so keep the request pending until it re-enables. + const editor = composerEditorRef.current; + if (!secondaryChromeReady || !editor || isComposerEditorDisabled) { + pendingComposerFocusRef.current = true; + return; + } + pendingComposerFocusRef.current = false; + editor.focusAtEnd(); + }, + [composerDisclosureOpen, secondaryChromeReady, isComposerEditorDisabled], + ); + const toggleComposerFocus = useCallback(() => { + if (!composerDisclosureOpen) { + focusComposer({ expand: true }); return; } - // Secondary chrome is deferred during thread switches; replay focus once it - // mounts. A disabled editor (dispatch connecting, pending approval) cannot - // take focus either, so keep the request pending until it re-enables. - const editor = composerEditorRef.current; - if (!secondaryChromeReady || !editor || isComposerEditorDisabled) { - pendingComposerFocusRef.current = true; - return; - } - pendingComposerFocusRef.current = false; - editor.focusAtEnd(); - }, [composerDisclosure, composerDisclosureOpen, secondaryChromeReady, isComposerEditorDisabled]); - const toggleComposerFocus = useCallback(() => { const editor = composerEditorRef.current; if (secondaryChromeReady && editor?.isFocused()) { pendingComposerFocusRef.current = false; @@ -3229,7 +3195,7 @@ export default function ChatView({ return; } focusComposer(); - }, [focusComposer, secondaryChromeReady]); + }, [composerDisclosureOpen, focusComposer, secondaryChromeReady]); const scheduleComposerFocus = useCallback(() => { pendingComposerFocusRef.current = true; window.requestAnimationFrame(() => { @@ -3870,21 +3836,14 @@ export default function ChatView({ // Tier the footer controls by MEASURED overflow: demote one step while // the footer row's content is wider than the row, promote back (with // hysteresis) when the recorded overflow width is comfortably exceeded. - const footerRow = composerForm.querySelector("[data-chat-composer-footer]"); - if (footerRow) { - const rowOverflows = footerRow.scrollWidth > footerRow.clientWidth + 1; - // The leading cluster clips (overflow-hidden) in compact mode instead - // of growing the row's scrollWidth, so check it directly — a clipped - // leading cluster must also demote the tier. - const leadingCluster = footerRow.querySelector("[data-chat-composer-leading]"); - const leadingClips = - nextCompact && - leadingCluster !== null && - leadingCluster.scrollWidth > leadingCluster.clientWidth + 1; + // The underbar is `overflow-hidden`, so its content clips rather than + // growing the row — compare scroll width against client width directly. + const pickerRow = composerUnderbarRef.current; + if (pickerRow) { const nextStep = resolveNextComposerFooterTier({ currentTier: composerFooterTierRef.current, - clientWidth: footerRow.clientWidth, - isOverflowing: rowOverflows || leadingClips, + clientWidth: pickerRow.clientWidth, + isOverflowing: pickerRow.scrollWidth > pickerRow.clientWidth + 1, demotionWidths: composerFooterDemotionWidthsRef.current, }); composerFooterDemotionWidthsRef.current = nextStep.demotionWidths; @@ -3933,6 +3892,11 @@ export default function ChatView({ }); observer.observe(composerForm); + // The underbar holds the tiered pickers now and can change width independently + // of the composer form (split resize, branch label length), so it needs its own + // observation or the tier never re-evaluates. + const underbar = composerUnderbarRef.current; + if (underbar) observer.observe(underbar); return () => { observer.disconnect(); }; @@ -4424,6 +4388,17 @@ export default function ChatView({ return; } + if (command === "composer.collapse.toggle") { + event.preventDefault(); + event.stopPropagation(); + if (composerDisclosureForcedOpen) return; + setComposerCollapsed((collapsed) => { + if (collapsed) window.requestAnimationFrame(() => scheduleComposerFocus()); + return !collapsed; + }); + return; + } + if (command === "modelPicker.toggle") { if (!composerPickerShortcutActive) return; event.preventDefault(); @@ -4471,6 +4446,7 @@ export default function ChatView({ }, [ activeProject, activeThreadId, + composerDisclosureForcedOpen, runProjectScript, keybindings, onToggleDiff, @@ -6350,8 +6326,17 @@ export default function ChatView({ useLayoutEffect(() => { composerFooterLayoutSyncRef.current?.(); }, [composerFooterTier]); - const composerModelPickerWidthClassName = isComposerFooterCompact ? "w-32" : "w-36 sm:w-44"; - const composerOptionsPickerWidthClassName = isComposerFooterCompact ? "w-28" : "w-32"; + // Capped rather than fixed widths. These pickers moved out of the composer + // footer and into the branch underbar, which is narrower and shares its row with + // the worktree and branch controls — a hard `w-*` there overflows a split pane + // instead of giving way. `isComposerFooterCompact` still sets the ceiling, since + // it tracks the same pane width. + const composerModelPickerWidthClassName = isComposerFooterCompact + ? "w-full min-w-0 max-w-32 shrink" + : "w-full min-w-0 max-w-36 shrink sm:max-w-44"; + const composerOptionsPickerWidthClassName = isComposerFooterCompact + ? "w-full min-w-0 max-w-28 shrink" + : "w-full min-w-0 max-w-32 shrink"; const composerModelEffortPickerWidthClassName = isComposerFooterCompact ? "w-40" : "w-44 sm:w-52"; const isComposerModelEffortPickerOpen = isModelPickerOpen || isTraitsPickerOpen; const handleComposerModelEffortPickerOpenChange = useCallback( @@ -6833,38 +6818,11 @@ export default function ChatView({ ); // Replaces the active `@...` token with a completed absolute folder mention. - const handleSelectLocalDirectoryMention = useCallback( - (absolutePath: string) => { - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - applyComposerTriggerReplacement({ - snapshot, - trigger, - base: `${formatComposerMentionToken(absolutePath)} `, - }); - }, - [applyComposerTriggerReplacement, resolveActiveComposerTrigger], - ); // Rewrites the active `@...` mention to an absolute folder path with a trailing separator // so the local-folder picker stays open and the user can keep browsing by clicking or typing. // Paths with whitespace are written as an unclosed `@"...` so detectComposerTrigger keeps // matching and the picker stays open while the user descends into folders with spaces. - const handleNavigateLocalFolder = useCallback( - (absolutePath: string) => { - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - const separator = absolutePath.includes("\\") ? "\\" : "/"; - const withTrailingSeparator = absolutePath.endsWith(separator) - ? absolutePath - : `${absolutePath}${separator}`; - const base = /\s/.test(withTrailingSeparator) - ? `@"${withTrailingSeparator}` - : `@${withTrailingSeparator}`; - applyComposerTriggerReplacement({ snapshot, trigger, base }); - }, - [applyComposerTriggerReplacement, resolveActiveComposerTrigger], - ); const setComposerPromptValue = useCallback( (nextPrompt: string) => { @@ -7003,18 +6961,6 @@ export default function ChatView({ } const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - if (item.type === "path") { - applyComposerTriggerReplacement({ - snapshot, - trigger, - base: `${formatComposerMentionToken(item.path)} `, - }); - return; - } - if (item.type === "local-root") { - handleNavigateLocalFolder(localFolderBrowseRootPath ?? "/"); - return; - } if (item.type === "slash-command") { handleSlashCommandSelection(item); return; @@ -7090,12 +7036,10 @@ export default function ChatView({ applyComposerTriggerReplacement, scheduleComposerFocus, handleForkTargetSelection, - handleNavigateLocalFolder, handleReviewTargetSelection, handleSlashCommandSelection, onProviderModelSelect, setComposerCommandPicker, - localFolderBrowseRootPath, selectedProvider, updateSelectedComposerMentions, updateSelectedComposerSkills, @@ -7125,11 +7069,7 @@ export default function ChatView({ ); const isComposerMenuLoading = (composerTriggerKind === "mention" && - ((mentionTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || - workspaceEntriesQuery.isLoading || - workspaceEntriesQuery.isFetching || - providerPluginsQuery.isLoading || - providerPluginsQuery.isFetching)) || + (providerPluginsQuery.isLoading || providerPluginsQuery.isFetching)) || (composerTriggerKind === "slash-command" && (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching || @@ -7255,20 +7195,6 @@ export default function ChatView({ const { snapshot, trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; - if (menuIsActive && isLocalFolderBrowserOpen) { - if (key === "ArrowDown") { - localDirectoryMenuRef.current?.moveHighlight("down"); - return true; - } - if (key === "ArrowUp") { - localDirectoryMenuRef.current?.moveHighlight("up"); - return true; - } - if (key === "Enter" || key === "Tab") { - localDirectoryMenuRef.current?.activateHighlighted(); - return true; - } - } if (menuIsActive) { const currentItems = composerMenuItemsRef.current; @@ -7403,6 +7329,62 @@ export default function ChatView({ } onOpenTurnDiff(activeTurnLiveDiffState.turnId); }, [activeTurnLiveDiffState.turnId, onOpenTurnDiff]); + // Cross-Worker request channels this Thread is an end of. Derived from durable + // Task state rather than the message stream, so a channel still renders after a + // reload with no traffic in view. + const workerChannels = useMemo( + () => + deriveWorkerChannels({ + threadId: activeThreadId ?? null, + threadTaskId: activeThread?.taskId ?? null, + tasks: workerTasks, + threads: allThreads, + workers: workerProjects.map((project) => ({ id: project.id, title: project.name })), + messages: (activeThread?.messages ?? []).map((message) => ({ + id: message.id, + text: message.text ?? "", + createdAt: message.createdAt, + })), + }), + [ + activeThreadId, + activeThread?.taskId, + activeThread?.messages, + workerTasks, + allThreads, + workerProjects, + ], + ); + + const onOpenPeerThread = useCallback( + (channel: WorkerChannelView) => { + if (!channel.peerThreadId) return; + void navigate({ to: "/$threadId", params: { threadId: channel.peerThreadId } }); + }, + [navigate], + ); + + // Closing is a Task close: the channel is the Task, so there is no separate + // channel lifecycle to keep in sync. + const onCloseWorkerChannel = useCallback(async (channel: WorkerChannelView) => { + const api = readNativeApi(); + if (!api) return; + try { + await api.orchestration.dispatchCommand({ + type: "task.update", + commandId: newCommandId(), + taskId: channel.taskId, + status: "completed", + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not close the channel", + description: error instanceof Error ? error.message : "Unable to close the request.", + }); + } + }, []); + const onNavigateToThread = useCallback( (nextThreadId: ThreadId) => { void navigate({ @@ -7785,31 +7767,18 @@ export default function ChatView({ > {composerMenuOpen && !isComposerApprovalState ? (
- {isLocalFolderBrowserOpen ? ( - - handleSelectLocalDirectoryMention(absolutePath) - } - onNavigateFolder={handleNavigateLocalFolder} - handleRef={localDirectoryMenuRef} - /> - ) : ( - - )} +
) : null} {!isComposerApprovalState && @@ -7893,7 +7862,6 @@ export default function ChatView({ : "min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible", )} > - {composerPickerControls} {activeTaskList || sidebarProposedPlan || planSidebarOpen ? ( -
- - {composerSection} - - - ) : ( - composerSection - ); - return (
{/* Chat column */}
+ {/* The composer collapses into this. Absolutely positioned so a collapsed + composer costs the transcript no layout height at all, and kept + mounted either way so the fade/scale can play in both directions. */} + {shouldRenderChatPaneContent && !isCenteredEmptyLanding ? ( + + ) : null}
{shouldRenderChatPaneContent && isCenteredEmptyLanding ? (
-
+
0 ? composerFloatingHeight + 8 : 0}px`, + } as CSSProperties + } + > {transcriptContent ?? ( void onCloseWorkerChannel(channel)} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} onEditUserMessage={onEditUserMessage} @@ -8307,37 +8317,66 @@ export default function ChatView({ scrollButtonVisible={showScrollToBottom} onScrollToBottom={onScrollToBottom} bottomContentInsetPx={ - composerStackedChromeHeight > 0 - ? composerStackedChromeHeight + 8 - : undefined + composerFloatingHeight > 0 ? composerFloatingHeight + 8 : undefined } /> )}
- {composerSectionWithDisclosure} -
- {secondaryChromeReady ? ( -
-
- {isGitRepo ? ( - - ) : null} +
{composerSection}
+ {/* Part of the same floating unit as the composer, so it neither + reserves height nor drifts away from it. Unlike the composer it + has no surface of its own, so scrolling content read straight + through the branch labels — hence the opaque row here only. */} + {secondaryChromeReady ? ( +
+ {/* Narrower than the composer and centred on it, tucked up behind + its bottom edge (negative margin, lower z) so it reads as + sliding out from underneath rather than sitting as a second + bar. The top padding puts the content back where it was. */} +
+ {composerPickerControls} + {/* BranchToolbar is built for a full-width row (`w-full`, + `justify-between`). Here it is one item among several, so it + has to size to its content and be allowed to shrink — + otherwise it claims the row and overflows a narrow pane. */} + {isGitRepo ? ( + + ) : null} +
-
- ) : null} + ) : null} +
) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 55676703..96d14480 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -181,6 +181,8 @@ import { upsertProjectRunCommandScripts, } from "../projectRunTargets"; import { launchProjectRun } from "../projectRunLauncher"; +import { threadDragSourceProps } from "./threadDragSource"; +import { useSplitPaneStore } from "../splitPaneStore"; import { projectScriptRuntimeEnv } from "../projectScripts"; import { toastManager } from "./ui/toast"; import { @@ -1002,6 +1004,7 @@ export default function Sidebar() { const pinThreadLocally = usePinnedThreadsStore((store) => store.pinThread); const unpinThread = usePinnedThreadsStore((store) => store.unpinThread); const prunePinnedThreads = usePinnedThreadsStore((store) => store.prunePinnedThreads); + const pruneSplits = useSplitPaneStore((store) => store.pruneSplits); const homeDir = useWorkspaceStore((store) => store.homeDir); const chatWorkspaceRoot = useWorkspaceStore((store) => store.chatWorkspaceRoot); const navigate = useNavigate(); @@ -3673,6 +3676,16 @@ export default function Sidebar() { prunePinnedThreads(sidebarThreads.map((thread) => thread.id)); }, [prunePinnedThreads, sidebarThreads, threadsHydrated]); + // Persisted splits outlive the Threads in them; without this a deleted Thread + // comes back as an empty pane on the next load. Gated on the same hydration + // check, so an unhydrated (empty) Thread list cannot wipe every split. + useEffect(() => { + if (!shouldPrunePinnedThreads({ threadsHydrated })) { + return; + } + pruneSplits(sidebarThreads.map((thread) => thread.id)); + }, [pruneSplits, sidebarThreads, threadsHydrated]); + useEffect(() => { if (!shouldPrunePinnedThreads({ threadsHydrated })) { return; @@ -4350,6 +4363,7 @@ export default function Sidebar() { role="button" tabIndex={0} data-thread-item + {...threadDragSourceProps(thread.id)} className={cn( SIDEBAR_HEADER_ROW_CLASS_NAME, // Match the normal thread row: a flex row whose title claims all free @@ -4512,6 +4526,7 @@ export default function Sidebar() { "before:absolute before:top-4 before:-left-2 before:h-px before:w-2 before:bg-foreground/[0.05]", )} data-thread-item + {...threadDragSourceProps(thread.id)} > } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fb69e866..dbf44247 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -106,6 +106,8 @@ interface ChatHeaderProps { showGitActions?: boolean; diffDisabledReason?: string | null; surfaceMode?: "single" | "split"; + /** Shown only for a pane in a split, which is the only thing that can be closed. */ + closePaneAction?: { label: string; onClick: () => void } | undefined; chatLayoutAction?: { kind: "split" | "maximize"; label: string; @@ -491,6 +493,7 @@ export const ChatHeader = memo(function ChatHeader({ showGitActions = true, diffDisabledReason = null, surfaceMode = "single", + closePaneAction, chatLayoutAction = null, changeThreadAction = null, editorChatControls = null, @@ -767,6 +770,27 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} + {/* Closing a split pane lives in the header, not as a floating overlay: + the header is inside `.drag-region`, whose `no-drag` opt-out only + reaches its descendants. An overlay on top of it is not one, so the + OS swallowed the click before React ever saw it. */} + {closePaneAction ? ( + + + + + } + /> + {closePaneAction.label} + + ) : null} + {/* Change thread stays as a standalone control (split/sidechat only). */} {changeThreadAction ? ( diff --git a/apps/web/src/components/chat/ChatSplitSurface.tsx b/apps/web/src/components/chat/ChatSplitSurface.tsx new file mode 100644 index 00000000..ce196fa4 --- /dev/null +++ b/apps/web/src/components/chat/ChatSplitSurface.tsx @@ -0,0 +1,192 @@ +// FILE: ChatSplitSurface.tsx +// Purpose: Render the chat split — a pane tree, with drag-to-split drops. +// Layer: Chat presentation +// Exports: ChatSplitSurface + +import type { ThreadId } from "@t3tools/contracts"; +import { useCallback, useMemo, useState } from "react"; + +import ChatView from "~/components/ChatView"; +import { ProjectSurfaceFrame } from "~/components/ProjectSurfaceFrame"; +import { RouteInsetSurface } from "~/components/RouteInsetSurface"; +import { cn } from "~/lib/utils"; +import { THREAD_DRAG_MIME, useSplitPaneStore } from "~/splitPaneStore"; +import { + adoptTreeForThread, + resolveSplitDropZone, + treePaneIds, + type SplitDropZone, + type SplitNode, +} from "~/splitPanes"; +import { + CHAT_MAIN_CONTENT_SURFACE_CLASS_NAME, + CHAT_MAIN_VIEWPORT_SHELL_CLASS_NAME, + CHAT_SURFACE_TRANSPARENT_CLASS_NAME, +} from "./composerPickerStyles"; + +interface HoverState extends SplitDropZone { + readonly threadId: ThreadId; +} + +// Pointer position inside a pane, as ratios, which is what the drop rules take. +function paneRatios(event: React.DragEvent): { xRatio: number; yRatio: number } { + const bounds = event.currentTarget.getBoundingClientRect(); + return { + xRatio: bounds.width > 0 ? (event.clientX - bounds.left) / bounds.width : 0.5, + yRatio: bounds.height > 0 ? (event.clientY - bounds.top) / bounds.height : 0.5, + }; +} + +export function ChatSplitSurface({ routeThreadId }: { routeThreadId: ThreadId }) { + const treesByThreadId = useSplitPaneStore((store) => store.treesByThreadId); + const focusedThreadId = useSplitPaneStore((store) => store.focusedThreadId); + const dropThread = useSplitPaneStore((store) => store.dropThread); + const closePane = useSplitPaneStore((store) => store.closePane); + const focusPane = useSplitPaneStore((store) => store.focusPane); + const [hover, setHover] = useState(null); + + // Derived rather than held in state so the first paint after a reload already + // shows the persisted split, with no frame of single pane. + const tree = useMemo( + () => adoptTreeForThread({ trees: treesByThreadId, routeThreadId }), + [treesByThreadId, routeThreadId], + ); + const paneIds = useMemo(() => treePaneIds(tree), [tree]); + const split = paneIds.length > 1; + + const handleDragOver = useCallback((threadId: ThreadId, event: React.DragEvent) => { + if (!event.dataTransfer.types.includes(THREAD_DRAG_MIME)) return; + // Claiming the event is what makes the browser show a drop cursor at all. + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + setHover({ threadId, ...resolveSplitDropZone(paneRatios(event)) }); + }, []); + + const handleDrop = useCallback( + (targetThreadId: ThreadId, event: React.DragEvent) => { + const raw = event.dataTransfer.getData(THREAD_DRAG_MIME); + setHover(null); + if (!raw) return; + event.preventDefault(); + event.stopPropagation(); + const zone = resolveSplitDropZone(paneRatios(event)); + dropThread({ + routeThreadId, + targetThreadId, + threadId: raw as ThreadId, + edge: zone.edge, + orientation: zone.orientation, + }); + }, + [dropThread, routeThreadId], + ); + + const renderNode = useCallback( + (node: SplitNode): React.ReactNode => { + if (node.kind === "branch") { + return ( +
+ {node.children.map((child, index) => ( +
0 && + (node.orientation === "row" + ? "border-l border-border" + : "border-t border-border"), + )} + > + {renderNode(child)} +
+ ))} +
+ ); + } + + const threadId = node.threadId; + const focused = split ? threadId === focusedThreadId : true; + const hovered = hover?.threadId === threadId ? hover : null; + const paneIndex = paneIds.indexOf(threadId); + + return ( +
handleDragOver(threadId, event)} + onDragLeave={() => setHover(null)} + onDrop={(event) => handleDrop(threadId, event)} + onPointerDownCapture={() => { + if (split) focusPane(threadId); + }} + > + closePane(routeThreadId, threadId) : undefined} + /> + + {/* Drop affordance. Pointer-events off so it never eats the dragover + events that keep it alive. */} + {hovered ? ( +
+ ) : null} +
+ ); + }, + [ + closePane, + focusPane, + focusedThreadId, + handleDragOver, + handleDrop, + hover, + paneIds, + routeThreadId, + split, + ], + ); + + return ( + +
+ +
{renderNode(tree)}
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index 49542f20..d9fae21e 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -34,6 +34,7 @@ import { DISCLOSURE_CONTENT_MOTION_CLASS } from "~/lib/disclosureMotion"; import { type ExpandedImagePreview } from "./ExpandedImagePreview"; import { ChatEmptyStateHero } from "./ChatEmptyStateHero"; import { MessagesTimeline, type MessagesTimelineController } from "./MessagesTimeline"; +import type { WorkerChannelView } from "./workerChannel"; import { AgentActivityDetailView } from "./AgentActivityDetailView"; import type { AgentActivityDetail } from "./agentActivity.logic"; @@ -51,6 +52,9 @@ interface ChatTranscriptPaneProps { emptyStateProjectName: string | undefined; expandedWorkGroups?: Record; hasMessages: boolean; + workerChannels?: ReadonlyArray | undefined; + onOpenPeerThread?: ((channel: WorkerChannelView) => void) | undefined; + onCloseWorkerChannel?: ((channel: WorkerChannelView) => void) | undefined; isRevertingCheckpoint: boolean; isWorking: boolean; followLiveOutput: boolean; @@ -105,6 +109,9 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ emptyStateProjectName, expandedWorkGroups, hasMessages, + workerChannels, + onOpenPeerThread, + onCloseWorkerChannel, isRevertingCheckpoint, isWorking, followLiveOutput, @@ -170,6 +177,9 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ { - it("groups mention suggestions as Tasks, Workers, plugins, local, then subagents", () => { + it("groups mention suggestions as Tasks, Workers, plugins, then subagents", () => { const items: ComposerCommandItem[] = [ { id: "task:task-1", @@ -60,14 +60,6 @@ describe("groupCommandItems", () => { label: "@mini", description: "GPT-5.4 Mini", }, - { - id: "path:file:/workspace/AGENTS.md", - type: "path", - path: "/workspace/AGENTS.md", - pathKind: "file", - label: "AGENTS.md", - description: "/workspace", - }, { id: "plugin:github", type: "plugin", @@ -94,12 +86,6 @@ describe("groupCommandItems", () => { label: "GitHub", description: "Triage PRs and CI", }, - { - id: "local-root", - type: "local-root", - label: "@local", - description: "Browse folders on this computer", - }, ]; expect(groupCommandItems(items, "mention", true)).toEqual([ @@ -116,12 +102,7 @@ describe("groupCommandItems", () => { { id: "plugins", label: "Plugins", - items: [items[4]], - }, - { - id: "local", - label: "Local", - items: [items[3], items[5]], + items: [items[3]], }, { id: "subagents", diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 26740240..4cc443d3 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,5 +1,4 @@ import { - type ProjectEntry, type ModelSlug, type ProviderNativeCommandDescriptor, type ProviderMentionReference, @@ -44,7 +43,6 @@ import { CommandList, CommandSeparator, } from "../ui/command"; -import { FileEntryIcon } from "./FileEntryIcon"; import { COMPOSER_COMMAND_MENU_ITEM_ACTIVE_CLASS_NAME, COMPOSER_COMMAND_MENU_ITEM_CLASS_NAME, @@ -107,10 +105,6 @@ function commandMenuTrailingMeta(item: ComposerCommandItem): string | null { return "Worker"; } - if (item.type === "local-root") { - return "Local"; - } - if (item.type === "skill") { return formatSkillScope(item.skill.scope); } @@ -123,12 +117,6 @@ function commandMenuTrailingMeta(item: ComposerCommandItem): string | null { return `/${item.command}`; } - // Right-align the parent path so many same-named entries (e.g. worktrees) stay - // distinguishable without crowding the name column. - if (item.type === "path") { - return item.description.length > 0 ? item.description : null; - } - return null; } @@ -144,7 +132,6 @@ function commandMenuSecondaryText(item: ComposerCommandItem): string | null { if ( item.type === "plugin" || item.type === "skill" || - item.type === "local-root" || item.type === "task" || item.type === "worker" ) { @@ -155,20 +142,6 @@ function commandMenuSecondaryText(item: ComposerCommandItem): string | null { } export type ComposerCommandItem = - | { - id: string; - type: "path"; - path: string; - pathKind: ProjectEntry["kind"]; - label: string; - description: string; - } - | { - id: string; - type: "local-root"; - label: string; - description: string; - } | { id: string; type: "slash-command"; @@ -265,15 +238,12 @@ export function groupCommandItems( const taskItems = items.filter((item) => item.type === "task"); const workerItems = items.filter((item) => item.type === "worker"); const pluginItems = items.filter((item) => item.type === "plugin"); - const localItems = items.filter((item) => item.type === "local-root" || item.type === "path"); const agentItems = items.filter((item) => item.type === "agent"); const otherItems = items.filter( (item) => item.type !== "plugin" && item.type !== "task" && item.type !== "worker" && - item.type !== "local-root" && - item.type !== "path" && item.type !== "agent", ); @@ -287,9 +257,6 @@ export function groupCommandItems( if (pluginItems.length > 0) { groups.push({ id: "plugins", label: "Plugins", items: pluginItems }); } - if (localItems.length > 0) { - groups.push({ id: "local", label: "Local", items: localItems }); - } if (agentItems.length > 0) { groups.push({ id: "subagents", label: "Subagents", items: agentItems }); } @@ -392,23 +359,6 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
))} - {props.triggerKind === "mention" ? ( - <> - {groups.length > 0 ? : null} - {/* This footer is informational copy, not a selectable result group. */} -
-

- Files -

-

Type to search for files

-
- - ) : null} {props.items.length === 0 && (

@@ -420,7 +370,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { : "Loading commands..." : (props.emptyStateText ?? (props.triggerKind === "mention" - ? "No matching plugin or file." + ? "No matching Worker, Task, or plugin." : props.triggerKind === "skill" ? "No matching skill." : "No matching command."))} @@ -437,12 +387,6 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { const COMPOSER_COMMAND_ITEM_ICON_SLOT_CLASSNAME = "flex size-4 shrink-0 items-center justify-center text-faint"; -// Files mirror the recap / diff changed-files treatment (FileEntryIcon at -// size-3.5 with the same dimmed foreground) so a file reads identically whether -// it appears in a turn summary or in the composer. -const COMPOSER_COMMAND_ITEM_FILE_ICON_CLASSNAME = - "size-3.5 text-[var(--color-text-foreground)] opacity-70 dark:opacity-80"; - const COMPOSER_COMMAND_ITEM_GLYPH_CLASSNAME = "size-3.5"; // Reuse the app's existing icon components for each concept so the command menu @@ -472,18 +416,6 @@ function commandMenuSlashGlyph(command: string, fallback: LucideIcon): ReactNode function commandMenuItemGlyph(item: ComposerCommandItem): ReactNode { const cls = COMPOSER_COMMAND_ITEM_GLYPH_CLASSNAME; switch (item.type) { - case "path": - return ( - - ); - case "local-root": - return ; case "fork-target": return item.target === "local" ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d0526e0e..66d02937 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -4,6 +4,8 @@ // Exports: row derivation, structural sharing, copy/timer helpers import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { workerChannelTaskIdOf } from "@t3tools/shared/workerChannelMessages"; +import type { WorkerChannelView } from "./workerChannel"; import { type TimelineEntry, type WorkLogEntry, formatElapsed } from "../../session-logic"; import { normalizeCompactToolLabel as normalizeCompactToolLabelValue } from "../../lib/toolCallLabel"; import { @@ -76,6 +78,15 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + // The transcript artifact for a cross-Worker request channel. Replaces the + // raw protocol message the channel injects, which is model-facing text and + // reads as noise. + kind: "worker-channel"; + id: string; + createdAt: string; + channel: WorkerChannelView; + } | { // Live-turn header that mirrors the settled "Worked for Xs" disclosure // (label + full-width divider), but is non-collapsible and counts up while @@ -239,6 +250,8 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt: string | null; turnDiffSummaryByAssistantMessageId: ReadonlyMap; revertTurnCountByUserMessageId: ReadonlyMap; + /** Channels this Thread is an end of, keyed for lookup by injected message id. */ + workerChannels?: ReadonlyArray | undefined; }): MessagesTimelineRow[] { const nextRows: MessagesTimelineRow[] = []; const timelineMessages = input.timelineEntries.flatMap((entry) => @@ -291,12 +304,36 @@ export function deriveMessagesTimelineRows(input: { pendingWorkGroup = null; }; + // A channel renders once, at the point its first message lands, so the card + // keeps the chronological position of the exchange it replaces. + const channelByTaskId = new Map((input.workerChannels ?? []).map((c) => [c.taskId as string, c])); + const renderedChannelTaskIds = new Set(); + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } + if (timelineEntry.kind === "message") { + const channelTaskId = workerChannelTaskIdOf(timelineEntry.message.id); + if (channelTaskId) { + // Channel traffic never renders as a typed message. The card owns it. + const channel = channelByTaskId.get(channelTaskId); + if (channel && !renderedChannelTaskIds.has(channelTaskId)) { + renderedChannelTaskIds.add(channelTaskId); + flushPendingWorkGroup(); + nextRows.push({ + kind: "worker-channel", + id: `worker-channel-${channelTaskId}`, + createdAt: timelineEntry.message.createdAt, + channel, + }); + } + continue; + } + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -384,6 +421,20 @@ export function deriveMessagesTimelineRows(input: { }); } + // The requester Thread receives no injected message when it sends a request — + // only when a reply comes back — so a freshly sent channel would otherwise be + // invisible until the peer answers. + for (const channel of input.workerChannels ?? []) { + if (renderedChannelTaskIds.has(channel.taskId)) continue; + renderedChannelTaskIds.add(channel.taskId); + nextRows.push({ + kind: "worker-channel", + id: `worker-channel-${channel.taskId}`, + createdAt: channel.createdAt, + channel, + }); + } + collapseSettledTurns(nextRows, { terminalAssistantMessageIds, activeTurnInProgress: input.activeTurnInProgress ?? false, @@ -784,6 +835,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean if (a.kind !== b.kind || a.id !== b.id) return false; switch (a.kind) { + case "worker-channel": + return a.channel === (b as typeof a).channel; + case "working-header": return a.createdAt === (b as typeof a).createdAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 239b6dee..1ca5e64d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -114,6 +114,8 @@ import { isAgentActivityWorkEntry } from "./agentActivity.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState } from "~/lib/composerMessageContext"; import { cn } from "~/lib/utils"; +import { WorkerChannelCard } from "./WorkerChannelCard"; +import type { WorkerChannelView } from "./workerChannel"; import { DEFAULT_CHAT_FONT_SIZE_PX, normalizeChatFontSizePx, @@ -399,6 +401,10 @@ function WorktreeSetupCard({ steps }: { steps: ReadonlyArray interface MessagesTimelineProps { assistantProvider?: ProviderKind; + /** Cross-Worker request channels this Thread is an end of. */ + workerChannels?: ReadonlyArray | undefined; + onOpenPeerThread?: ((channel: WorkerChannelView) => void) | undefined; + onCloseWorkerChannel?: ((channel: WorkerChannelView) => void) | undefined; hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; @@ -503,6 +509,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ chatFontSizePx = DEFAULT_CHAT_FONT_SIZE_PX, timestampFormat, workspaceRoot, + workerChannels, + onOpenPeerThread, + onCloseWorkerChannel, emptyStateContent, bottomContentInsetPx, contentInsetRightPx, @@ -617,8 +626,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeTurnStartedAt, turnDiffSummaryByAssistantMessageId, revertTurnCountByUserMessageId, + workerChannels, }), [ + workerChannels, timelineEntries, isWorking, presentedWorktreeSetup, @@ -1719,6 +1730,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({

)} + {row.kind === "worker-channel" && ( + + )} + {row.kind === "working-header" && ( void) | undefined; + onCloseChannel?: ((channel: WorkerChannelView) => void) | undefined; +} + +// Status drives one pill, not a colour scheme: the blueprint has three inks, so +// the only status that earns a colour is the terminal one. +function statusLabel(channel: WorkerChannelView): string { + switch (channel.status) { + case "waiting": + return channel.side === "requester" ? "Waiting" : "Open"; + case "answered": + return channel.side === "requester" ? "Replied" : "You replied"; + case "closed": + return "Closed"; + case "cancelled": + return "Cancelled"; + } +} + +export function WorkerChannelCard({ + channel, + chatMetaFontSizePx, + onOpenPeerThread, + onCloseChannel, +}: WorkerChannelCardProps) { + // Collapsed once answered: a settled channel is a record, and an open one is + // the thing the reader is waiting on. + const [expanded, setExpanded] = useState(channel.status === "waiting"); + const open = isWorkerChannelOpen(channel.status); + const waiting = channel.status === "waiting"; + const HeadIcon = channel.side === "requester" ? ChatBubbleIcon : InboxIcon; + const metaStyle = { fontSize: `${chatMetaFontSizePx}px` }; + + return ( +
+ + + +
+ + {channel.messages + .filter((message) => message.kind === "reply") + .map((message) => ( + + ))} + {waiting && channel.side === "requester" ? ( +
+ + {channel.peerWorkerTitle} is working on it +
+ ) : null} +
+
+ +
+ + {open + ? channel.side === "responder" + ? "Answer from this repository only" + : "Channel stays open until one side closes it" + : "Channel closed"} + + {channel.peerThreadId && onOpenPeerThread ? ( + + ) : null} + {open && onCloseChannel ? ( + + ) : null} +
+
+ ); +} + +function ChannelMessage(props: { who: string; text: string; metaStyle: { fontSize: string } }) { + return ( +
+
+ {props.who} +
+
+ {props.text} +
+
+ ); +} diff --git a/apps/web/src/components/chat/workerChannel.test.ts b/apps/web/src/components/chat/workerChannel.test.ts new file mode 100644 index 00000000..3622ec60 --- /dev/null +++ b/apps/web/src/components/chat/workerChannel.test.ts @@ -0,0 +1,173 @@ +import { ProjectId, TaskId, ThreadId, type OrchestrationTaskShell } from "@t3tools/contracts"; +import { + workerChannelReplyMessageId, + workerChannelRequestMessageId, +} from "@t3tools/shared/workerChannelMessages"; +import { describe, expect, it } from "vitest"; + +import { deriveWorkerChannels, isWorkerChannelOpen, workerChannelStatus } from "./workerChannel"; + +const requesterWorker = ProjectId.makeUnsafe("worker-a"); +const recipientWorker = ProjectId.makeUnsafe("worker-b"); +const requesterThread = ThreadId.makeUnsafe("thread-a"); +const responderThread = ThreadId.makeUnsafe("thread-b"); +const taskId = TaskId.makeUnsafe("task-1"); + +const workers = [ + { id: requesterWorker, title: "phibrowser-mac" }, + { id: recipientWorker, title: "BonsAI" }, +]; + +function task(overrides: Partial = {}): OrchestrationTaskShell { + return { + id: taskId, + workerId: recipientWorker, + requesterWorkerId: requesterWorker, + requesterTaskId: null, + requesterThreadId: requesterThread, + title: "Project system design", + brief: "Could you share the design of your project system?", + status: "in_progress", + origin: "delegation", + artifacts: [], + completionSummary: null, + createdAt: "2026-07-22T20:47:00.000Z", + updatedAt: "2026-07-22T20:47:00.000Z", + completedAt: null, + ...overrides, + }; +} + +const threads = [ + { id: requesterThread, taskId: null }, + { id: responderThread, taskId }, +]; + +describe("deriveWorkerChannels", () => { + it("shows the requester its channel with the peer thread bound", () => { + const [channel] = deriveWorkerChannels({ + threadId: requesterThread, + threadTaskId: null, + tasks: [task()], + threads, + workers, + messages: [], + }); + + expect(channel).toMatchObject({ + side: "requester", + status: "waiting", + subject: "Project system design", + peerWorkerTitle: "BonsAI", + peerThreadId: responderThread, + }); + // The card shows the body as sent, never the protocol text wrapped around it. + expect(channel?.ask).toBe("Could you share the design of your project system?"); + }); + + it("shows the responder the same channel from the other end", () => { + const [channel] = deriveWorkerChannels({ + threadId: responderThread, + threadTaskId: taskId, + tasks: [task()], + threads, + workers, + messages: [], + }); + + expect(channel).toMatchObject({ + side: "responder", + peerWorkerTitle: "phibrowser-mac", + peerThreadId: requesterThread, + }); + }); + + it("ignores Threads that are not an end of the channel", () => { + expect( + deriveWorkerChannels({ + threadId: ThreadId.makeUnsafe("thread-outsider"), + threadTaskId: null, + tasks: [task()], + threads, + workers, + messages: [], + }), + ).toEqual([]); + }); + + it("ignores ordinary Tasks", () => { + expect( + deriveWorkerChannels({ + threadId: responderThread, + threadTaskId: taskId, + tasks: [task({ origin: "user" })], + threads, + workers, + messages: [], + }), + ).toEqual([]); + }); + + // Channel traffic is folded into the card, so the card must be able to find it + // by the minted id rather than by scanning message text. + it("collects injected channel traffic and flips to answered on a reply", () => { + const [channel] = deriveWorkerChannels({ + threadId: requesterThread, + threadTaskId: null, + tasks: [task()], + threads, + workers, + messages: [ + { id: "msg-typed", text: "unrelated", createdAt: "2026-07-22T20:46:00.000Z" }, + { + id: workerChannelRequestMessageId(taskId), + text: "the request prompt", + createdAt: "2026-07-22T20:47:00.000Z", + }, + { + id: workerChannelReplyMessageId(taskId, "r1"), + text: "Boards persist as one document per board.", + createdAt: "2026-07-22T20:49:00.000Z", + }, + ], + }); + + expect(channel?.status).toBe("answered"); + expect(channel?.messages.map((message) => message.kind)).toEqual(["request", "reply"]); + expect(channel?.messages.at(-1)?.text).toBe("Boards persist as one document per board."); + }); + + it("reports a channel with no responder Thread yet", () => { + const [channel] = deriveWorkerChannels({ + threadId: requesterThread, + threadTaskId: null, + tasks: [task()], + threads: [{ id: requesterThread, taskId: null }], + workers, + messages: [], + }); + + expect(channel?.peerThreadId).toBeNull(); + expect(channel?.status).toBe("waiting"); + }); +}); + +describe("workerChannelStatus", () => { + it("separates a closed channel from one that merely replied", () => { + expect(workerChannelStatus({ task: task(), hasReply: false })).toBe("waiting"); + expect(workerChannelStatus({ task: task(), hasReply: true })).toBe("answered"); + expect(workerChannelStatus({ task: task({ status: "completed" }), hasReply: true })).toBe( + "closed", + ); + expect(workerChannelStatus({ task: task({ status: "cancelled" }), hasReply: false })).toBe( + "cancelled", + ); + }); + + it("treats only settled channels as no longer replyable", () => { + expect(isWorkerChannelOpen("waiting")).toBe(true); + expect(isWorkerChannelOpen("answered")).toBe(true); + expect(isWorkerChannelOpen("closed")).toBe(false); + expect(isWorkerChannelOpen("cancelled")).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/workerChannel.ts b/apps/web/src/components/chat/workerChannel.ts new file mode 100644 index 00000000..90da3802 --- /dev/null +++ b/apps/web/src/components/chat/workerChannel.ts @@ -0,0 +1,153 @@ +// FILE: workerChannel.ts +// Purpose: Derive the channel-card view of a cross-Worker request from store state. +// Layer: web chat feature (pure logic, no I/O) +// Exports: deriveWorkerChannels, workerChannelStatus, type WorkerChannelView + +import type { OrchestrationTaskShell, ProjectId, TaskId, ThreadId } from "@t3tools/contracts"; +import { + workerChannelMessageKind, + workerChannelTaskIdOf, +} from "@t3tools/shared/workerChannelMessages"; + +/** + * A delegation Task seen from one Thread. The Task is the channel; this is the + * end of it the current Thread is standing on, plus the traffic that has flowed + * through it, ready to render as a card. + */ +export interface WorkerChannelView { + readonly taskId: TaskId; + /** Which end of the channel the current Thread is. */ + readonly side: "requester" | "responder"; + readonly status: WorkerChannelStatus; + readonly subject: string; + /** The request body as sent — never the protocol text wrapped around it. */ + readonly ask: string; + readonly peerWorkerId: ProjectId | null; + readonly peerWorkerTitle: string; + /** The Thread at the other end, once it exists. */ + readonly peerThreadId: ThreadId | null; + readonly messages: ReadonlyArray; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface WorkerChannelMessage { + readonly id: string; + readonly kind: "request" | "reply"; + /** Which Worker sent it, as a display title. */ + readonly fromWorkerTitle: string; + readonly text: string; + readonly createdAt: string; +} + +export type WorkerChannelStatus = "waiting" | "answered" | "closed" | "cancelled"; + +type RawChannelMessage = Omit; + +const CLOSED_STATUSES = new Set(["completed", "cancelled"]); + +/** + * Card status, which is not the Task status: a Task is `in_progress` from the + * moment the responder session starts until it closes, but the reader wants to + * know whether an answer has actually come back. + */ +export function workerChannelStatus(input: { + readonly task: OrchestrationTaskShell; + readonly hasReply: boolean; +}): WorkerChannelStatus { + if (input.task.status === "cancelled") return "cancelled"; + if (input.task.status === "completed") return "closed"; + return input.hasReply ? "answered" : "waiting"; +} + +interface DeriveInput { + readonly threadId: ThreadId | null; + readonly threadTaskId: TaskId | null; + readonly tasks: ReadonlyArray; + readonly threads: ReadonlyArray<{ readonly id: ThreadId; readonly taskId?: TaskId | null }>; + readonly workers: ReadonlyArray<{ readonly id: ProjectId; readonly title: string }>; + /** Every message in the current Thread, in transcript order. */ + readonly messages: ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly createdAt: string; + }>; +} + +/** + * Every channel this Thread is an end of. + * + * A Thread is the requester when it sent the request, and the responder when it + * is the Task's canonical Thread. Both are read from durable state rather than + * from the messages, so a channel still renders after a reload with no traffic + * in view. + */ +export function deriveWorkerChannels(input: DeriveInput): ReadonlyArray { + if (!input.threadId) return []; + + const workerTitle = (id: ProjectId | null): string => + (id ? input.workers.find((worker) => worker.id === id)?.title : undefined) ?? + "the other Worker"; + + // Channel traffic is grouped by Task once, so a Thread holding several + // channels does not rescan its whole message list per channel. The sender is + // not stored here: it is always the peer, because a Thread never receives its + // own outgoing message, and the peer is only known inside the Task loop below. + const trafficByTask = new Map(); + for (const message of input.messages) { + const taskId = workerChannelTaskIdOf(message.id); + const kind = workerChannelMessageKind(message.id); + if (!taskId || !kind) continue; + const bucket = trafficByTask.get(taskId) ?? []; + bucket.push({ id: message.id, kind, text: message.text, createdAt: message.createdAt }); + trafficByTask.set(taskId, bucket); + } + + const channels: WorkerChannelView[] = []; + + for (const task of input.tasks) { + if (task.origin !== "delegation") continue; + + const isRequester = task.requesterThreadId === input.threadId; + const isResponder = input.threadTaskId !== null && input.threadTaskId === task.id; + if (!isRequester && !isResponder) continue; + + const side = isRequester ? "requester" : "responder"; + const peerWorkerId = side === "requester" ? task.workerId : task.requesterWorkerId; + const responderThreadId = input.threads.find((thread) => thread.taskId === task.id)?.id ?? null; + const peerThreadId = side === "requester" ? responderThreadId : task.requesterThreadId; + + const peerTitle = workerTitle(peerWorkerId); + const traffic: WorkerChannelMessage[] = (trafficByTask.get(task.id) ?? []).map((message) => ({ + id: message.id, + kind: message.kind, + fromWorkerTitle: peerTitle, + text: message.text, + createdAt: message.createdAt, + })); + const hasReply = traffic.some((message) => message.kind === "reply"); + + channels.push({ + taskId: task.id, + side, + status: workerChannelStatus({ task, hasReply }), + subject: task.title, + ask: task.brief, + peerWorkerId, + peerWorkerTitle: peerTitle, + peerThreadId, + messages: traffic, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + }); + } + + return channels; +} + +/** Whether a channel can still take a reply. */ +export function isWorkerChannelOpen(status: WorkerChannelStatus): boolean { + return status === "waiting" || status === "answered"; +} + +export { CLOSED_STATUSES as WORKER_CHANNEL_CLOSED_TASK_STATUSES }; diff --git a/apps/web/src/components/research/ResearchDocumentView.tsx b/apps/web/src/components/research/ResearchDocumentView.tsx index 34ef4e45..f0db665e 100644 --- a/apps/web/src/components/research/ResearchDocumentView.tsx +++ b/apps/web/src/components/research/ResearchDocumentView.tsx @@ -141,11 +141,19 @@ export function ResearchDocumentView({ return (
-
+ {/* The composer overlays the bottom of this surface, so the document has to + scroll clear of it. `--chat-composer-inset` is the composer's measured + height, published by ChatView; without it the last lines of the document + sit permanently underneath and read as cut off. */} +
-
+ {/* A real flex-basis, not just `flex-1`: with `flex: 1 1 0%` the + browser measures this column as zero when deciding whether the + row fits, so it never wrapped the actions to their own line and + instead squeezed the title down to one word per line. */} +
{document.archivedAt ? "Archived Markdown" : "Markdown research"} diff --git a/apps/web/src/components/threadDragSource.ts b/apps/web/src/components/threadDragSource.ts new file mode 100644 index 00000000..2d224227 --- /dev/null +++ b/apps/web/src/components/threadDragSource.ts @@ -0,0 +1,32 @@ +// FILE: threadDragSource.ts +// Purpose: Make a sidebar Thread row draggable into the chat split, from one place. +// Layer: Web UI helper +// Exports: threadDragSourceProps + +import type { DragEvent } from "react"; +import type { ThreadId } from "@t3tools/contracts"; + +import { THREAD_DRAG_MIME } from "~/splitPaneStore"; + +/** + * Drag props for a sidebar Thread row. + * + * Native HTML5 drag rather than the dnd-kit context the sidebar already uses for + * reordering: this drag ends outside that context, on the chat surface, and + * nesting a second sensor context inside the sortable one makes both fight for + * the same pointer stream. + */ +export function threadDragSourceProps(threadId: ThreadId) { + return { + draggable: true, + onDragStart: (event: DragEvent) => { + event.dataTransfer.setData(THREAD_DRAG_MIME, threadId); + // Some targets only ever see `text/plain`; carrying the id there too keeps + // a drop onto an external editor or terminal meaningful rather than empty. + event.dataTransfer.setData("text/plain", threadId); + event.dataTransfer.effectAllowed = "move"; + // Stops the row's own drag from also being read as a sortable reorder. + event.stopPropagation(); + }, + } as const; +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 1c1da20c..8d2a706b 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -2,6 +2,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; import * as React from "react"; +import { disclosurePeekClassName } from "~/lib/disclosureMotion"; +import { useSidebarEdgePeek } from "~/hooks/useSidebarEdgePeek"; +import { SIDEBAR_PEEK } from "~/lib/sidebarPeek"; import { cn } from "~/lib/utils"; import { CentralIcon } from "~/lib/central-icons"; import { Button } from "~/components/ui/button"; @@ -256,6 +259,9 @@ function Sidebar({ transparentSurface?: boolean; }) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + const peek = useSidebarEdgePeek({ + enabled: !isMobile && collapsible === "offcanvas" && state === "collapsed", + }); const resolvedResizable = React.useMemo( () => resolveSidebarResizable(resizable, { collapsible, isMobile }), [collapsible, isMobile, resizable], @@ -319,11 +325,26 @@ function Sidebar({
+ {/* Invisible edge strip that opens the peek. Rendered only once armed, so + the pointer that collapsed the sidebar cannot immediately reopen it. */} + {peek.armed ? ( +