diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 302c90bc..2a36c57d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.10.0", + "version": "0.11.0", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index 223a0a4b..c1fa2815 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "repository": { "type": "git", 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/package.json b/apps/web/package.json index d6f9c5c8..9dba0f1f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.10.0", + "version": "0.11.0", "private": true, "type": "module", "scripts": { 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 ? ( +