diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0825c5cb92ccb..1c8eddf4198cb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,6 +47,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + LayersIcon, LinkIcon, MessageSquareIcon, MonitorIcon, @@ -76,7 +77,7 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { useCustomThemes } from "../hooks/useCustomThemes"; import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; @@ -705,6 +706,7 @@ function OpenCommandPaletteDialog(props: { const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -2010,6 +2012,20 @@ function OpenCommandPaletteDialog(props: { }, }); + if (!clientSettings.legacySidebarEnabled) { + actionItems.push({ + kind: "action", + value: "action:sidebar-tabs", + searchTerms: ["tabs", "sidebar", "chat tabs", "group", "show", "hide"], + title: clientSettings.sidebarShowTabs ? "Hide tabs in sidebar" : "Show tabs in sidebar", + icon: , + shortcutCommand: "sidebar.toggleTabs", + run: async () => { + await updateClientSettings({ sidebarShowTabs: !clientSettings.sidebarShowTabs }); + }, + }); + } + if ( environments.some( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fede3c1834485..c0c9d38047d47 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -13,6 +13,7 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, + groupSidebarTabThreads, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, @@ -45,6 +46,7 @@ import { shouldCreateNewThreadInCurrentProject, shouldNavigateAfterThreadPark, THREAD_JUMP_HINT_SHOW_DELAY_MS, + withSidebarTabThreads, type SidebarListItem, type SidebarListMarker, type SidebarSection, @@ -598,6 +600,38 @@ describe("shouldCreateNewThreadInCurrentProject", () => { }); }); +describe("sidebar tab rows", () => { + const threadByKey = new Map( + ["local:a", "local:a2", "local:a3", "local:b", "local:c", "local:c2"].map((key) => [key, key]), + ); + const hiddenTabThreads = new Map([ + ["local:a3", "local:a"], + ["local:c2", "local:c"], + ["local:a2", "local:a"], + ["local:gone", "local:c"], + ]); + + it("lists each group's tabs under its row in tab order, skipping unknown threads", () => { + const tabsByRowKey = groupSidebarTabThreads(threadByKey, hiddenTabThreads); + expect([...tabsByRowKey]).toEqual([ + ["local:a", ["local:a3", "local:a2"]], + ["local:c", ["local:c2"]], + ]); + expect( + withSidebarTabThreads(["local:b", "local:a", "local:c"], (key) => key, tabsByRowKey), + ).toEqual(["local:b", "local:a", "local:a3", "local:a2", "local:c", "local:c2"]); + }); + + it("leaves tabs out when their group's row is not listed", () => { + const tabsByRowKey = groupSidebarTabThreads(threadByKey, hiddenTabThreads); + expect(withSidebarTabThreads(["local:b", "local:c"], (key) => key, tabsByRowKey)).toEqual([ + "local:b", + "local:c", + "local:c2", + ]); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2796b2f7f885d..6d82cf8c6f45e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -738,6 +738,34 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } +/** + * The tabs listed under each tab group's sidebar row, in tab order. `hiddenTabThreads` maps a + * tab's thread key to the key of the row that stands for its group. + */ +export function groupSidebarTabThreads( + threadByKey: ReadonlyMap, + hiddenTabThreads: ReadonlyMap, +): ReadonlyMap { + const tabsByRowKey = new Map(); + for (const [threadKey, rowKey] of hiddenTabThreads) { + const thread = threadByKey.get(threadKey); + if (thread === undefined) continue; + const tabs = tabsByRowKey.get(rowKey); + if (tabs) tabs.push(thread); + else tabsByRowKey.set(rowKey, [thread]); + } + return tabsByRowKey; +} + +/** Rows in the order the sidebar shows them: each group's row followed by its tabs. */ +export function withSidebarTabThreads( + rows: readonly T[], + getKey: (row: T) => string, + tabsByRowKey: ReadonlyMap, +): T[] { + return rows.flatMap((row) => [row, ...(tabsByRowKey.get(getKey(row)) ?? [])]); +} + export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3c941098ee00a..ebaed7bcf84fd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,6 +58,7 @@ import { EyeIcon, FolderIcon, GitBranchIcon, + LayersIcon, MessageCircleQuestionIcon, PinIcon, PinOffIcon, @@ -128,7 +129,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -166,6 +167,7 @@ import { filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, + groupSidebarTabThreads, hasUnseenCompletion, isSidebarNestedLinkClick, isTrailingDoubleClick, @@ -191,6 +193,7 @@ import { useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, + withSidebarTabThreads, type SidebarListItem, type SidebarListMarker, type SidebarSection, @@ -309,10 +312,80 @@ function WorkingDuration(props: { startedAt: string | null }) { return {formatWorkingDurationLabel(Date.now() - startedMs)}; } +type SidebarTopStatus = { + label: string; + icon: "working" | "monitoring" | "approval" | "input" | "failed" | "woke" | "done"; + className: string; +}; + +// Status hues follow the system-wide convention set by sidebar v1 and the +// mobile Live Activity/widgets (amber approval, indigo input, sky working) +// so a thread reads the same color everywhere it surfaces. +function resolveSidebarTopStatus( + status: ReturnType, + isWoke: boolean, + isUnread: boolean, +): SidebarTopStatus | null { + switch (status) { + case "working": + // No shimmer: a label that animates forever is noise in a sidebar + // full of them (and repaints every vsync on high-refresh displays). + return { label: "Working", icon: "working", className: "text-sky-600 dark:text-sky-400" }; + case "monitoring": + // Monitoring is calm background presence, not active progress + // (monitoring-pill D6), so it keeps the label at full strength. + return { + label: "Monitoring", + icon: "monitoring", + className: "text-foreground dark:text-white", + }; + case "approval": + return { label: "Approval", icon: "approval", className: "text-warning-foreground" }; + case "input": + return { + label: "Input", + icon: "input", + className: "text-indigo-600 dark:text-indigo-300", + }; + case "failed": + return { label: "Failed", icon: "failed", className: "text-red-700 dark:text-red-300" }; + } + if (isWoke) return { label: "Woke", icon: "woke", className: "text-warning-foreground" }; + if (isUnread) { + return { label: "Done", icon: "done", className: "text-emerald-700 dark:text-emerald-300" }; + } + return null; +} + +function SidebarTopStatusIcon(props: { icon: SidebarTopStatus["icon"]; className: string }) { + switch (props.icon) { + case "working": + return ; + case "input": + return ; + case "approval": + return ; + case "failed": + return ; + case "monitoring": + return ; + case "done": + return ; + case "woke": + return ; + } +} + const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); // Collapsed shelves share one empty list so a route change alone does not // give the sidebar list a new identity. const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; +const EMPTY_TAB_THREADS: ReadonlyMap = new Map(); +const EMPTY_TABS_BY_ROW: ReadonlyMap = new Map(); + +function sidebarThreadKey(thread: EnvironmentThreadShell): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} function terminalProcessLabel(count: number): string { return `${count} terminal ${count === 1 ? "process" : "processes"} running`; @@ -1030,6 +1103,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { * composer. Absent when the sidebar cannot open server threads. */ onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; + /** Tabs in this row's group while they are hidden; the badge shows only above one. */ + tabCount: number; + /** The group's other tabs, listed under the row while tabs are shown. */ + tabs: ReactNode; }) { const { isRenaming, @@ -1136,57 +1213,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isActive: props.isActive, isSelected, }); - // Status hues follow the system-wide convention set by sidebar v1 and the - // mobile Live Activity/widgets (amber approval, indigo input, sky working) - // so a thread reads the same color everywhere it surfaces. - const topStatus = - status === "working" - ? { - label: "Working", - icon: "working" as const, - // No shimmer: a label that animates forever is noise in a sidebar - // full of them (and repaints every vsync on high-refresh displays). - className: "text-sky-600 dark:text-sky-400", - } - : status === "monitoring" - ? { - // Monitoring is calm background presence, not active progress - // (monitoring-pill D6), so it keeps the label at full strength. - label: "Monitoring", - icon: "monitoring" as const, - className: "text-foreground dark:text-white", - } - : status === "approval" - ? { - label: "Approval", - icon: "approval" as const, - className: "text-warning-foreground", - } - : status === "input" - ? { - label: "Input", - icon: "input" as const, - className: "text-indigo-600 dark:text-indigo-300", - } - : status === "failed" - ? { - label: "Failed", - icon: "failed" as const, - className: "text-red-700 dark:text-red-300", - } - : isWoke - ? { - label: "Woke", - icon: "woke" as const, - className: "text-warning-foreground", - } - : isUnread - ? { - label: "Done", - icon: "done" as const, - className: "text-emerald-700 dark:text-emerald-300", - } - : null; + const topStatus = resolveSidebarTopStatus(status, isWoke, isUnread); const isWokeStatus = topStatus?.icon === "woke"; const branchMismatch = resolveLocalCheckoutBranchMismatch({ @@ -1297,31 +1324,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { window.addEventListener("dragend", clearFileDrag); return () => window.removeEventListener("dragend", clearFileDrag); }, [isFileDragOver]); - const renameCommittedRef = useRef(false); - useEffect(() => { - if (isRenaming) renameCommittedRef.current = false; - }, [isRenaming]); - const handleRenameKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") { - event.preventDefault(); - renameCommittedRef.current = true; - onCommitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renameCommittedRef.current = true; - onCancelRename(); - } - }, - [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], - ); - const handleRenameBlur = useCallback(() => { - if (!renameCommittedRef.current) { - onCommitRename(threadRef, renamingTitle, thread.title); - } - }, [onCommitRename, renamingTitle, thread.title, threadRef]); const handleSettleClick = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -1469,17 +1471,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const title = isRenaming ? ( - onRenameTitleChange(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - onBlur={handleRenameBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + onChange={onRenameTitleChange} + onCommit={onCommitRename} + onCancel={onCancelRename} /> ) : ( Unsent draft ) : null; + const tabCountBadge = props.tabCount > 1 ? : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); const pinIndicator = showPin ? ( @@ -1602,7 +1601,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
  • {draftIndicator} {title} + {tabCountBadge} {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1748,6 +1748,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {detailsTooltip} + {props.tabs}
  • ); } @@ -1758,7 +1759,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
  • - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "input" ? ( - - ) : topStatus.icon === "approval" ? ( - - ) : topStatus.icon === "failed" ? ( - - ) : topStatus.icon === "monitoring" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} + {/* The label alone is the live region: a role="status" wrapper around the ticking duration would make screen readers announce every second. */} @@ -1962,6 +1951,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} + {tabCountBadge} {terminalStatusIcon} {prBadge} {diff ? ( @@ -2007,6 +1997,275 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {detailsTooltip} + {props.tabs} +
  • + ); +}); + +/** Inline title editor, mounted only while a row is being renamed. */ +function SidebarRenameInput(props: { + threadRef: ScopedThreadRef; + originalTitle: string; + value: string; + onChange: (title: string) => void; + onCommit: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancel: () => void; +}) { + // Enter and Escape unmount the field, which blurs it; the blur must not commit a second time. + const finishedRef = useRef(false); + return ( + props.onChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + finishedRef.current = true; + props.onCommit(props.threadRef, props.value, props.originalTitle); + } else if (event.key === "Escape") { + event.preventDefault(); + finishedRef.current = true; + props.onCancel(); + } + }} + onBlur={() => { + if (!finishedRef.current) props.onCommit(props.threadRef, props.value, props.originalTitle); + }} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-input bg-card px-1 text-sm font-medium text-card-foreground outline-none focus:border-foreground" + /> + ); +} + +function SidebarTabCountBadge(props: { count: number }) { + return ( + + + {props.count} + tabs + + ); +} + +/** A group's tabs under its row, hung off a line that starts below the project icon. */ +function SidebarTabList(props: { children: ReactNode }) { + return ( +
      + {props.children} +
    + ); +} + +/** + * One chat tab listed under its group's row: title, live status, time since your last + * message, and the provider it runs on. Lifecycle actions stay on the group's row. + */ +const SidebarTabRow = memo(function SidebarTabRow(props: { + thread: SidebarThreadSummary; + isActive: boolean; + jumpLabel: string | null; + environmentLabel: string | null; + environmentMachine: EnvironmentMachineKind; + project: EnvironmentProject | null; + projectDisplayName: string | null; + providerEntryByInstanceId: ReadonlyMap; + isRenaming: boolean; + renamingTitle: string; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; +}) { + const { thread, onFileDropThreads } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarThreadStatus(thread); + const topStatus = resolveSidebarTopStatus(status, false, isUnread); + const shouldRecede = shouldRecedeSidebarThread({ + status, + isUnread, + isWoke: false, + isActive: props.isActive, + isSelected, + }); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => onFileDropThreads(threadRef, files), + addFolders: () => {}, + }), + [onFileDropThreads, threadRef], + ); + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); + + const accessibility = resolveSidebarRowAccessibility({ + title: thread.title, + statusLabel: topStatus?.label ?? null, + projectDisplayName: props.projectDisplayName, + isActive: props.isActive, + }); + + return ( +
  • + + props.onThreadClick(event, threadRef)} + onDoubleClick={(event) => { + if (props.isRenaming || event.metaKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + props.onStartRename(threadRef, thread.title); + }} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + props.onThreadActivate(threadRef); + }} + onContextMenu={(event) => { + event.preventDefault(); + props.onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }} + /> + } + > + {props.isRenaming ? ( + + ) : ( + + {thread.title} + + )} + {terminalStatus ? ( + + ) : null} + {topStatus ? ( + + + {topStatus.label} + + ) : null} + + {threadTimeLabel(thread)} + + {driverKind ? ( + + + + ) : null} + {props.jumpLabel ? : null} + + +
  • ); }); @@ -2178,7 +2437,12 @@ export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); - const hiddenTabThreads = useHiddenTabThreads(threads); + const tabThreadGroups = useHiddenTabThreads(threads); + const showTabs = useClientSettings((s) => s.sidebarShowTabs); + const updateClientSettings = useUpdateClientSettings(); + // Listed tabs open themselves; hidden tabs fold into their group's row, which reopens the + // tab you last had open. + const hiddenTabThreads = showTabs ? EMPTY_TAB_THREADS : tabThreadGroups; const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2290,8 +2554,10 @@ export default function Sidebar() { [routeDraftThread, routeTarget], ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // The open thread's group row, which must stay rendered even inside a collapsed shelf. const sidebarRouteThreadKey = - routeThreadKey === null ? null : (hiddenTabThreads.get(routeThreadKey) ?? routeThreadKey); + routeThreadKey === null ? null : (tabThreadGroups.get(routeThreadKey) ?? routeThreadKey); + const highlightedRouteThreadKey = showTabs ? routeThreadKey : sidebarRouteThreadKey; const routeTargetRef = useRef(routeTarget); routeTargetRef.current = routeTarget; // Post-settle navigation validates against the CURRENT route, not the one @@ -2578,7 +2844,7 @@ export default function Sidebar() { const visible = threads.filter( (thread) => thread.archivedAt === null && - !hiddenTabThreads.has(`${thread.environmentId}:${thread.id}`) && + !tabThreadGroups.has(`${thread.environmentId}:${thread.id}`) && (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); @@ -2667,7 +2933,7 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ - hiddenTabThreads, + tabThreadGroups, nowMinute, optimisticDrop, scopedProjectKeys, @@ -2676,13 +2942,30 @@ export default function Sidebar() { threads, ]); + const tabsByRowKey = useMemo( + () => + tabThreadGroups.size === 0 + ? EMPTY_TABS_BY_ROW + : groupSidebarTabThreads( + new Map(threads.map((thread) => [sidebarThreadKey(thread), thread] as const)), + tabThreadGroups, + ), + [tabThreadGroups, threads], + ); + const listedTabsByRowKey = showTabs ? tabsByRowKey : EMPTY_TABS_BY_ROW; + const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, pinnedThreads, settledThreads, snoozedThreads], + () => + withSidebarTabThreads( + [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], + sidebarThreadKey, + listedTabsByRowKey, + ), + [activeThreads, listedTabsByRowKey, pinnedThreads, settledThreads, snoozedThreads], ); const searchEnvironmentIds = useMemo( () => @@ -2820,8 +3103,19 @@ export default function Sidebar() { }, [sidebarRouteThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => + withSidebarTabThreads( + [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + sidebarThreadKey, + listedTabsByRowKey, + ), + [ + pinnedThreads, + activeThreads, + visibleSnoozedThreads, + renderedSettledThreads, + listedTabsByRowKey, + ], ); const orderedThreadKeys = useMemo( () => @@ -4340,6 +4634,11 @@ export default function Sidebar() { ], ); + const toggleShowTabs = useCallback(() => { + void updateClientSettings({ sidebarShowTabs: !showTabs }); + }, [showTabs, updateClientSettings]); + const toggleTabsShortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggleTabs"); + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as // v1 — the keybinding layer is shared, only the ordered list differs. const routeTerminalOpen = useTerminalUiStateStore((state) => @@ -4369,6 +4668,12 @@ export default function Sidebar() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return true; }; + if (command === "sidebar.toggleTabs") { + event.preventDefault(); + event.stopPropagation(); + toggleShowTabs(); + return; + } const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { navigateToThreadKey( @@ -4393,6 +4698,7 @@ export default function Sidebar() { routeTerminalOpen, routeThreadKey, threadByKey, + toggleShowTabs, ]); // Same predicate as v1: hints show only while the held modifiers exactly @@ -4614,6 +4920,15 @@ export default function Sidebar() { searchResultCount={threadSearchResults.length} activeSearchResultIndex={activeSearchResultIndex} onClearSearch={clearThreadSearch} + tabsToggle={ + tabsByRowKey.size > 0 || showTabs + ? { + shown: showTabs, + shortcutLabel: toggleTabsShortcutLabel, + onToggle: toggleShowTabs, + } + : null + } /> } @@ -4658,7 +4973,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES } isHighlighted={activeSearchResultIndex === index} - isRouteActive={sidebarRouteThreadKey === threadKey} + isRouteActive={highlightedRouteThreadKey === threadKey} resultId={`sidebar-thread-search-result-${index}`} searchMatch={ threadSearchMatchByKey.get( @@ -4734,6 +5049,7 @@ export default function Sidebar() { // not from the sidebar second-guessing what still matters. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; + const rowTabs = tabsByRowKey.get(threadKey); return ( + {rowTabs.map((tab) => { + const tabKey = sidebarThreadKey(tab); + const tabProjectKey = + `${tab.environmentId}:${tab.projectId}` as const; + return ( + + ); + })} + + ) : null + } /> ); }; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 7729ce1837541..6b13f7b809ce4 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -49,19 +49,23 @@ describe("KeybindingsSettings.logic", () => { it("orders Usage bindings and command choices like the page", () => { const expected = [ "usage.cost", - "usage.open", "usage.tokens", "usage.limits", "usage.period.day", "usage.period.week", "usage.period.month", "usage.period.quarter", + "usage.open", ]; - const bindings = DEFAULT_RESOLVED_KEYBINDINGS.toReversed(); - expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); - expect( - buildKeybindingCommandOptions(bindings).filter((command) => command.startsWith("usage.")), - ).toEqual(expected); + for (const bindings of [ + DEFAULT_RESOLVED_KEYBINDINGS, + DEFAULT_RESOLVED_KEYBINDINGS.toReversed(), + ]) { + expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected); + expect( + buildKeybindingCommandOptions(bindings).filter((command) => command.startsWith("usage.")), + ).toEqual(expected); + } }); it("builds searchable rows with readable key and when values", () => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a06628e5d5052..7d4f5c639d054 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -19,10 +19,14 @@ const usageCommandOrder = new Map( [...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option, index) => [option.command, index]), ); -function compareUsageCommands(left: KeybindingCommand, right: KeybindingCommand): number | null { - const leftIndex = usageCommandOrder.get(left); - const rightIndex = usageCommandOrder.get(right); - return leftIndex !== undefined && rightIndex !== undefined ? leftIndex - rightIndex : null; +/** + * Sort text that keeps the Usage page's metric and period commands in page order under + * `usagePrefix`, while everything else sorts by `name`. One key per command keeps the sort a + * total order, so the result never depends on the order bindings arrive in. + */ +function commandSortName(command: KeybindingCommand, name: string, usagePrefix: string): string { + const index = usageCommandOrder.get(command); + return index === undefined ? name : `${usagePrefix}${String(index).padStart(2, "0")}`; } export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -215,9 +219,9 @@ export function buildKeybindingRows( }); rowsWithConflicts.sort((left, right) => { - const commandCompare = - compareUsageCommands(left.command, right.command) ?? - left.command.localeCompare(right.command); + const commandCompare = commandSortName(left.command, left.command, "usage.").localeCompare( + commandSortName(right.command, right.command, "usage."), + ); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); @@ -290,9 +294,10 @@ export function buildKeybindingCommandOptions( for (const binding of keybindings) { commands.add(binding.command); } - return [...commands].toSorted( - (left, right) => - compareUsageCommands(left, right) ?? commandLabel(left).localeCompare(commandLabel(right)), + return [...commands].toSorted((left, right) => + commandSortName(left, commandLabel(left), "Usage: ").localeCompare( + commandSortName(right, commandLabel(right), "Usage: "), + ), ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fc5082fcf1a41..8138a61dfcacc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -563,6 +563,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), + ...(settings.sidebarShowTabs !== DEFAULT_UNIFIED_SETTINGS.sidebarShowTabs + ? ["Show tabs in sidebar"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] @@ -684,6 +687,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, + settings.sidebarShowTabs, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, @@ -781,6 +785,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + sidebarShowTabs: DEFAULT_UNIFIED_SETTINGS.sidebarShowTabs, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, @@ -2269,6 +2274,30 @@ export function GeneralSettingsPanel() { } /> + {settings.legacySidebarEnabled ? null : ( + + updateSettings({ sidebarShowTabs: DEFAULT_UNIFIED_SETTINGS.sidebarShowTabs }) + } + /> + ) : null + } + control={ + updateSettings({ sidebarShowTabs: Boolean(checked) })} + aria-label="Show tabs in sidebar" + /> + } + /> + )} + {supportsAutoSettlement ? ( <> void; + /** Shows or hides chat tabs in the list; null while no chat has more than one tab. */ + tabsToggle: { + shown: boolean; + shortcutLabel: string | null; + onToggle: () => void; + } | null; } export function SidebarThreadHeader({ @@ -67,6 +73,7 @@ export function SidebarThreadHeader({ searchResultCount, activeSearchResultIndex, onClearSearch, + tabsToggle, }: SidebarThreadHeaderProps) { const resultsVisible = isSearching && searchResultCount > 0; // Results shrink as the query narrows, so the active index can outrun the @@ -124,6 +131,21 @@ export function SidebarThreadHeader({ hover states, and a background well reads far louder on themed palettes than on the base light and dark ones. */}
    + {tabsToggle ? ( + + + + ) : null} {hasProjects ? ( <> {projectScope} @@ -176,7 +198,7 @@ export function SidebarHeaderIconButton({ children?: ReactNode; } & Omit< ComponentProps, - "children" | "className" | "tooltip" | "isActive" | "aria-label" + "children" | "className" | "tooltip" | "aria-label" >) { return ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fa04556995b6e..5837b6695ccf2 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -17,8 +17,14 @@ if that project exists there. Otherwise it selects an environment that has it. Click the tab name at the end of the header breadcrumb (`project / thread / tab`) and choose **New tab** to open a separate chat in the same workspace. The same menu switches between tabs. -Each tab has its own provider and conversation, and the sidebar shows only the original thread. -Opening it from the sidebar returns to the tab you last had open. +Each tab has its own provider and conversation. By default the sidebar shows one row per chat, +with a count of its tabs, and opening that row returns to the tab you last had open. + +On web and desktop, press `Cmd+Option+T` on macOS or `Ctrl+Alt+T` on Windows and Linux to list +each tab under its chat instead, with its own status, provider, and time since your last message. +The tabs button in the sidebar header, the command palette, and **Settings → General** toggle the +same view. Moving, pinning, or settling the chat's row applies to the row, and its tabs stay under +it. To bring in another tab's conversation, type `@` in the composer and pick the tab; its summary lands as a chip at the cursor. Before the first message in a new tab, you can also click a sibling diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 239f9905c534e..aea3f493070f5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -57,6 +57,7 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", + "sidebar.toggleTabs", "navigation.back", "navigation.forward", "terminal.toggle", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 156721aa26786..677f81d03843e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -476,6 +476,8 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + // Off: a chat's tabs share one sidebar row. On: each tab is listed under it. + sidebarShowTabs: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1733,6 +1735,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarShowTabs: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939e..15d0c77574f51 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,6 +20,7 @@ type WhenToken = export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+b", command: "sidebar.toggle" }, + { key: "mod+alt+t", command: "sidebar.toggleTabs" }, { key: "mod+[", command: "navigation.back", when: "!terminalFocus" }, { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" },