From d6c6ebeac23683f8957b293e1f6e69c74d3ea584 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 09:31:42 -0300 Subject: [PATCH 01/43] feat(web): add workspace thread tabs system Add configurable workspace thread tabs system enabled by default. Supports: - Permanent New thread launcher tab at the start of the workspace topbar. - Thread tabs with live title synchronization, project favicon, and active state. - Smooth close with fallback navigation, middle-click close, and context menu. - HTML5 Drag and Drop tab reordering with local storage persistence. - Horizontal mouse wheel scrolling and automatic scroll-into-view. - Toggle in Settings -> General with settings search integration. Google Antigravity --- apps/web/src/components/chat/ChatHeader.tsx | 153 ++++--- .../components/chat/WorkspaceTabs.test.tsx | 61 +++ .../web/src/components/chat/WorkspaceTabs.tsx | 432 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 22 + .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/hooks/useSettings.ts | 6 + apps/web/src/routes/_chat.draft.$draftId.tsx | 11 +- apps/web/src/workspaceTabsStore.test.ts | 169 +++++++ apps/web/src/workspaceTabsStore.ts | 212 +++++++++ packages/contracts/src/settings.test.ts | 6 + packages/contracts/src/settings.ts | 2 + 11 files changed, 1008 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/WorkspaceTabs.test.tsx create mode 100644 apps/web/src/components/chat/WorkspaceTabs.tsx create mode 100644 apps/web/src/workspaceTabsStore.test.ts create mode 100644 apps/web/src/workspaceTabsStore.ts diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 08e0422dd255..f203026b278c 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -42,6 +42,8 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; +import { useTabsEnabled } from "~/hooks/useSettings"; +import { WorkspaceTabs } from "./WorkspaceTabs"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -131,6 +133,7 @@ export const ChatHeader = memo(function ChatHeader({ onUpdateProjectScript, onDeleteProjectScript, }: ChatHeaderProps) { + const tabsEnabled = useTabsEnabled(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const fileScripts = useT3ProjectFileScripts( activeThreadEnvironmentId, @@ -229,88 +232,98 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > - - {/* The project always leads the header: knowing which project a - thread lives in is priority zero, and the thread title alone - doesn't answer it. */} - {activeProjectName ? ( - <> - + {tabsEnabled ? ( + + ) : ( + + {activeProjectName ? ( + <> + + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + + + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( } > - {activeThreadTitle} + - {activeProjectName} - New thread in {activeProjectName} + {activeThreadTitle} - - - - ) : null} - - {renamingTitle !== null ? ( - { - if (renameCommittedRef.current) return; - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> - ) : isServerThread ? ( - - - } - > -

{activeThreadTitle}

- + + {activeThreadTitle} + + } /> - - {activeThreadTitle} -
- ) : ( - - - {activeThreadTitle} - - } - /> - {activeThreadTitle} - - )} -
-
+ {activeThreadTitle} + + )} +
+
+ )}
({ + useNavigate: () => vi.fn(), +})); + +describe("WorkspaceTabs", () => { + const envId = EnvironmentId.make("env-local"); + const threadId = ThreadId.make("thread-1"); + + beforeEach(() => { + useWorkspaceTabsStore.setState({ + tabs: [ + { + key: serverTabKey(envId, threadId), + kind: "server", + environmentId: envId, + threadId, + title: "My Active Thread", + projectId: ProjectId.make("project-1"), + projectName: "Project Alpha", + }, + ], + activeTabKey: serverTabKey(envId, threadId), + }); + }); + + it("renders tabs container and active tab title", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + + expect(html).toContain("data-workspace-tabs"); + expect(html).toContain("My Active Thread"); + expect(html).toContain('data-tab-key="new-thread"'); + }); + + it("renders new thread tab", () => { + const html = renderToStaticMarkup( + {}} + />, + ); + + expect(html).toContain("New thread"); + }); +}); diff --git a/apps/web/src/components/chat/WorkspaceTabs.tsx b/apps/web/src/components/chat/WorkspaceTabs.tsx new file mode 100644 index 000000000000..b69d00ceef31 --- /dev/null +++ b/apps/web/src/components/chat/WorkspaceTabs.tsx @@ -0,0 +1,432 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; +import { Pin, Plus, X } from "lucide-react"; +import { + type DragEvent as ReactDragEvent, + type MouseEvent as ReactMouseEvent, + type WheelEvent as ReactWheelEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DraftId } from "~/composerDraftStore"; +import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { useThreadShell } from "../../state/entities"; +import { serverTabKey, useWorkspaceTabsStore, type WorkspaceTab } from "../../workspaceTabsStore"; + +interface WorkspaceTabsProps { + readonly activeThreadEnvironmentId: EnvironmentId; + readonly activeThreadId?: ThreadId | undefined; + readonly draftId?: DraftId | undefined; + readonly activeThreadTitle: string; + readonly activeProjectName?: string | undefined; + readonly activeProjectCwd?: string | null | undefined; + readonly activeProjectFaviconPath?: string | null | undefined; + readonly isWorking?: boolean | undefined; + readonly onNewTab: () => void; +} + +function ServerThreadTabItem({ + tab, + isActive, + isDragged, + isDragOver, + onActivate, + onClose, + onAuxClick, + onContextMenu, + onDragStart, + onDragOver, + onDragLeave, + onDrop, + onDragEnd, +}: { + readonly tab: WorkspaceTab; + readonly isActive: boolean; + readonly isDragged: boolean; + readonly isDragOver: boolean; + readonly onActivate: () => void; + readonly onClose: (e: ReactMouseEvent) => void; + readonly onAuxClick: (e: ReactMouseEvent) => void; + readonly onContextMenu: (e: ReactMouseEvent) => void; + readonly onDragStart: (e: ReactDragEvent) => void; + readonly onDragOver: (e: ReactDragEvent) => void; + readonly onDragLeave: () => void; + readonly onDrop: (e: ReactDragEvent) => void; + readonly onDragEnd: () => void; +}) { + const itemRef = useRef(null); + const threadRef = useMemo( + () => scopeThreadRef(tab.environmentId, tab.threadId), + [tab.environmentId, tab.threadId], + ); + const shell = useThreadShell(threadRef); + const title = shell?.title || tab.title || "Thread"; + const fullLabel = tab.projectName ? `${tab.projectName} ยท ${title}` : title; + + useEffect(() => { + if (isActive && itemRef.current) { + itemRef.current.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); + } + }, [isActive]); + + return ( +
+
+ + + {title}} /> + {fullLabel} + + {tab.pinned ? : null} +
+ + +
+ ); +} + +export function WorkspaceTabs({ + activeThreadEnvironmentId, + activeThreadId, + draftId, + activeThreadTitle, + activeProjectName, + activeProjectCwd, + activeProjectFaviconPath, + onNewTab, +}: WorkspaceTabsProps) { + const navigate = useNavigate(); + const tabs = useWorkspaceTabsStore((state) => state.tabs); + const openTab = useWorkspaceTabsStore((state) => state.openTab); + const closeTab = useWorkspaceTabsStore((state) => state.closeTab); + const closeOtherTabs = useWorkspaceTabsStore((state) => state.closeOtherTabs); + const closeTabsToRight = useWorkspaceTabsStore((state) => state.closeTabsToRight); + const closeAllTabs = useWorkspaceTabsStore((state) => state.closeAllTabs); + const reorderTabs = useWorkspaceTabsStore((state) => state.reorderTabs); + const togglePinTab = useWorkspaceTabsStore((state) => state.togglePinTab); + + const [draggedKey, setDraggedKey] = useState(null); + const [dragOverKey, setDragOverKey] = useState(null); + + const currentTabKey = activeThreadId + ? serverTabKey(activeThreadEnvironmentId, activeThreadId) + : null; + + const isDraftActive = !activeThreadId || Boolean(draftId); + + useEffect(() => { + if (!activeThreadId) return; + openTab({ + key: serverTabKey(activeThreadEnvironmentId, activeThreadId), + kind: "server", + environmentId: activeThreadEnvironmentId, + threadId: activeThreadId, + title: activeThreadTitle || "Thread", + projectName: activeProjectName, + projectCwd: activeProjectCwd, + faviconPath: activeProjectFaviconPath, + }); + }, [ + activeProjectCwd, + activeProjectFaviconPath, + activeProjectName, + activeThreadEnvironmentId, + activeThreadId, + activeThreadTitle, + openTab, + ]); + + const effectiveTabs = useMemo(() => { + if ( + !activeThreadId || + tabs.some( + (t) => t.threadId === activeThreadId && t.environmentId === activeThreadEnvironmentId, + ) + ) { + return tabs; + } + const currentTab: WorkspaceTab = { + key: serverTabKey(activeThreadEnvironmentId, activeThreadId), + kind: "server", + environmentId: activeThreadEnvironmentId, + threadId: activeThreadId, + title: activeThreadTitle || "Thread", + projectName: activeProjectName, + projectCwd: activeProjectCwd, + faviconPath: activeProjectFaviconPath, + }; + return [currentTab, ...tabs]; + }, [ + activeProjectCwd, + activeProjectFaviconPath, + activeProjectName, + activeThreadEnvironmentId, + activeThreadId, + activeThreadTitle, + tabs, + ]); + + const handleNavigateToTab = useCallback( + (tab: WorkspaceTab) => { + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: tab.environmentId, threadId: tab.threadId }, + }); + }, + [navigate], + ); + + const handleCloseTab = useCallback( + (tab: WorkspaceTab, event?: ReactMouseEvent) => { + event?.stopPropagation(); + event?.preventDefault(); + + const nextActive = closeTab(tab.key); + if (tab.key === currentTabKey) { + if (nextActive) { + handleNavigateToTab(nextActive); + } else { + onNewTab(); + } + } + }, + [closeTab, currentTabKey, handleNavigateToTab, onNewTab], + ); + + const handleTabAuxClick = useCallback( + (event: ReactMouseEvent, tab: WorkspaceTab) => { + if (event.button === 1) { + event.preventDefault(); + event.stopPropagation(); + handleCloseTab(tab); + } + }, + [handleCloseTab], + ); + + const handleTabContextMenu = useCallback( + async (event: ReactMouseEvent, tab: WorkspaceTab) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) return; + + const tabIndex = effectiveTabs.findIndex((t) => t.key === tab.key); + if (tabIndex < 0) return; + + const items = [ + { id: "close", label: "Close" }, + { + id: "close-others", + label: "Close others", + disabled: effectiveTabs.length <= 1, + }, + { + id: "close-to-right", + label: "Close to the right", + disabled: tabIndex >= effectiveTabs.length - 1, + }, + { + id: "close-all", + label: "Close all", + disabled: effectiveTabs.length === 0, + }, + { + id: "toggle-pin", + label: tab.pinned ? "Unpin tab" : "Pin tab", + }, + ]; + + const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); + switch (action) { + case "close": + handleCloseTab(tab); + break; + case "close-others": + closeOtherTabs(tab.key); + if (currentTabKey !== tab.key) { + handleNavigateToTab(tab); + } + break; + case "close-to-right": + closeTabsToRight(tab.key); + if (effectiveTabs.findIndex((t) => t.key === currentTabKey) > tabIndex) { + handleNavigateToTab(tab); + } + break; + case "close-all": + closeAllTabs(); + onNewTab(); + break; + case "toggle-pin": + togglePinTab(tab.key); + break; + case null: + break; + } + }, + [ + closeAllTabs, + closeOtherTabs, + closeTabsToRight, + currentTabKey, + effectiveTabs, + handleCloseTab, + handleNavigateToTab, + onNewTab, + togglePinTab, + ], + ); + + const handleDragStart = useCallback((e: ReactDragEvent, tabKey: string) => { + e.dataTransfer.setData("text/plain", tabKey); + e.dataTransfer.effectAllowed = "move"; + setDraggedKey(tabKey); + }, []); + + const handleDragOver = useCallback((e: ReactDragEvent, tabKey: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverKey(tabKey); + }, []); + + const handleDragLeave = useCallback((tabKey: string) => { + setDragOverKey((prev) => (prev === tabKey ? null : prev)); + }, []); + + const handleDrop = useCallback( + (e: ReactDragEvent, targetKey: string) => { + e.preventDefault(); + if (draggedKey && draggedKey !== targetKey) { + const sourceIndex = effectiveTabs.findIndex((t) => t.key === draggedKey); + const targetIndex = effectiveTabs.findIndex((t) => t.key === targetKey); + if (sourceIndex >= 0 && targetIndex >= 0) { + reorderTabs(sourceIndex, targetIndex); + } + } + setDraggedKey(null); + setDragOverKey(null); + }, + [draggedKey, effectiveTabs, reorderTabs], + ); + + const handleDragEnd = useCallback(() => { + setDraggedKey(null); + setDragOverKey(null); + }, []); + + const handleWheel = useCallback((e: ReactWheelEvent) => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + e.currentTarget.scrollLeft += e.deltaY; + } + }, []); + + return ( +
+ +
+ + + + New thread +
+ } + /> + New thread + + + {effectiveTabs.map((tab) => { + const isActive = tab.key === currentTabKey; + const isDragged = tab.key === draggedKey; + const isDragOver = tab.key === dragOverKey; + + return ( + handleNavigateToTab(tab)} + onClose={(e) => handleCloseTab(tab, e)} + onAuxClick={(e) => handleTabAuxClick(e, tab)} + onContextMenu={(e) => void handleTabContextMenu(e, tab)} + onDragStart={(e) => handleDragStart(e, tab.key)} + onDragOver={(e) => handleDragOver(e, tab.key)} + onDragLeave={() => handleDragLeave(tab.key)} + onDrop={(e) => handleDrop(e, tab.key)} + onDragEnd={handleDragEnd} + /> + ); + })} +
+ +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d57a4da1c2f0..bc4a47a9b774 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1838,6 +1838,28 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ tabsEnabled: DEFAULT_UNIFIED_SETTINGS.tabsEnabled }) + } + /> + ) : null + } + control={ + updateSettings({ tabsEnabled: Boolean(checked) })} + aria-label="Workspace tabs" + /> + } + /> + ( environmentId: EnvironmentId, diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 74c1b15c0711..f0dcd18e3713 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -11,6 +11,7 @@ import { SidebarInset } from "../components/ui/sidebar"; import { waitForDraftHeroTransition } from "../components/chat/draftHeroTransition"; import { buildThreadRouteParams } from "../threadRoutes"; import { useThread, useThreadRefs } from "../state/entities"; +import { serverTabKey, useWorkspaceTabsStore } from "../workspaceTabsStore"; function DraftChatThreadRouteView() { const navigate = useNavigate(); @@ -42,6 +43,14 @@ function DraftChatThreadRouteView() { return; } + useWorkspaceTabsStore.getState().openTab({ + key: serverTabKey(canonicalThreadRef.environmentId, canonicalThreadRef.threadId), + kind: "server", + environmentId: canonicalThreadRef.environmentId, + threadId: canonicalThreadRef.threadId, + title: serverThread?.title || "Thread", + }); + let cancelled = false; void waitForDraftHeroTransition().then(() => { if (cancelled) { @@ -57,7 +66,7 @@ function DraftChatThreadRouteView() { return () => { cancelled = true; }; - }, [canonicalThreadRef, navigate]); + }, [canonicalThreadRef, draftId, navigate, serverThread?.title]); useEffect(() => { if (draftSession || canonicalThreadRef) { diff --git a/apps/web/src/workspaceTabsStore.test.ts b/apps/web/src/workspaceTabsStore.test.ts new file mode 100644 index 000000000000..baec198420f6 --- /dev/null +++ b/apps/web/src/workspaceTabsStore.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { serverTabKey, useWorkspaceTabsStore, type WorkspaceTab } from "./workspaceTabsStore"; + +const envId = EnvironmentId.make("env-local"); +const projId = ProjectId.make("project-1"); +const thread1Id = ThreadId.make("thread-1"); +const thread2Id = ThreadId.make("thread-2"); +const thread3Id = ThreadId.make("thread-3"); + +function createServerTab(id: ThreadId, title = `Thread ${id}`): WorkspaceTab { + return { + key: serverTabKey(envId, id), + kind: "server", + environmentId: envId, + threadId: id, + title, + projectId: projId, + projectName: "My Project", + }; +} + +describe("workspaceTabsStore", () => { + beforeEach(() => { + useWorkspaceTabsStore.setState({ tabs: [], activeTabKey: null }); + }); + + it("opens a new tab and sets it active", () => { + const tab1 = createServerTab(thread1Id); + useWorkspaceTabsStore.getState().openTab(tab1); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0]?.key).toBe(tab1.key); + expect(state.activeTabKey).toBe(tab1.key); + }); + + it("updates existing tab title without creating duplicate", () => { + const tab1 = createServerTab(thread1Id, "Initial Title"); + useWorkspaceTabsStore.getState().openTab(tab1); + + const tab1Updated = createServerTab(thread1Id, "Updated Title"); + useWorkspaceTabsStore.getState().openTab(tab1Updated); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0]?.title).toBe("Updated Title"); + expect(state.activeTabKey).toBe(tab1.key); + }); + + it("prepends new tabs at the beginning", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + const tab3 = createServerTab(thread3Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab3); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs.map((t) => t.key)).toEqual([tab3.key, tab2.key, tab1.key]); + }); + + it("closes inactive tab without changing active tab", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + + const nextActive = useWorkspaceTabsStore.getState().closeTab(tab1.key); + expect(nextActive).toBeNull(); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.activeTabKey).toBe(tab2.key); + }); + + it("closes active tab and selects adjacent tab", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + const tab3 = createServerTab(thread3Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab3); + useWorkspaceTabsStore.getState().openTab(tab2); + + const nextActive = useWorkspaceTabsStore.getState().closeTab(tab2.key); + expect(nextActive?.key).toBe(tab1.key); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs.map((t) => t.key)).toEqual([tab3.key, tab1.key]); + expect(state.activeTabKey).toBe(tab1.key); + }); + + it("closes active tab when closing last tab in list", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab1); + + const nextActive = useWorkspaceTabsStore.getState().closeTab(tab1.key); + expect(nextActive?.key).toBe(tab2.key); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs.map((t) => t.key)).toEqual([tab2.key]); + expect(state.activeTabKey).toBe(tab2.key); + }); + + it("closes other tabs preserving keepTabKey", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + const tab3 = createServerTab(thread3Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab3); + + useWorkspaceTabsStore.getState().closeOtherTabs(tab2.key); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0]?.key).toBe(tab2.key); + expect(state.activeTabKey).toBe(tab2.key); + }); + + it("closes tabs to the right", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + const tab3 = createServerTab(thread3Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab3); + + useWorkspaceTabsStore.getState().closeTabsToRight(tab2.key); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs.map((t) => t.key)).toEqual([tab3.key, tab2.key]); + }); + + it("reorders tabs correctly", () => { + const tab1 = createServerTab(thread1Id); + const tab2 = createServerTab(thread2Id); + const tab3 = createServerTab(thread3Id); + + useWorkspaceTabsStore.getState().openTab(tab1); + useWorkspaceTabsStore.getState().openTab(tab2); + useWorkspaceTabsStore.getState().openTab(tab3); + + useWorkspaceTabsStore.getState().reorderTabs(0, 2); + + const state = useWorkspaceTabsStore.getState(); + expect(state.tabs.map((t) => t.key)).toEqual([tab2.key, tab1.key, tab3.key]); + }); + + it("toggles pin status on tab", () => { + const tab1 = createServerTab(thread1Id); + useWorkspaceTabsStore.getState().openTab(tab1); + + useWorkspaceTabsStore.getState().togglePinTab(tab1.key); + expect(useWorkspaceTabsStore.getState().tabs[0]?.pinned).toBe(true); + + useWorkspaceTabsStore.getState().togglePinTab(tab1.key); + expect(useWorkspaceTabsStore.getState().tabs[0]?.pinned).toBe(false); + }); +}); diff --git a/apps/web/src/workspaceTabsStore.ts b/apps/web/src/workspaceTabsStore.ts new file mode 100644 index 000000000000..910db8efac18 --- /dev/null +++ b/apps/web/src/workspaceTabsStore.ts @@ -0,0 +1,212 @@ +import { create } from "zustand"; +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; + +export interface WorkspaceTab { + readonly key: string; + readonly kind: "server"; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly title: string; + readonly projectId?: ProjectId | null | undefined; + readonly projectCwd?: string | null | undefined; + readonly projectName?: string | null | undefined; + readonly faviconPath?: string | null | undefined; + readonly pinned?: boolean | undefined; +} + +export function serverTabKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return `server:${environmentId}:${threadId}`; +} + +export interface WorkspaceTabsState { + readonly tabs: readonly WorkspaceTab[]; + readonly activeTabKey: string | null; + readonly openTab: (tab: WorkspaceTab) => void; + readonly closeTab: (tabKey: string) => WorkspaceTab | null; + readonly closeOtherTabs: (keepTabKey: string) => void; + readonly closeTabsToRight: (fromTabKey: string) => void; + readonly closeAllTabs: () => void; + readonly reorderTabs: (sourceIndex: number, targetIndex: number) => void; + readonly togglePinTab: (tabKey: string) => void; +} + +export const WORKSPACE_TABS_STORAGE_KEY = "t3code:workspace-tabs:v1"; + +interface PersistedTabsPayload { + tabs: WorkspaceTab[]; + activeTabKey: string | null; +} + +function loadPersistedTabs(): { tabs: WorkspaceTab[]; activeTabKey: string | null } { + if (typeof window === "undefined" || !window.localStorage) { + return { tabs: [], activeTabKey: null }; + } + try { + const raw = window.localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY); + if (!raw) return { tabs: [], activeTabKey: null }; + const parsed = JSON.parse(raw) as PersistedTabsPayload; + if (!parsed || !Array.isArray(parsed.tabs)) { + return { tabs: [], activeTabKey: null }; + } + const seen = new Set(); + const sanitizedTabs: WorkspaceTab[] = []; + for (const tab of parsed.tabs) { + if ( + tab && + tab.kind === "server" && + typeof tab.environmentId === "string" && + typeof tab.threadId === "string" + ) { + const key = serverTabKey(tab.environmentId as EnvironmentId, tab.threadId as ThreadId); + if (!seen.has(key)) { + seen.add(key); + sanitizedTabs.push({ + ...tab, + key, + kind: "server", + environmentId: tab.environmentId as EnvironmentId, + threadId: tab.threadId as ThreadId, + }); + } + } + } + const activeTabKey = + typeof parsed.activeTabKey === "string" && + sanitizedTabs.some((tab) => tab.key === parsed.activeTabKey) + ? parsed.activeTabKey + : (sanitizedTabs[0]?.key ?? null); + return { tabs: sanitizedTabs, activeTabKey }; + } catch { + return { tabs: [], activeTabKey: null }; + } +} + +function savePersistedTabs(tabs: readonly WorkspaceTab[], activeTabKey: string | null): void { + if (typeof window === "undefined" || !window.localStorage) return; + try { + const payload: PersistedTabsPayload = { + tabs: [...tabs], + activeTabKey, + }; + window.localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(payload)); + } catch {} +} + +const initial = loadPersistedTabs(); + +export const useWorkspaceTabsStore = create((set, get) => ({ + tabs: initial.tabs, + activeTabKey: initial.activeTabKey, + + openTab: (tab: WorkspaceTab) => { + set((state) => { + const existingIndex = state.tabs.findIndex((t) => t.key === tab.key); + let nextTabs: WorkspaceTab[]; + if (existingIndex >= 0) { + nextTabs = state.tabs.map((t, idx) => + idx === existingIndex + ? { + ...t, + ...tab, + title: tab.title || t.title, + projectName: tab.projectName ?? t.projectName, + projectCwd: tab.projectCwd ?? t.projectCwd, + faviconPath: tab.faviconPath ?? t.faviconPath, + } + : t, + ); + } else { + nextTabs = [tab, ...state.tabs]; + } + savePersistedTabs(nextTabs, tab.key); + return { tabs: nextTabs, activeTabKey: tab.key }; + }); + }, + + closeTab: (tabKey: string) => { + const state = get(); + const index = state.tabs.findIndex((t) => t.key === tabKey); + if (index === -1) return null; + + const isClosingActive = state.activeTabKey === tabKey; + const nextTabs = state.tabs.filter((t) => t.key !== tabKey); + let nextActiveTab: WorkspaceTab | null = null; + + if (isClosingActive) { + if (nextTabs.length > 0) { + const nextIndex = Math.min(index, nextTabs.length - 1); + nextActiveTab = nextTabs[nextIndex] ?? null; + } + } else { + nextActiveTab = state.tabs.find((t) => t.key === state.activeTabKey) ?? null; + } + + const nextActiveKey = nextActiveTab ? nextActiveTab.key : null; + savePersistedTabs(nextTabs, nextActiveKey); + set({ tabs: nextTabs, activeTabKey: nextActiveKey }); + + return isClosingActive ? nextActiveTab : null; + }, + + closeOtherTabs: (keepTabKey: string) => { + set((state) => { + const nextTabs = state.tabs.filter((t) => t.key === keepTabKey || t.pinned); + const nextActiveKey = nextTabs.some((t) => t.key === state.activeTabKey) + ? state.activeTabKey + : keepTabKey; + savePersistedTabs(nextTabs, nextActiveKey); + return { tabs: nextTabs, activeTabKey: nextActiveKey }; + }); + }, + + closeTabsToRight: (fromTabKey: string) => { + set((state) => { + const index = state.tabs.findIndex((t) => t.key === fromTabKey); + if (index === -1) return state; + const nextTabs = state.tabs.filter((t, i) => i <= index || t.pinned); + const nextActiveKey = nextTabs.some((t) => t.key === state.activeTabKey) + ? state.activeTabKey + : fromTabKey; + savePersistedTabs(nextTabs, nextActiveKey); + return { tabs: nextTabs, activeTabKey: nextActiveKey }; + }); + }, + + closeAllTabs: () => { + set((state) => { + const nextTabs = state.tabs.filter((t) => t.pinned); + const nextActiveKey = nextTabs[0]?.key ?? null; + savePersistedTabs(nextTabs, nextActiveKey); + return { tabs: nextTabs, activeTabKey: nextActiveKey }; + }); + }, + + reorderTabs: (sourceIndex: number, targetIndex: number) => { + set((state) => { + if ( + sourceIndex < 0 || + sourceIndex >= state.tabs.length || + targetIndex < 0 || + targetIndex >= state.tabs.length || + sourceIndex === targetIndex + ) { + return state; + } + const tab = state.tabs[sourceIndex]; + if (!tab) return state; + const nextTabs = [...state.tabs]; + nextTabs.splice(sourceIndex, 1); + nextTabs.splice(targetIndex, 0, tab); + savePersistedTabs(nextTabs, state.activeTabKey); + return { tabs: nextTabs }; + }); + }, + + togglePinTab: (tabKey: string) => { + set((state) => { + const nextTabs = state.tabs.map((t) => (t.key === tabKey ? { ...t, pinned: !t.pinned } : t)); + savePersistedTabs(nextTabs, state.activeTabKey); + return { tabs: nextTabs }; + }); + }, +})); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..9a15cb1e6186 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -71,10 +71,16 @@ describe("ClientSettings sidebar", () => { it("defaults to the current sidebar with automatic merge and inactivity settling", () => { const settings = decodeClientSettings({}); expect(settings.legacySidebarEnabled).toBe(false); + expect(settings.tabsEnabled).toBe(true); expect(settings.sidebarAutoSettleAfterDays).toBe(3); expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); + it("preserves an explicit tabsEnabled setting", () => { + expect(decodeClientSettings({ tabsEnabled: false }).tabsEnabled).toBe(false); + expect(decodeClientSettingsPatch({ tabsEnabled: false }).tabsEnabled).toBe(false); + }); + it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { const decoded = decodeClientSettings({ sidebarV2Enabled: false, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..5b8e4f1ca2bd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -180,6 +180,7 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + tabsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -797,6 +798,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + tabsEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), From 49766646b248dedf2ad6a040e8aa3e9660236d96 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 09:44:11 -0300 Subject: [PATCH 02/43] fix(web): address review comments on workspace tabs Fix Electron drag-region, Button primitives, wheel scroll viewport, inline rename, and settings restore defaults. Google Antigravity --- apps/web/src/components/chat/ChatHeader.tsx | 7 ++ .../web/src/components/chat/WorkspaceTabs.tsx | 113 ++++++++++++------ .../components/settings/SettingsPanels.tsx | 19 +-- apps/web/src/workspaceTabsStore.test.ts | 2 +- apps/web/src/workspaceTabsStore.ts | 16 ++- 5 files changed, 98 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f203026b278c..50a02c223b91 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -242,6 +242,13 @@ export const ChatHeader = memo(function ChatHeader({ activeProjectCwd={activeProjectCwd} activeProjectFaviconPath={activeProjectFaviconPath} onNewTab={onNewThreadInProject} + renamingTitle={renamingTitle} + onCommitRename={commitRename} + onCancelRename={() => { + renameCommittedRef.current = true; + setRenaming(null); + }} + onRenameKeyDown={handleRenameKeyDown} /> ) : ( diff --git a/apps/web/src/components/chat/WorkspaceTabs.tsx b/apps/web/src/components/chat/WorkspaceTabs.tsx index b69d00ceef31..d48ca697432d 100644 --- a/apps/web/src/components/chat/WorkspaceTabs.tsx +++ b/apps/web/src/components/chat/WorkspaceTabs.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Pin, Plus, X } from "lucide-react"; import { type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type WheelEvent as ReactWheelEvent, useCallback, @@ -15,6 +16,7 @@ import { import type { DraftId } from "~/composerDraftStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -31,6 +33,10 @@ interface WorkspaceTabsProps { readonly activeProjectFaviconPath?: string | null | undefined; readonly isWorking?: boolean | undefined; readonly onNewTab: () => void; + readonly renamingTitle?: string | null | undefined; + readonly onCommitRename?: ((title: string) => void) | undefined; + readonly onCancelRename?: (() => void) | undefined; + readonly onRenameKeyDown?: ((event: ReactKeyboardEvent) => void) | undefined; } function ServerThreadTabItem({ @@ -38,6 +44,9 @@ function ServerThreadTabItem({ isActive, isDragged, isDragOver, + renamingTitle, + onCommitRename, + onRenameKeyDown, onActivate, onClose, onAuxClick, @@ -52,6 +61,9 @@ function ServerThreadTabItem({ readonly isActive: boolean; readonly isDragged: boolean; readonly isDragOver: boolean; + readonly renamingTitle?: string | null | undefined; + readonly onCommitRename?: ((title: string) => void) | undefined; + readonly onRenameKeyDown?: ((event: ReactKeyboardEvent) => void) | undefined; readonly onActivate: () => void; readonly onClose: (e: ReactMouseEvent) => void; readonly onAuxClick: (e: ReactMouseEvent) => void; @@ -77,10 +89,12 @@ function ServerThreadTabItem({ } }, [isActive]); + const isRenaming = isActive && renamingTitle !== null && renamingTitle !== undefined; + return (
-
- { + onCommitRename?.(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={onRenameKeyDown} /> - - {title}} /> - {fullLabel} - - {tab.pinned ? : null} -
+ ) : ( + + )} - +
); } @@ -139,6 +173,9 @@ export function WorkspaceTabs({ activeProjectCwd, activeProjectFaviconPath, onNewTab, + renamingTitle, + onCommitRename, + onRenameKeyDown, }: WorkspaceTabsProps) { const navigate = useNavigate(); const tabs = useWorkspaceTabsStore((state) => state.tabs); @@ -288,18 +325,24 @@ export function WorkspaceTabs({ case "close": handleCloseTab(tab); break; - case "close-others": + case "close-others": { closeOtherTabs(tab.key); - if (currentTabKey !== tab.key) { + const remainingTabs = useWorkspaceTabsStore.getState().tabs; + const isCurrentStillOpen = remainingTabs.some((t) => t.key === currentTabKey); + if (!isCurrentStillOpen) { handleNavigateToTab(tab); } break; - case "close-to-right": + } + case "close-to-right": { closeTabsToRight(tab.key); - if (effectiveTabs.findIndex((t) => t.key === currentTabKey) > tabIndex) { + const remainingTabs = useWorkspaceTabsStore.getState().tabs; + const isCurrentStillOpen = remainingTabs.some((t) => t.key === currentTabKey); + if (!isCurrentStillOpen) { handleNavigateToTab(tab); } break; + } case "close-all": closeAllTabs(); onNewTab(); @@ -344,16 +387,12 @@ export function WorkspaceTabs({ (e: ReactDragEvent, targetKey: string) => { e.preventDefault(); if (draggedKey && draggedKey !== targetKey) { - const sourceIndex = effectiveTabs.findIndex((t) => t.key === draggedKey); - const targetIndex = effectiveTabs.findIndex((t) => t.key === targetKey); - if (sourceIndex >= 0 && targetIndex >= 0) { - reorderTabs(sourceIndex, targetIndex); - } + reorderTabs(draggedKey, targetKey); } setDraggedKey(null); setDragOverKey(null); }, - [draggedKey, effectiveTabs, reorderTabs], + [draggedKey, reorderTabs], ); const handleDragEnd = useCallback(() => { @@ -362,9 +401,10 @@ export function WorkspaceTabs({ }, []); const handleWheel = useCallback((e: ReactWheelEvent) => { - if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { - e.currentTarget.scrollLeft += e.deltaY; - } + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + const viewport = e.currentTarget.closest('[data-slot="scroll-area-viewport"]'); + if (!viewport) return; + viewport.scrollLeft += e.deltaY; }, []); return ( @@ -382,12 +422,14 @@ export function WorkspaceTabs({ New thread - + } /> New thread @@ -413,6 +455,9 @@ export function WorkspaceTabs({ isActive={isActive} isDragged={isDragged} isDragOver={isDragOver} + renamingTitle={renamingTitle} + onCommitRename={onCommitRename} + onRenameKeyDown={onRenameKeyDown} onActivate={() => handleNavigateToTab(tab)} onClose={(e) => handleCloseTab(tab, e)} onAuxClick={(e) => handleTabAuxClick(e, tab)} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bc4a47a9b774..63bdd8ecbfe9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -496,6 +496,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), + ...(settings.tabsEnabled !== DEFAULT_UNIFIED_SETTINGS.tabsEnabled ? ["Workspace tabs"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace @@ -557,6 +558,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, + settings.tabsEnabled, settings.timestampFormat, settings.wordWrap, followSystem, @@ -576,24 +578,13 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - // Only touch the theme keys that are actually dirty, so a theme-storage - // failure cannot block restoring unrelated settings. Preferences are - // re-read after the confirmation dialog: they may have changed (another - // tab, an OS flip) while it was open, and rollback must restore the live - // values rather than the ones captured at render time. let previousTheme = theme; try { previousTheme = readThemePreference(); - } catch { - // Storage is unreadable; the render-time value is the best rollback. - } - // The mix may have changed while the confirmation dialog was open; both - // the dirty check and the rollback must see the live value. + } catch {} const liveHalves = readThemeHalves(); const needsThemeReset = previousTheme !== "system"; const needsMixReset = liveHalves !== null; - // Same for the appearance mode: trusting the render-time value would skip - // the reset and report success while a non-system mode stayed in storage. const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; const notifyThemeRestoreFailure = () => { toastManager.add( @@ -604,9 +595,6 @@ export function useSettingsRestore(onRestored?: () => void) { }), ); }; - // Rollback restores the base preference first (which clears any mix) and - // then re-applies the captured mix on top, so no failure path can leave - // the pair of keys half-restored. const previousHalves = liveHalves; const rollbackThemeState = () => { if (needsThemeReset) setTheme(previousTheme); @@ -637,6 +625,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, + tabsEnabled: DEFAULT_UNIFIED_SETTINGS.tabsEnabled, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, diff --git a/apps/web/src/workspaceTabsStore.test.ts b/apps/web/src/workspaceTabsStore.test.ts index baec198420f6..4c092bb698d3 100644 --- a/apps/web/src/workspaceTabsStore.test.ts +++ b/apps/web/src/workspaceTabsStore.test.ts @@ -150,7 +150,7 @@ describe("workspaceTabsStore", () => { useWorkspaceTabsStore.getState().openTab(tab2); useWorkspaceTabsStore.getState().openTab(tab3); - useWorkspaceTabsStore.getState().reorderTabs(0, 2); + useWorkspaceTabsStore.getState().reorderTabs(tab3.key, tab1.key); const state = useWorkspaceTabsStore.getState(); expect(state.tabs.map((t) => t.key)).toEqual([tab2.key, tab1.key, tab3.key]); diff --git a/apps/web/src/workspaceTabsStore.ts b/apps/web/src/workspaceTabsStore.ts index 910db8efac18..67d7ec153598 100644 --- a/apps/web/src/workspaceTabsStore.ts +++ b/apps/web/src/workspaceTabsStore.ts @@ -26,7 +26,7 @@ export interface WorkspaceTabsState { readonly closeOtherTabs: (keepTabKey: string) => void; readonly closeTabsToRight: (fromTabKey: string) => void; readonly closeAllTabs: () => void; - readonly reorderTabs: (sourceIndex: number, targetIndex: number) => void; + readonly reorderTabs: (sourceKey: string, targetKey: string) => void; readonly togglePinTab: (tabKey: string) => void; } @@ -150,6 +150,8 @@ export const useWorkspaceTabsStore = create((set, get) => ({ closeOtherTabs: (keepTabKey: string) => { set((state) => { + const exists = state.tabs.some((t) => t.key === keepTabKey); + if (!exists) return state; const nextTabs = state.tabs.filter((t) => t.key === keepTabKey || t.pinned); const nextActiveKey = nextTabs.some((t) => t.key === state.activeTabKey) ? state.activeTabKey @@ -181,15 +183,11 @@ export const useWorkspaceTabsStore = create((set, get) => ({ }); }, - reorderTabs: (sourceIndex: number, targetIndex: number) => { + reorderTabs: (sourceKey: string, targetKey: string) => { set((state) => { - if ( - sourceIndex < 0 || - sourceIndex >= state.tabs.length || - targetIndex < 0 || - targetIndex >= state.tabs.length || - sourceIndex === targetIndex - ) { + const sourceIndex = state.tabs.findIndex((t) => t.key === sourceKey); + const targetIndex = state.tabs.findIndex((t) => t.key === targetKey); + if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) { return state; } const tab = state.tabs[sourceIndex]; From 9d0cf27c03b56beae7ca2e2724777046de0f56f7 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 09:47:01 -0300 Subject: [PATCH 03/43] fix(settings): preserve existing comments in useSettingsRestore --- .../src/components/settings/SettingsPanels.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 63bdd8ecbfe9..f0bc135c56fb 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -578,13 +578,24 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; + // Only touch the theme keys that are actually dirty, so a theme-storage + // failure cannot block restoring unrelated settings. Preferences are + // re-read after the confirmation dialog: they may have changed (another + // tab, an OS flip) while it was open, and rollback must restore the live + // values rather than the ones captured at render time. let previousTheme = theme; try { previousTheme = readThemePreference(); - } catch {} + } catch { + // Storage is unreadable; the render-time value is the best rollback. + } + // The mix may have changed while the confirmation dialog was open; both + // the dirty check and the rollback must see the live value. const liveHalves = readThemeHalves(); const needsThemeReset = previousTheme !== "system"; const needsMixReset = liveHalves !== null; + // Same for the appearance mode: trusting the render-time value would skip + // the reset and report success while a non-system mode stayed in storage. const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; const notifyThemeRestoreFailure = () => { toastManager.add( @@ -595,6 +606,9 @@ export function useSettingsRestore(onRestored?: () => void) { }), ); }; + // Rollback restores the base preference first (which clears any mix) and + // then re-applies the captured mix on top, so no failure path can leave + // the pair of keys half-restored. const previousHalves = liveHalves; const rollbackThemeState = () => { if (needsThemeReset) setTheme(previousTheme); From 6f077c2de280eb13e4f1d9e22d5a60d302680969 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 09:58:59 -0300 Subject: [PATCH 04/43] fix(web): support thread action menu from active tab and guard middle-click Wire thread action menu on active tab with chevron indicator and guard middle-click from autoscroll. Google Antigravity --- apps/web/src/components/chat/ChatHeader.tsx | 7 ++++ .../web/src/components/chat/WorkspaceTabs.tsx | 41 ++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 50a02c223b91..b71b59c74afa 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -249,6 +249,13 @@ export const ChatHeader = memo(function ChatHeader({ setRenaming(null); }} onRenameKeyDown={handleRenameKeyDown} + onOpenThreadMenu={(rect) => { + if (rect) { + openMenu({ x: rect.left, y: rect.bottom + 4 }); + } else { + openMenuFromTitle(); + } + }} /> ) : ( diff --git a/apps/web/src/components/chat/WorkspaceTabs.tsx b/apps/web/src/components/chat/WorkspaceTabs.tsx index d48ca697432d..8ce2302978d9 100644 --- a/apps/web/src/components/chat/WorkspaceTabs.tsx +++ b/apps/web/src/components/chat/WorkspaceTabs.tsx @@ -1,7 +1,7 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { useNavigate } from "@tanstack/react-router"; -import { Pin, Plus, X } from "lucide-react"; +import { ChevronDown, Pin, Plus, X } from "lucide-react"; import { type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, @@ -37,6 +37,7 @@ interface WorkspaceTabsProps { readonly onCommitRename?: ((title: string) => void) | undefined; readonly onCancelRename?: (() => void) | undefined; readonly onRenameKeyDown?: ((event: ReactKeyboardEvent) => void) | undefined; + readonly onOpenThreadMenu?: ((targetRect?: DOMRect) => void) | undefined; } function ServerThreadTabItem({ @@ -47,6 +48,7 @@ function ServerThreadTabItem({ renamingTitle, onCommitRename, onRenameKeyDown, + onOpenThreadMenu, onActivate, onClose, onAuxClick, @@ -64,6 +66,7 @@ function ServerThreadTabItem({ readonly renamingTitle?: string | null | undefined; readonly onCommitRename?: ((title: string) => void) | undefined; readonly onRenameKeyDown?: ((event: ReactKeyboardEvent) => void) | undefined; + readonly onOpenThreadMenu?: ((targetRect?: DOMRect) => void) | undefined; readonly onActivate: () => void; readonly onClose: (e: ReactMouseEvent) => void; readonly onAuxClick: (e: ReactMouseEvent) => void; @@ -75,6 +78,7 @@ function ServerThreadTabItem({ readonly onDragEnd: () => void; }) { const itemRef = useRef(null); + const triggerRef = useRef(null); const threadRef = useMemo( () => scopeThreadRef(tab.environmentId, tab.threadId), [tab.environmentId, tab.threadId], @@ -91,12 +95,25 @@ function ServerThreadTabItem({ const isRenaming = isActive && renamingTitle !== null && renamingTitle !== undefined; + const handleActivationClick = useCallback(() => { + if (isActive) { + if (triggerRef.current && onOpenThreadMenu) { + onOpenThreadMenu(triggerRef.current.getBoundingClientRect()); + } + } else { + onActivate(); + } + }, [isActive, onActivate, onOpenThreadMenu]); + return (
{ + if (event.button === 1) event.preventDefault(); + }} onDragStart={onDragStart} onDragOver={onDragOver} onDragLeave={onDragLeave} @@ -127,9 +144,11 @@ function ServerThreadTabItem({ /> ) : ( )} @@ -176,6 +201,7 @@ export function WorkspaceTabs({ renamingTitle, onCommitRename, onRenameKeyDown, + onOpenThreadMenu, }: WorkspaceTabsProps) { const navigate = useNavigate(); const tabs = useWorkspaceTabsStore((state) => state.tabs); @@ -288,12 +314,12 @@ export function WorkspaceTabs({ const handleTabContextMenu = useCallback( async (event: ReactMouseEvent, tab: WorkspaceTab) => { - event.preventDefault(); - event.stopPropagation(); - const api = readLocalApi(); if (!api) return; + event.preventDefault(); + event.stopPropagation(); + const tabIndex = effectiveTabs.findIndex((t) => t.key === tab.key); if (tabIndex < 0) return; @@ -458,6 +484,11 @@ export function WorkspaceTabs({ renamingTitle={renamingTitle} onCommitRename={onCommitRename} onRenameKeyDown={onRenameKeyDown} + onOpenThreadMenu={(rect) => { + if (rect && onOpenThreadMenu) { + onOpenThreadMenu(rect); + } + }} onActivate={() => handleNavigateToTab(tab)} onClose={(e) => handleCloseTab(tab, e)} onAuxClick={(e) => handleTabAuxClick(e, tab)} From f571f777a66e2d5999b7392fcf5f54da016a18be Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 10:29:35 -0300 Subject: [PATCH 05/43] fix(web): preserve surviving active tab on close-all and fix focus-visible chevron Fallback to surviving active pinned tab on close-all and scope tab trigger group for focus-visible. Google Antigravity --- apps/web/src/components/chat/WorkspaceTabs.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/WorkspaceTabs.tsx b/apps/web/src/components/chat/WorkspaceTabs.tsx index 8ce2302978d9..b78e3f3a3b27 100644 --- a/apps/web/src/components/chat/WorkspaceTabs.tsx +++ b/apps/web/src/components/chat/WorkspaceTabs.tsx @@ -149,7 +149,7 @@ function ServerThreadTabItem({ aria-label={fullLabel} aria-haspopup={isActive ? "menu" : undefined} onClick={handleActivationClick} - className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" + className="group/tab-trigger flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > ) : null} {tab.pinned ? : null} @@ -369,10 +369,17 @@ export function WorkspaceTabs({ } break; } - case "close-all": + case "close-all": { closeAllTabs(); - onNewTab(); + const { tabs: remainingTabs, activeTabKey } = useWorkspaceTabsStore.getState(); + const nextActiveTab = remainingTabs.find((t) => t.key === activeTabKey); + if (nextActiveTab) { + handleNavigateToTab(nextActiveTab); + } else { + onNewTab(); + } break; + } case "toggle-pin": togglePinTab(tab.key); break; From 4e2aaef66c4a37291f004abb6242bd9a29f4932f Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 10:36:54 -0300 Subject: [PATCH 06/43] fix(web): avoid navigating away from surviving pinned tab in close-all Only navigate to another tab on close-all if current active tab is no longer open. Google Antigravity --- apps/web/src/components/chat/WorkspaceTabs.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/WorkspaceTabs.tsx b/apps/web/src/components/chat/WorkspaceTabs.tsx index b78e3f3a3b27..646f1e903432 100644 --- a/apps/web/src/components/chat/WorkspaceTabs.tsx +++ b/apps/web/src/components/chat/WorkspaceTabs.tsx @@ -372,11 +372,14 @@ export function WorkspaceTabs({ case "close-all": { closeAllTabs(); const { tabs: remainingTabs, activeTabKey } = useWorkspaceTabsStore.getState(); - const nextActiveTab = remainingTabs.find((t) => t.key === activeTabKey); - if (nextActiveTab) { - handleNavigateToTab(nextActiveTab); - } else { - onNewTab(); + const isCurrentStillOpen = remainingTabs.some((t) => t.key === currentTabKey); + if (!isCurrentStillOpen) { + const nextActiveTab = remainingTabs.find((t) => t.key === activeTabKey); + if (nextActiveTab) { + handleNavigateToTab(nextActiveTab); + } else { + onNewTab(); + } } break; } From 0f102e151dde2938b2463268afc99933629226f9 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Fri, 14 Aug 2026 14:46:02 -0300 Subject: [PATCH 07/43] fix(web): keep settled shelf collapsed when closed --- apps/web/src/components/Sidebar.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a7a5b638c0eb..f52156d11411 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2114,14 +2114,9 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { - if (settledShelfExpanded) return visibleSettledThreads; - if (routeThreadKey === null) return []; - const routeThread = visibleSettledThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, - ); - return routeThread === undefined ? [] : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + if (!settledShelfExpanded) return []; + return visibleSettledThreads; + }, [settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump From bf9a14cae7cb4d3506456a21810ce259907bd2a2 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Fri, 14 Aug 2026 20:44:24 -0300 Subject: [PATCH 08/43] feat(web): enable dragging threads across sidebar shelves Add drag-and-drop support across all sidebar sections (Active, Pinned, Snoozed, and Settled) with dedicated drop targets, active session lifecycle handling, and smooth overlay centering. - Enables dragging between Active, Snoozed, and Settled with automatic lifecycle transitions (session turn interruption, snooze clearing, and settlement). - Adds responsive drop zones and generous collision detection across the lower sidebar for fast thread organization. - Centers drag overlay under pointer using snapCenterToCursor and portaled drag preview. Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/Sidebar.tsx | 1120 +++++++++++++++++------- apps/web/src/hooks/useThreadActions.ts | 29 +- 2 files changed, 800 insertions(+), 349 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f52156d11411..e08c04e5e30d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,13 +1,20 @@ +import { createPortal } from "react-dom"; import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, + DragOverlay, PointerSensor, closestCenter, + pointerWithin, + rectIntersection, + useDroppable, useSensor, useSensors, + type CollisionDetection, type DragEndEvent, + type DragStartEvent, } from "@dnd-kit/core"; import { SortableContext, @@ -15,9 +22,10 @@ import { useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; +import { snapCenterToCursor } from "@dnd-kit/modifiers"; import { + canSettle, canSnooze, changeRequestAutoSettles, effectiveSettled, @@ -105,7 +113,7 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { readThreadShell, useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; @@ -128,6 +136,7 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, + pinOrderKeyBetween, planPinnedReorder, resolveAdjacentThreadId, resolveSettledTimestamp, @@ -429,6 +438,95 @@ function SortablePinnedThreadRow(props: { return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } +function DraggableThreadRow(props: { + id: string; + children: (bag: SortablePinnedRowBag) => ReactNode; +}) { + const { listeners, setNodeRef, isDragging } = useSortable({ + id: props.id, + }); + return props.children({ + listeners, + setNodeRef, + transform: null, + transition: undefined, + isDragging, + }); +} + +function DroppableShelfHeader(props: { id: string; className?: string; children: ReactNode }) { + const { setNodeRef, isOver } = useDroppable({ id: props.id }); + return ( +
  • + {props.children} +
  • + ); +} + +function DroppableActiveDropZone(props: { id: string; children?: ReactNode }) { + const { setNodeRef, isOver } = useDroppable({ id: props.id }); + return ( +
  • + {props.children ?? "Move to active"} +
  • + ); +} + +function DroppableSettledBottomZone(props: { id: string; children?: ReactNode }) { + const { setNodeRef, isOver } = useDroppable({ id: props.id }); + return ( +
  • + {props.children ?? "Move to settled"} +
  • + ); +} + +const waitForSessionStopped = async (ref: ScopedThreadRef, timeoutMs = 4000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const shell = readThreadShell(ref); + if (shell) { + const status = shell.session?.status; + const isWorking = + status === "starting" || + status === "running" || + shell.hasPendingApprovals || + shell.hasPendingUserInput; + if (!isWorking) { + return true; + } + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return false; +}; + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it โ€” @@ -712,6 +810,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + isOverlay?: boolean | undefined; }) { const { isRenaming, @@ -877,7 +976,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; - const detailsTooltip = ( + const detailsTooltip = props.isOverlay ? null : ( @@ -1640,6 +1762,12 @@ export default function Sidebar() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { + reportFailure: false, + }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -2155,6 +2283,41 @@ export default function Sidebar() { ), [orderedThreads], ); + const [activeDragKey, setActiveDragKey] = useState(null); + const activeDragThread = useMemo(() => { + if (activeDragKey === null) return null; + return ( + searchableThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeDragKey, + ) ?? null + ); + }, [activeDragKey, searchableThreads]); + const activeDragSection = useMemo<"pinned" | "active" | "snoozed" | "settled">(() => { + if (!activeDragThread) return "active"; + if ( + pinnedThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "pinned"; + } + if ( + snoozedThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "snoozed"; + } + if ( + settledThreads.some( + (t) => t.id === activeDragThread.id && t.environmentId === activeDragThread.environmentId, + ) + ) { + return "settled"; + } + return "active"; + }, [activeDragThread, pinnedThreads, snoozedThreads, settledThreads]); // Rows call back into the click handler without carrying the ordered list as // a prop โ€” a fresh array identity per shell update would defeat every row's // memoization. The ref keeps shift-range-select working against the list as @@ -2394,16 +2557,21 @@ export default function Sidebar() { ); const attemptSettle = useCallback( - (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + ( + threadRef: ScopedThreadRef, + opts: { coSettlingKeys?: ReadonlySet; force?: boolean } = {}, + ) => { void (async () => { const threadKey = scopedThreadKey(threadRef); if (settlingThreadKeysRef.current.has(threadKey)) return; settlingThreadKeysRef.current.add(threadKey); try { const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); + const result = await settleThread( + threadRef, + opts.force !== undefined ? { force: opts.force } : undefined, + ); if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2416,8 +2584,6 @@ export default function Sidebar() { } return; } - // Only move forward if the user is still on the settled thread โ€” - // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSettle?.(); } @@ -2480,6 +2646,33 @@ export default function Sidebar() { const pinnedDndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); + const collisionDetectionStrategy: CollisionDetection = useCallback((args) => { + const pointerCollisions = pointerWithin(args); + if (pointerCollisions.length > 0) { + return pointerCollisions; + } + const intersections = rectIntersection(args); + if (intersections.length > 0) { + return intersections; + } + if (args.pointerCoordinates) { + const settledHeader = args.droppableContainers.find( + (c) => + c.id === "settled-shelf-header" || + c.id === "settled-drop-zone" || + c.id === "settled-bottom-drop-zone", + ); + if (settledHeader && settledHeader.rect.current) { + if (args.pointerCoordinates.y >= settledHeader.rect.current.top) { + const target = + args.droppableContainers.find((c) => c.id === "settled-bottom-drop-zone") ?? + settledHeader; + return [{ id: target.id }]; + } + } + } + return []; + }, []); const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ readonly order: readonly string[]; /** pinOrderKey per thread as of the drop โ€” the baseline that tells a @@ -2497,6 +2690,13 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); + const orderedPinnedKeys = useMemo( + () => + orderedPinnedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedPinnedThreads], + ); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => @@ -2575,80 +2775,13 @@ export default function Sidebar() { }, [unpinThread], ); - - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, - }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), - }); - void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) โ€” unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - // Any failure โ€” interrupted included โ€” releases the override: - // a key that never lands would otherwise hold it until some - // unrelated world change came along. - setOptimisticPinnedOrder(null); - if (isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to reorder pinned threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } - } - })(); - }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], - ); // One snooze per thread at a time โ€” same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( async ( threadRef: ScopedThreadRef, preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, + opts: { coSnoozingKeys?: ReadonlySet; force?: boolean } = {}, ) => { const threadKey = scopedThreadKey(threadRef); if (snoozingThreadKeysRef.current.has(threadKey)) { @@ -2656,18 +2789,17 @@ export default function Sidebar() { } snoozingThreadKeysRef.current.add(threadKey); try { - // Snoozing the open thread moves you forward, same as settle โ€” - // both park the thread you're done with for now. const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); + const result = await snoozeThread( + threadRef, + preset.snoozedUntil, + opts.force !== undefined ? { force: opts.force } : undefined, + ); if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. return isAtomCommandInterrupted(result) ? ({ status: "interrupted" } as const) : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); } - // Only move forward if the user is still on the snoozed thread โ€” - // a navigation made during the await wins over ours. if (routeThreadKeyRef.current === threadKey) { navigateAfterSnooze?.(); } @@ -2682,7 +2814,7 @@ export default function Sidebar() { ( threadRef: ScopedThreadRef, preset: SnoozePreset, - opts: { coSnoozingKeys?: ReadonlySet } = {}, + opts: { coSnoozingKeys?: ReadonlySet; force?: boolean } = {}, ) => { void (async () => { const outcome = await performSnooze(threadRef, preset, opts); @@ -2716,6 +2848,299 @@ export default function Sidebar() { [attemptUnsnooze, performSnooze, timestampFormat], ); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const overKey = event.over === null ? null : String(event.over.id); + if (overKey === null || activeKey === overKey) return; + + const activeThread = searchableThreads.find( + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeKey, + ); + if (activeThread === undefined) return; + const activeThreadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + + const activeSection: "pinned" | "active" | "snoozed" | "settled" = pinnedThreads.some( + (thread) => + thread.id === activeThread.id && thread.environmentId === activeThread.environmentId, + ) + ? "pinned" + : snoozedThreads.some( + (thread) => + thread.id === activeThread.id && + thread.environmentId === activeThread.environmentId, + ) + ? "snoozed" + : settledThreads.some( + (thread) => + thread.id === activeThread.id && + thread.environmentId === activeThread.environmentId, + ) + ? "settled" + : "active"; + + const overThread = searchableThreads.find( + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === overKey, + ); + + let overSection: "pinned" | "active" | "snoozed" | "settled" | null = null; + if (overKey === "snoozed-shelf-header" || overKey === "snoozed-drop-zone") { + overSection = "snoozed"; + } else if ( + overKey === "settled-shelf-header" || + overKey === "settled-drop-zone" || + overKey === "settled-bottom-drop-zone" + ) { + overSection = "settled"; + } else if (overKey === "active-drop-zone") { + overSection = "active"; + } else if (overThread !== undefined) { + overSection = pinnedThreads.some( + (thread) => + thread.id === overThread.id && thread.environmentId === overThread.environmentId, + ) + ? "pinned" + : snoozedThreads.some( + (thread) => + thread.id === overThread.id && thread.environmentId === overThread.environmentId, + ) + ? "snoozed" + : settledThreads.some( + (thread) => + thread.id === overThread.id && + thread.environmentId === overThread.environmentId, + ) + ? "settled" + : "active"; + } + + if (overSection === null) return; + + if (activeSection === "pinned" && overSection === "pinned") { + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const fromIndex = keys.indexOf(activeKey); + const toIndex = keys.indexOf(overKey); + if (fromIndex === -1 || toIndex === -1) return; + const newOrder = arrayMove([...keys], fromIndex, toIndex); + const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); + const keysAtDrop = new Map( + reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), + ); + const assignments = planPinnedReorder({ + orderedIds: newOrder, + keysById: keysAtDrop, + movedId: activeKey, + }); + if (assignments.length === 0) return; + setOptimisticPinnedOrder({ + order: newOrder, + keysAtDrop, + assignedKeys: new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ), + }); + void (async () => { + for (const assignment of assignments) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + const result = await reorderPinnedThread( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ); + if (result._tag === "Failure") { + setOptimisticPinnedOrder(null); + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to reorder pinned threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + } + })(); + return; + } + + if (overSection === "settled") { + if (activeSection !== "settled") { + void (async () => { + if (activeSection === "snoozed") { + await unsnoozeThread(activeThreadRef); + } + const shell = readThreadShell(activeThreadRef) ?? activeThread; + const isWorking = + shell.session?.status === "running" || + shell.session?.status === "starting" || + shell.hasPendingApprovals || + shell.hasPendingUserInput; + if (isWorking) { + const runningTurnId = + shell.session?.status === "running" + ? (shell.session.activeTurnId ?? undefined) + : undefined; + await interruptThreadTurn({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + ...(runningTurnId ? { turnId: runningTurnId } : {}), + }, + }); + let stopped = await waitForSessionStopped(activeThreadRef, 1500); + if (!stopped) { + await stopThreadSession({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + stopped = await waitForSessionStopped(activeThreadRef, 2500); + } + if (!stopped) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: "Active session is still stopping. Please try again in a moment.", + }), + ); + return; + } + } + attemptSettle(activeThreadRef, { force: true }); + })(); + } + return; + } + + if (overSection === "snoozed") { + if (activeSection !== "snoozed") { + const presets = resolveSnoozePresets(new Date(), timestampFormat); + const defaultPreset = presets.find((p) => p.id === "tomorrow") ?? presets[0]; + if (defaultPreset) { + void (async () => { + const shell = readThreadShell(activeThreadRef) ?? activeThread; + const hasBlockingRequest = shell.hasPendingApprovals || shell.hasPendingUserInput; + if (hasBlockingRequest) { + await stopThreadSession({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + await waitForSessionStopped(activeThreadRef, 2500); + } + attemptSnooze(activeThreadRef, defaultPreset, { force: true }); + })(); + } + } + return; + } + + if ( + overSection === "active" && + (activeSection === "settled" || activeSection === "snoozed") + ) { + if (activeSection === "settled") { + attemptUnsettle(activeThreadRef); + } else { + attemptUnsnooze(activeThreadRef); + } + return; + } + + if (activeSection === "pinned" && overSection === "active") { + attemptUnpin(activeThreadRef); + return; + } + + if (overSection === "pinned" && activeSection !== "pinned") { + const reorderable = orderedPinnedThreads.filter((thread) => + reorderablePinnedKeys.has( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ); + const keys = reorderable.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ); + const overIndex = keys.indexOf(overKey); + let orderKey: string | undefined; + if (overIndex !== -1) { + const prevKey = overIndex > 0 ? (reorderable[overIndex - 1]?.pinOrderKey ?? null) : null; + const targetKey = reorderable[overIndex]?.pinOrderKey ?? null; + orderKey = pinOrderKeyBetween(prevKey, targetKey) ?? undefined; + } + void (async () => { + if (activeSection === "snoozed") { + await attemptUnsnooze(activeThreadRef); + } else if (activeSection === "settled") { + await attemptUnsettle(activeThreadRef); + } + const result = await pinThread( + activeThreadRef, + orderKey !== undefined ? { orderKey } : {}, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + return; + } + + if (activeSection === "active" && overSection === "active") { + const firstPinnedKey = orderedPinnedThreads[0]?.pinOrderKey ?? null; + const orderKey = pinOrderKeyBetween(null, firstPinnedKey) ?? undefined; + void (async () => { + const result = await pinThread( + activeThreadRef, + orderKey !== undefined ? { orderKey } : {}, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + } + }, + [ + attemptSettle, + attemptSnooze, + attemptUnpin, + attemptUnsettle, + attemptUnsnooze, + interruptThreadTurn, + stopThreadSession, + orderedPinnedThreads, + pinThread, + pinnedThreads, + reorderPinnedThread, + reorderablePinnedKeys, + searchableThreads, + settledThreads, + snoozedThreads, + timestampFormat, + ], + ); + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const handleMultiSelectContextMenu = useCallback( async (position: { x: number; y: number }) => { @@ -3284,6 +3709,80 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new") ?? (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); + + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: "pinned" | "active" | "snoozed" | "settled", + sortable?: SortablePinnedRowBag, + isOverlay?: boolean, + ) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + return ( <> @@ -3545,262 +4044,211 @@ export default function Sidebar() { {!isSearchingThreads ? ( -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - , - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} - - , - ]; - if (pinnedThreads.length > 0) { - items.push( -
    • { + setActiveDragKey(String(event.active.id)); + }} + onDragEnd={(event: DragEndEvent) => { + setActiveDragKey(null); + handleThreadDragEnd(event); + }} + onDragCancel={() => { + setActiveDragKey(null); + }} + > +
        + {(() => { + const items: ReactNode[] = [ + , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled โ€” out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
      • - -
      • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); + {orderedPinnedThreads.map((thread) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + + {(bag) => renderThreadRow(thread, "pinned", bag)} + + ); + })} + , + ); } - } - if (settledThreads.length > 0) { - items.push( -
      • 0 && activeThreads.length > 0) { + items.push( +
      • , + ); + } + if (activeThreads.length === 0 && activeDragKey !== null) { + items.push( + , + ); + } + for (const thread of activeThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "active", bag)} + , + ); + } + if (snoozedThreads.length > 0 || activeDragKey !== null) { + items.push( + + + , + ); + for (const thread of visibleSnoozedThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "snoozed", bag)} + , + ); + } + if (activeDragKey !== null && visibleSnoozedThreads.length === 0) { + items.push( + + Move to snoozed + , + ); + } + } + if (settledThreads.length > 0 || activeDragKey !== null) { + items.push( + + + , + ); + for (const thread of renderedSettledThreads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + items.push( + + {(bag) => renderThreadRow(thread, "settled", bag)} + , + ); + } + if (activeDragKey !== null) { + items.push( + + Move to settled + , + ); + } + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
      • + -
      • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + +
    • + ) : null} +
    + {typeof document !== "undefined" + ? createPortal( + + {activeDragThread ? ( +
    + {renderThreadRow(activeDragThread, "active", undefined, true)} +
    + ) : null} +
    , + document.body, + ) + : null} +
    ) : null} {!isSearchingThreads && diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 569b4be96e62..36094d0ea595 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -469,9 +469,7 @@ export function useThreadActions() { ); const settleThread = useCallback( - async (target: ScopedThreadRef) => { - // Version skew: never send the command to a server that predates it โ€” - // the raw protocol rejection would read as a random failure. + async (target: ScopedThreadRef, opts?: { force?: boolean | undefined }) => { if (!readEnvironmentSupportsSettlement(target.environmentId)) { return AsyncResult.failure( Cause.fail( @@ -483,10 +481,11 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Settle may only target what effectiveSettled could classify as - // settled: not starting/running sessions, not threads waiting on - // approvals or user input. Anything else would hide live work. - if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { + if ( + !opts?.force && + resolved && + !canSettle(resolved.thread, { now: new Date().toISOString() }) + ) { return AsyncResult.failure( Cause.fail( new ThreadSettleBlockedError({ @@ -612,8 +611,11 @@ export function useThreadActions() { ); const snoozeThread = useCallback( - async (target: ScopedThreadRef, snoozedUntil: string) => { - // Version skew: never send the command to a server that predates it. + async ( + target: ScopedThreadRef, + snoozedUntil: string, + opts?: { force?: boolean | undefined }, + ) => { if (!readEnvironmentSupportsSnooze(target.environmentId)) { return AsyncResult.failure( Cause.fail( @@ -625,10 +627,11 @@ export function useThreadActions() { ); } const resolved = resolveThreadTarget(target); - // Blocked-on-you work and queued turns can't be snoozed away โ€” - // client-side twin of the server invariants so the UI rejects before - // a round trip. - if (resolved && !canSnooze(resolved.thread, { now: new Date().toISOString() })) { + if ( + !opts?.force && + resolved && + !canSnooze(resolved.thread, { now: new Date().toISOString() }) + ) { return AsyncResult.failure( Cause.fail( new ThreadSnoozeBlockedError({ From 1c69b1d261fb490bb92620fdb172b4854fe4f472 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Fri, 14 Aug 2026 21:35:38 -0300 Subject: [PATCH 09/43] fix(web): address review findings on cross-shelf drag and drop - Keep active route thread visible when settled shelf is collapsed. - Prevent active-to-active drop from unexpectedly pinning threads. - Handle unsnooze and session stop failure gracefully with user alerts. - Check queued turn start before settling. - Measure list container width dynamically for drag overlay in portal. - Unify drop targets into DroppableThreadZone and enable auto-scroll. Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/Sidebar.tsx | 119 ++++++++++++++-------------- 1 file changed, 61 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e08c04e5e30d..dbd58b42c1da 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,6 +30,7 @@ import { changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, + hasQueuedTurnStart, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -471,7 +472,11 @@ function DroppableShelfHeader(props: { id: string; className?: string; children: ); } -function DroppableActiveDropZone(props: { id: string; children?: ReactNode }) { +function DroppableThreadZone(props: { + id: string; + className?: string | undefined; + children?: ReactNode; +}) { const { setNodeRef, isOver } = useDroppable({ id: props.id }); return (
  • {props.children ?? "Move to active"} @@ -489,24 +495,6 @@ function DroppableActiveDropZone(props: { id: string; children?: ReactNode }) { ); } -function DroppableSettledBottomZone(props: { id: string; children?: ReactNode }) { - const { setNodeRef, isOver } = useDroppable({ id: props.id }); - return ( -
  • - {props.children ?? "Move to settled"} -
  • - ); -} - const waitForSessionStopped = async (ref: ScopedThreadRef, timeoutMs = 4000): Promise => { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -2242,9 +2230,14 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { - if (!settledShelfExpanded) return []; - return visibleSettledThreads; - }, [settledShelfExpanded, visibleSettledThreads]); + if (settledShelfExpanded) return visibleSettledThreads; + if (routeThreadKey === null) return []; + const routeThread = settledThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, + ); + return routeThread === undefined ? [] : [routeThread]; + }, [routeThreadKey, settledShelfExpanded, settledThreads, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -2334,9 +2327,7 @@ export default function Sidebar() { ), [orderedThreads], ); - // Handlers read these through refs: depending on per-update Map/Set - // identities would give every row a fresh callback prop on each shell - // event and defeat row memoization during streaming. + const listContainerRef = useRef(null); const threadByKeyRef = useRef(threadByKey); threadByKeyRef.current = threadByKey; // handleNewThread is inherently unstable (depends on the projects list); @@ -2976,14 +2967,26 @@ export default function Sidebar() { if (activeSection !== "settled") { void (async () => { if (activeSection === "snoozed") { - await unsnoozeThread(activeThreadRef); + const unsnoozeResult = await unsnoozeThread(activeThreadRef); + if (unsnoozeResult._tag === "Failure" && !isAtomCommandInterrupted(unsnoozeResult)) { + const error = squashAtomCommandFailure(unsnoozeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsnooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } const shell = readThreadShell(activeThreadRef) ?? activeThread; const isWorking = shell.session?.status === "running" || shell.session?.status === "starting" || shell.hasPendingApprovals || - shell.hasPendingUserInput; + shell.hasPendingUserInput || + hasQueuedTurnStart(shell, { now: new Date().toISOString() }); if (isWorking) { const runningTurnId = shell.session?.status === "running" @@ -3034,7 +3037,18 @@ export default function Sidebar() { environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId }, }); - await waitForSessionStopped(activeThreadRef, 2500); + const stopped = await waitForSessionStopped(activeThreadRef, 2500); + if (!stopped) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze thread", + description: + "Active session is still stopping. Please try again in a moment.", + }), + ); + return; + } } attemptSnooze(activeThreadRef, defaultPreset, { force: true }); })(); @@ -3099,27 +3113,6 @@ export default function Sidebar() { })(); return; } - - if (activeSection === "active" && overSection === "active") { - const firstPinnedKey = orderedPinnedThreads[0]?.pinOrderKey ?? null; - const orderKey = pinOrderKeyBetween(null, firstPinnedKey) ?? undefined; - void (async () => { - const result = await pinThread( - activeThreadRef, - orderKey !== undefined ? { orderKey } : {}, - ); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to pin thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - } }, [ attemptSettle, @@ -4044,7 +4037,7 @@ export default function Sidebar() { {!isSearchingThreads ? ( @@ -4052,7 +4045,6 @@ export default function Sidebar() { key="sidebar-threads-dnd" sensors={pinnedDndSensors} collisionDetection={collisionDetectionStrategy} - autoScroll={false} onDragStart={(event: DragStartEvent) => { setActiveDragKey(String(event.active.id)); }} @@ -4064,7 +4056,14 @@ export default function Sidebar() { setActiveDragKey(null); }} > -
      +
        { + listContainerRef.current = el; + attachListAutoAnimateRef(el); + }} + role="list" + className="flex flex-col gap-px" + > {(() => { const items: ReactNode[] = [ , + , ); } for (const thread of activeThreads) { @@ -4162,9 +4161,9 @@ export default function Sidebar() { } if (activeDragKey !== null && visibleSnoozedThreads.length === 0) { items.push( - + Move to snoozed - , + , ); } } @@ -4208,12 +4207,13 @@ export default function Sidebar() { } if (activeDragKey !== null) { items.push( - Move to settled - , + , ); } } @@ -4240,7 +4240,10 @@ export default function Sidebar() { modifiers={[snapCenterToCursor]} > {activeDragThread ? ( -
        +
        {renderThreadRow(activeDragThread, "active", undefined, true)}
        ) : null} From 6f3798a525bc7343e5d5d020d036775e50ffee4c Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Fri, 14 Aug 2026 21:39:51 -0300 Subject: [PATCH 10/43] fix(web): handle queued turns and await unsnooze/unsettle before pinning - Await unsnooze and unsettle operations before issuing pinThread requests. - Interrupt active turns and wait for session stop on snoozed drop targets. - Match drag overlay appearance with activeDragSection. Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/Sidebar.tsx | 57 +++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dbd58b42c1da..332d9dbc524a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3031,13 +3031,32 @@ export default function Sidebar() { if (defaultPreset) { void (async () => { const shell = readThreadShell(activeThreadRef) ?? activeThread; - const hasBlockingRequest = shell.hasPendingApprovals || shell.hasPendingUserInput; - if (hasBlockingRequest) { - await stopThreadSession({ + const isWorking = + shell.session?.status === "running" || + shell.session?.status === "starting" || + shell.hasPendingApprovals || + shell.hasPendingUserInput || + hasQueuedTurnStart(shell, { now: new Date().toISOString() }); + if (isWorking) { + const runningTurnId = + shell.session?.status === "running" + ? (shell.session.activeTurnId ?? undefined) + : undefined; + await interruptThreadTurn({ environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId }, + input: { + threadId: activeThreadRef.threadId, + ...(runningTurnId ? { turnId: runningTurnId } : {}), + }, }); - const stopped = await waitForSessionStopped(activeThreadRef, 2500); + let stopped = await waitForSessionStopped(activeThreadRef, 1500); + if (!stopped) { + await stopThreadSession({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId }, + }); + stopped = await waitForSessionStopped(activeThreadRef, 2500); + } if (!stopped) { toastManager.add( stackedThreadToast({ @@ -3092,9 +3111,31 @@ export default function Sidebar() { } void (async () => { if (activeSection === "snoozed") { - await attemptUnsnooze(activeThreadRef); + const unsnoozeResult = await unsnoozeThread(activeThreadRef); + if (unsnoozeResult._tag === "Failure" && !isAtomCommandInterrupted(unsnoozeResult)) { + const error = squashAtomCommandFailure(unsnoozeResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsnooze thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } else if (activeSection === "settled") { - await attemptUnsettle(activeThreadRef); + const unsettleResult = await unsettleThread(activeThreadRef); + if (unsettleResult._tag === "Failure" && !isAtomCommandInterrupted(unsettleResult)) { + const error = squashAtomCommandFailure(unsettleResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unsettle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } } const result = await pinThread( activeThreadRef, @@ -4244,7 +4285,7 @@ export default function Sidebar() { style={{ width: listContainerRef.current?.offsetWidth ?? undefined }} className="w-[var(--sidebar-width,260px)] max-w-xs cursor-grabbing rounded-lg border border-dashed border-foreground/35 bg-sidebar/95 shadow-2xl backdrop-blur-sm" > - {renderThreadRow(activeDragThread, "active", undefined, true)} + {renderThreadRow(activeDragThread, activeDragSection, undefined, true)}
        ) : null} , From 2ec9b74b0f7b4bdb2e30b0ed4cb0b0ce05b5c5a0 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Fri, 14 Aug 2026 21:57:44 -0300 Subject: [PATCH 11/43] feat(web): add unread notification badge to sidebar trigger - Display a red badge with the unread count on the sidebar trigger when background threads finish responses. - Clear the unread count when opening the corresponding thread. Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/AppSidebarLayout.tsx | 7 +- .../src/components/sidebar/SidebarChrome.tsx | 3 + apps/web/src/components/ui/sidebar.test.tsx | 11 + apps/web/src/components/ui/sidebar.tsx | 18 +- .../useUnreadBackgroundThreadCount.test.ts | 189 ++++++++++++++++++ .../hooks/useUnreadBackgroundThreadCount.ts | 75 +++++++ 6 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts create mode 100644 apps/web/src/hooks/useUnreadBackgroundThreadCount.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a3ba76679689..189f496ef1bb 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -40,6 +40,7 @@ import { useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useUnreadBackgroundThreadCount } from "../hooks/useUnreadBackgroundThreadCount"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; @@ -73,6 +74,7 @@ function SidebarControl() { environmentIdentificationMode === "artwork", ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + const unreadCount = useUnreadBackgroundThreadCount(); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -90,15 +92,11 @@ function SidebarControl() { toggleSidebar(); }; - // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); }, [keybindings, toggleSidebar]); return ( - // The right-side layout controls carry mr-px (border compensation inside - // the panel), so the trigger mirrors it: both clusters sit one extra pixel - // off their edge and the titlebar reads symmetric.
        {backdropVariant ? : null} { expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); }); + it("renders unread badge when unreadCount is greater than zero", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-testid="sidebar-unread-badge"'); + expect(html).toContain("3"); + }); + it("uses shared geometry and icon constraints for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ce6e4cc78ca9..22271de57705 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -318,14 +318,19 @@ function Sidebar({ ); } -function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { +function SidebarTrigger({ + className, + onClick, + unreadCount, + ...props +}: React.ComponentProps & { unreadCount?: number | undefined }) { const { toggleSidebar } = useSidebar(); const isOpen = useSidebarVisibility(); return ( ); diff --git a/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts b/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts new file mode 100644 index 000000000000..51da5fa8cde7 --- /dev/null +++ b/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; + +import { + countUnreadBackgroundThreads, + isThreadUnreadBackground, +} from "./useUnreadBackgroundThreadCount"; + +const envId = EnvironmentId.make("environment-local"); +const projId = ProjectId.make("project-1"); +const providerInstanceId = ProviderInstanceId.make("provider-1"); + +function createMockThread( + id: string, + overrides?: Partial, +): EnvironmentThreadShell { + return { + environmentId: envId, + id: ThreadId.make(id), + projectId: projId, + title: `Thread ${id}`, + modelSelection: { + instanceId: providerInstanceId, + model: "test-model", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-08-14T20:00:00.000Z", + updatedAt: "2026-08-14T20:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +describe("isThreadUnreadBackground", () => { + const sessionStart = Date.parse("2026-08-14T20:00:00.000Z"); + + it("returns false for archived threads", () => { + const thread = createMockThread("thread-1", { + archivedAt: "2026-08-14T20:10:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + expect(isThreadUnreadBackground(thread, undefined, null, sessionStart)).toBe(false); + }); + + it("returns false for the currently open route thread", () => { + const thread = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + expect( + isThreadUnreadBackground(thread, "2026-08-14T20:00:00.000Z", threadKey, sessionStart), + ).toBe(false); + }); + + it("returns true when turn completed after last visited timestamp", () => { + const thread = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + expect( + isThreadUnreadBackground(thread, "2026-08-14T20:05:00.000Z", "other-thread", sessionStart), + ).toBe(true); + }); + + it("returns false when last visited timestamp is newer than turn completion", () => { + const thread = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + expect( + isThreadUnreadBackground(thread, "2026-08-14T20:07:00.000Z", "other-thread", sessionStart), + ).toBe(false); + }); + + it("returns true when turn completed in current session and thread was never visited", () => { + const thread = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + expect(isThreadUnreadBackground(thread, undefined, "other-thread", sessionStart)).toBe(true); + }); + + it("returns false for historical turn completion before session started when unvisited", () => { + const thread = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T19:05:00.000Z", + startedAt: "2026-08-14T19:05:01.000Z", + completedAt: "2026-08-14T19:06:00.000Z", + assistantMessageId: null, + }, + }); + expect(isThreadUnreadBackground(thread, undefined, "other-thread", sessionStart)).toBe(false); + }); +}); + +describe("countUnreadBackgroundThreads", () => { + const sessionStart = Date.parse("2026-08-14T20:00:00.000Z"); + + it("counts only unread background threads", () => { + const thread1 = createMockThread("thread-1", { + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-08-14T20:05:00.000Z", + startedAt: "2026-08-14T20:05:01.000Z", + completedAt: "2026-08-14T20:06:00.000Z", + assistantMessageId: null, + }, + }); + const thread2 = createMockThread("thread-2", { + latestTurn: { + turnId: TurnId.make("turn-2"), + state: "completed", + requestedAt: "2026-08-14T20:10:00.000Z", + startedAt: "2026-08-14T20:10:01.000Z", + completedAt: "2026-08-14T20:11:00.000Z", + assistantMessageId: null, + }, + }); + const thread3 = createMockThread("thread-3"); + + const key1 = scopedThreadKey(scopeThreadRef(thread1.environmentId, thread1.id)); + const key2 = scopedThreadKey(scopeThreadRef(thread2.environmentId, thread2.id)); + + const visits = { + [key1]: "2026-08-14T20:05:00.000Z", + [key2]: "2026-08-14T20:15:00.000Z", + }; + + expect( + countUnreadBackgroundThreads( + [thread1, thread2, thread3], + visits, + "active-thread", + sessionStart, + ), + ).toBe(1); + expect( + countUnreadBackgroundThreads([thread1, thread2, thread3], visits, key1, sessionStart), + ).toBe(0); + }); +}); diff --git a/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts b/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts new file mode 100644 index 000000000000..0eb8bc222a7f --- /dev/null +++ b/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts @@ -0,0 +1,75 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useParams } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { hasUnseenCompletion } from "~/components/Sidebar.logic"; +import { useThreadShells } from "~/state/entities"; +import { resolveThreadRouteTarget } from "~/threadRoutes"; +import { useUiStateStore } from "~/uiStateStore"; + +const APP_SESSION_STARTED_AT_MS = Date.now(); + +export function isThreadUnreadBackground( + thread: EnvironmentThreadShell, + lastVisitedAt: string | undefined, + currentRouteThreadKey: string | null, + sessionStartedAtMs: number = APP_SESSION_STARTED_AT_MS, +): boolean { + if (thread.archivedAt !== null) return false; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (threadKey === currentRouteThreadKey) return false; + + if (hasUnseenCompletion({ ...thread, lastVisitedAt })) { + return true; + } + + if (lastVisitedAt === undefined && thread.latestTurn?.completedAt) { + const completedAtMs = Date.parse(thread.latestTurn.completedAt); + if (!Number.isNaN(completedAtMs) && completedAtMs >= sessionStartedAtMs) { + return true; + } + } + + return false; +} + +export function countUnreadBackgroundThreads( + threads: ReadonlyArray, + threadLastVisitedAtById: Readonly>, + currentRouteThreadKey: string | null, + sessionStartedAtMs: number = APP_SESSION_STARTED_AT_MS, +): number { + let count = 0; + for (const thread of threads) { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = threadLastVisitedAtById[threadKey]; + if ( + isThreadUnreadBackground(thread, lastVisitedAt, currentRouteThreadKey, sessionStartedAtMs) + ) { + count++; + } + } + return count; +} + +export function useUnreadBackgroundThreadCount(): number { + const threads = useThreadShells(); + const threadLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + + const currentRouteThreadKey = useMemo(() => { + if (routeTarget?.kind === "server") { + return scopedThreadKey(routeTarget.threadRef); + } + return null; + }, [routeTarget]); + + return useMemo( + () => countUnreadBackgroundThreads(threads, threadLastVisitedAtById, currentRouteThreadKey), + [threads, threadLastVisitedAtById, currentRouteThreadKey], + ); +} From c1ecf6a10baacf65b5da638347c64932ccdc6c82 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 08:32:02 -0300 Subject: [PATCH 12/43] fix(web): use semantic badge tokens and improve accessible labels - Use bg-destructive token for badge styling. - Mark badge aria-hidden and include unread count in trigger aria-label. - Preserve existing titlebar and capture-listener comments. Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/AppSidebarLayout.tsx | 11 ++++++++++- apps/web/src/components/ui/sidebar.tsx | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 189f496ef1bb..029080daa0d9 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -92,11 +92,15 @@ function SidebarControl() { toggleSidebar(); }; + // Capture before focused editors consume commands such as Mod+B for rich-text formatting. window.addEventListener("keydown", onKeyDown, true); return () => window.removeEventListener("keydown", onKeyDown, true); }, [keybindings, toggleSidebar]); return ( + // The right-side layout controls carry mr-px (border compensation inside + // the panel), so the trigger mirrors it: both clusters sit one extra pixel + // off their edge and the titlebar reads symmetric.
        0 + ? `Toggle main sidebar (${unreadCount} unread)` + : "Toggle main sidebar" + } /> } /> Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + {unreadCount > 0 ? ` ยท ${unreadCount} unread` : ""}
        diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 22271de57705..067c7c344ed7 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -322,10 +322,16 @@ function SidebarTrigger({ className, onClick, unreadCount, + "aria-label": ariaLabel, ...props }: React.ComponentProps & { unreadCount?: number | undefined }) { const { toggleSidebar } = useSidebar(); const isOpen = useSidebarVisibility(); + const resolvedAriaLabel = + ariaLabel ?? + (unreadCount !== undefined && unreadCount > 0 + ? `Toggle Sidebar (${unreadCount} unread)` + : undefined); return ( +
        + + {codexProviders.length === 0 ? ( + + ) : ( + <> +
        +
        +

        + Provider +

        + + {codexProviders.length === 1 + ? "Codex installation and authentication" + : `${codexProviders.length} Codex instances`} + +
        +
        + {codexProviders.map((provider) => ( + + ))} +
        +
        + +
        +
        +

        + Sessions +

        + + {codexSessions.length === 0 + ? "No active sessions" + : `${codexSessions.length} active session${codexSessions.length === 1 ? "" : "s"}`} + +
        + + {codexSessions.length === 0 ? ( + + ) : ( +
        + {codexSessions.map((thread, index) => { + const project = projectByKey.get( + `${thread.environmentId}:${thread.projectId}`, + ); + const cwd = thread.worktreePath ?? project?.workspaceRoot ?? fallbackCwd; + return ( + + ); + })} +
        + )} +
        + + )} + + +
    + + ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 697eb607c4d8..53e73415c3c8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UsageRouteImport } from './routes/usage' +import { Route as StatusRouteImport } from './routes/status' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' @@ -34,6 +35,11 @@ const UsageRoute = UsageRouteImport.update({ path: '/usage', getParentRoute: () => rootRouteImport, } as any) +const StatusRoute = StatusRouteImport.update({ + id: '/status', + path: '/status', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '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/settings', path: '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/settings', @@ -130,6 +136,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '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/settings': typeof SettingsRouteWithChildren + '/status': typeof StatusRoute '/usage': typeof UsageRoute '/pull-requests': typeof ChatPullRequestsRoute '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/connect/callback': typeof ConnectCallbackRoute @@ -149,6 +156,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '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/settings': typeof SettingsRouteWithChildren + '/status': typeof StatusRoute '/usage': typeof UsageRoute '/pull-requests': typeof ChatPullRequestsRoute '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/connect/callback': typeof ConnectCallbackRoute @@ -171,6 +179,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '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/settings': typeof SettingsRouteWithChildren + '/status': typeof StatusRoute '/usage': typeof UsageRoute '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/_chat/pull-requests': typeof ChatPullRequestsRoute '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/connect_/callback': typeof ConnectCallbackRoute @@ -194,6 +203,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '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/settings' + | '/status' | '/usage' | '/pull-requests' | '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/connect/callback' @@ -213,6 +223,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '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/settings' + | '/status' | '/usage' | '/pull-requests' | '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/connect/callback' @@ -234,6 +245,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '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/settings' + | '/status' | '/usage' | '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/_chat/pull-requests' | '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/connect_/callback' @@ -256,6 +268,7 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + StatusRoute: typeof StatusRoute UsageRoute: typeof UsageRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute @@ -270,6 +283,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsageRouteImport parentRoute: typeof rootRouteImport } + '/status': { + id: '/status' + path: '/status' + fullPath: '/status' + preLoaderRoute: typeof StatusRouteImport + parentRoute: typeof rootRouteImport + } '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/settings': { id: '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/settings' path: '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/settings' @@ -446,6 +466,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + StatusRoute: StatusRoute, UsageRoute: UsageRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, diff --git a/apps/web/src/routes/status.tsx b/apps/web/src/routes/status.tsx new file mode 100644 index 000000000000..79980cc21533 --- /dev/null +++ b/apps/web/src/routes/status.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { StatusPage } from "../components/status/StatusPage"; + +export const Route = createFileRoute("/status")({ + component: StatusPage, +}); From 8ac44cb0d90dfaf0299c083abae8dad92af9b458 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Wed, 12 Aug 2026 11:39:25 -0300 Subject: [PATCH 28/43] feat(web): mirror Codex CLI status --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/provider/Layers/CodexProvider.ts | 52 ++- .../provider/Layers/CodexSessionRuntime.ts | 30 ++ apps/server/src/provider/providerSnapshot.ts | 3 + apps/server/src/ws.ts | 16 + .../status/StatusPage.logic.test.ts | 22 +- .../src/components/status/StatusPage.logic.ts | 26 ++ apps/web/src/components/status/StatusPage.tsx | 403 ++++++++++++------ apps/web/src/state/server.ts | 9 + packages/client-runtime/src/state/server.ts | 5 + packages/contracts/src/provider.ts | 15 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/server.ts | 41 ++ 13 files changed, 506 insertions(+), 126 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..459c48813e54 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -31,6 +31,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetCodexStatus]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..fb02446a9a7a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -19,6 +19,7 @@ import type { ServerProviderState, ModelCapabilities, ProviderOptionDescriptor, + ServerProviderCodexStatus, ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; @@ -45,6 +46,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly rateLimits?: CodexSchema.V2GetAccountRateLimitsResponse; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -112,6 +114,47 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) return account.email; } +function mapCodexRateLimitWindow( + window: CodexSchema.V2GetAccountRateLimitsResponse["rateLimits"]["primary"], +) { + if (window === undefined || window === null) return window; + return { + usedPercent: window.usedPercent, + ...(window.resetsAt !== undefined ? { resetsAt: window.resetsAt } : {}), + ...(window.windowDurationMins !== undefined + ? { windowDurationMins: window.windowDurationMins } + : {}), + }; +} + +function mapCodexStatus(snapshot: CodexAppServerProviderSnapshot): ServerProviderCodexStatus { + const account = snapshot.account.account; + const rateLimits = snapshot.rateLimits?.rateLimits; + const primary = rateLimits ? mapCodexRateLimitWindow(rateLimits.primary) : undefined; + const secondary = rateLimits ? mapCodexRateLimitWindow(rateLimits.secondary) : undefined; + + return { + account: + account === undefined || account === null + ? null + : { + type: account.type, + ...(account.type === "chatgpt" + ? { email: account.email, planType: account.planType } + : {}), + }, + ...(rateLimits + ? { + rateLimits: { + ...(rateLimits.limitName !== undefined ? { limitName: rateLimits.limitName } : {}), + ...(primary !== undefined ? { primary } : {}), + ...(secondary !== undefined ? { secondary } : {}), + }, + } + : {}), + }; +} + export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { @@ -395,18 +438,24 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models] = yield* Effect.all( + const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), + accountResponse.account + ? client + .request("account/rateLimits/read", undefined) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + : Effect.succeed(undefined), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + ...(rateLimits ? { rateLimits } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -606,6 +655,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu version: snapshot.version ?? null, status: accountStatus.status, auth: accountStatus.auth, + codexStatus: mapCodexStatus(snapshot), ...(accountStatus.message ? { message: accountStatus.message } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 58c012bd63ea..dd9db4213cbc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -6,9 +6,11 @@ import { ProviderItemId, type ProviderInstanceId, type ProviderApprovalDecision, + type ProviderApprovalPolicy, type ProviderEvent, type ProviderInteractionMode, type ProviderRequestKind, + type ProviderSandboxMode, type ProviderSession, type ProviderTurnStartResult, type ProviderUserInputAnswers, @@ -445,6 +447,33 @@ type CodexThreadOpenResponse = | CodexRpc.ClientRequestResponsesByMethod["thread/start"] | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; +type CodexSessionDetails = NonNullable; + +function providerSandboxMode(sandbox: CodexThreadOpenResponse["sandbox"]): ProviderSandboxMode { + switch (sandbox.type) { + case "readOnly": + return "read-only"; + case "workspaceWrite": + return "workspace-write"; + case "dangerFullAccess": + return "danger-full-access"; + case "externalSandbox": + return "workspace-write"; + } +} + +function codexSessionDetails(opened: CodexThreadOpenResponse): CodexSessionDetails { + return { + providerThreadId: opened.thread.id, + modelProvider: opened.modelProvider, + ...(opened.reasoningEffort ? { reasoningEffort: opened.reasoningEffort } : {}), + approvalPolicy: opened.approvalPolicy as ProviderApprovalPolicy, + sandbox: providerSandboxMode(opened.sandbox), + instructionSources: opened.instructionSources ?? [], + ...(opened.serviceTier ? { serviceTier: opened.serviceTier } : {}), + }; +} + type CodexThreadOpenMethod = "thread/start" | "thread/resume"; interface CodexThreadOpenClient { @@ -1704,6 +1733,7 @@ export const makeCodexSessionRuntime = ( status: "ready", cwd: opened.cwd, model: opened.model, + codex: codexSessionDetails(opened), resumeCursor: { threadId: providerThreadId }, updatedAt: yield* nowIso, } satisfies ProviderSession; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d0..92a5ed12c5d6 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -3,6 +3,7 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderCodexStatus, ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, @@ -50,6 +51,7 @@ export interface ProviderProbeResult { readonly status: Exclude; readonly auth: ServerProviderAuth; readonly message?: string; + readonly codexStatus?: ServerProviderCodexStatus; } export interface ServerProviderPresentation { @@ -240,6 +242,7 @@ export function buildServerProvider(input: { auth: input.probe.auth, checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), + ...(input.probe.codexStatus ? { codexStatus: input.probe.codexStatus } : {}), models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b8..05c7afad1c57 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -79,6 +79,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -381,6 +382,7 @@ const makeWsRpcLayer = ( const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const providerService = yield* Effect.serviceOption(ProviderService.ProviderService); const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); yield* Effect.addFinalizer(() => @@ -1445,6 +1447,20 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetCodexStatus]: (_input) => + observeRpcEffect( + WS_METHODS.serverGetCodexStatus, + Option.match(providerService, { + onNone: () => Effect.succeed({ sessions: [] }), + onSome: (service) => + service.listSessions().pipe( + Effect.map((sessions) => ({ + sessions: sessions.filter((session) => session.provider === "codex"), + })), + ), + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/apps/web/src/components/status/StatusPage.logic.test.ts b/apps/web/src/components/status/StatusPage.logic.test.ts index 451e6a8daae9..d73931d2bf4c 100644 --- a/apps/web/src/components/status/StatusPage.logic.test.ts +++ b/apps/web/src/components/status/StatusPage.logic.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { describeCodexRuntimeMode } from "./StatusPage.logic"; +import { + codexPermissionsLabel, + codexRateLimitWindowLabel, + codexRemainingPercent, + describeCodexRuntimeMode, +} from "./StatusPage.logic"; describe("describeCodexRuntimeMode", () => { it("matches the read-only Codex policy", () => { @@ -32,3 +37,18 @@ describe("describeCodexRuntimeMode", () => { }); }); }); + +describe("Codex status presentation", () => { + it("uses the CLI permission labels", () => { + expect(codexPermissionsLabel("approval-required")).toBe("Read Only"); + expect(codexPermissionsLabel("auto-accept-edits")).toBe("Workspace Write"); + expect(codexPermissionsLabel("full-access")).toBe("Full Access"); + }); + + it("converts used rate-limit percentage into remaining percentage", () => { + expect(codexRemainingPercent(24)).toBe(76); + expect(codexRemainingPercent(-10)).toBe(100); + expect(codexRemainingPercent(120)).toBe(0); + expect(codexRateLimitWindowLabel(10_080)).toBe("Weekly limit"); + }); +}); diff --git a/apps/web/src/components/status/StatusPage.logic.ts b/apps/web/src/components/status/StatusPage.logic.ts index 8f1f25d87da3..16382b06e002 100644 --- a/apps/web/src/components/status/StatusPage.logic.ts +++ b/apps/web/src/components/status/StatusPage.logic.ts @@ -41,6 +41,32 @@ export function describeCodexRuntimeMode(runtimeMode: RuntimeMode): CodexRuntime } } +export function codexPermissionsLabel(runtimeMode: RuntimeMode): string { + switch (runtimeMode) { + case "approval-required": + return "Read Only"; + case "auto-accept-edits": + return "Workspace Write"; + case "auto": + return "Auto"; + case "full-access": + default: + return "Full Access"; + } +} + +export function codexRateLimitWindowLabel(windowDurationMins: number | null | undefined): string { + if (windowDurationMins == null) return "Rate limit"; + if (windowDurationMins >= 10_000) return "Weekly limit"; + if (windowDurationMins >= 1_000) return "Daily limit"; + if (windowDurationMins >= 60) return `${Math.round(windowDurationMins / 60)}h limit`; + return `${windowDurationMins}m limit`; +} + +export function codexRemainingPercent(usedPercent: number): number { + return Math.max(0, Math.min(100, 100 - usedPercent)); +} + export function isCodexSessionStatus(status: OrchestrationSessionStatus): boolean { return status !== "stopped" && status !== "idle"; } diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 8fd6e7753f92..8dd3d1507b95 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -1,10 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { RefreshCwIcon, ShieldCheckIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import type { + ProviderSession, + ServerProvider, + ServerProviderCodexRateLimitWindow, +} from "@t3tools/contracts"; +import { ExternalLinkIcon, RefreshCwIcon, ShieldCheckIcon } from "lucide-react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; -import type { ServerProvider } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { isElectron } from "../../env"; import { @@ -12,9 +17,11 @@ import { formatContextWindowTokens, } from "../../lib/contextWindow"; import { cn } from "../../lib/utils"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; import { useProjects, useThread, useThreadShells } from "../../state/entities"; import { usePrimaryEnvironment, usePrimaryEnvironmentId } from "../../state/environments"; import { + primaryCodexStatusAtom, primaryServerConfigAtom, primaryServerProvidersAtom, serverEnvironment, @@ -28,12 +35,17 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; import { + codexPermissionsLabel, codexProviderStatusLabel, + codexRateLimitWindowLabel, + codexRemainingPercent, describeCodexRuntimeMode, formatStatusTimestamp, isCodexSessionStatus, } from "./StatusPage.logic"; +const CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage"; + function isCodexThread( thread: EnvironmentThreadShell, codexInstanceIds: ReadonlySet, @@ -50,109 +62,232 @@ function sortSessions(left: EnvironmentThreadShell, right: EnvironmentThreadShel return rightUpdatedAt.localeCompare(leftUpdatedAt); } -function providerAuthLabel(provider: ServerProvider): string { - if (provider.auth.status === "authenticated") { - return provider.auth.label ?? provider.auth.type ?? "Authenticated"; +function providerPlanLabel(provider: ServerProvider): string | null { + const planType = provider.codexStatus?.account?.planType; + switch (planType) { + case "free": + return "Free"; + case "go": + return "Go"; + case "plus": + return "Plus"; + case "pro": + return "Pro"; + case "prolite": + return "Pro"; + case "team": + return "Team"; + case "self_serve_business_usage_based": + case "business": + return "Business"; + case "enterprise_cbp_usage_based": + case "enterprise": + return "Enterprise"; + case "edu": + return "Edu"; + case "unknown": + return "ChatGPT"; + default: + return null; } +} + +function providerAccountLabel(provider: ServerProvider): string { + const email = provider.codexStatus?.account?.email ?? provider.auth.email; + const plan = providerPlanLabel(provider); + if (email && plan) return `${email} (${plan})`; + if (email) return email; + if (plan) return plan; if (provider.auth.status === "unauthenticated") return "Not authenticated"; - return "Unknown"; + return provider.auth.label ?? provider.auth.type ?? "Unknown"; +} + +function sessionForProvider(session: ProviderSession, provider: ServerProvider): boolean { + return ( + session.providerInstanceId === provider.instanceId || + (session.providerInstanceId === undefined && session.provider === provider.driver) + ); +} + +function sandboxLabel(value: NonNullable["sandbox"]): string { + switch (value) { + case "read-only": + return "Read Only"; + case "workspace-write": + return "Workspace Write"; + case "danger-full-access": + return "Full Access"; + default: + return "Unknown"; + } } -function StatusMetric({ label, value }: { readonly label: string; readonly value: string }) { +function StatusRow({ label, children }: { readonly label: string; readonly children: ReactNode }) { return ( -
    +
    {label}
    -
    - {value} -
    +
    {children}
    ); } -function ProviderStatusCard({ provider }: { readonly provider: ServerProvider }) { - return ( - -
    -
    -
    -
    -

    {codexProviderStatusLabel(provider)}

    -
    - - {provider.version ? `v${provider.version.replace(/^v/, "")}` : "Version unknown"} - -
    +function formatResetTimestamp(timestamp: number | null | undefined): string | null { + if (timestamp == null) return null; + return formatStatusTimestamp(new Date(timestamp * 1000).toISOString()); +} -
    - - -
    +function RateLimitRow({ + window, + fallbackLabel, +}: { + readonly window: ServerProviderCodexRateLimitWindow; + readonly fallbackLabel: string; +}) { + const remaining = codexRemainingPercent(window.usedPercent); + const reset = formatResetTimestamp(window.resetsAt); + const label = + codexRateLimitWindowLabel(window.windowDurationMins) === "Rate limit" + ? fallbackLabel + : codexRateLimitWindowLabel(window.windowDurationMins); - {provider.message ? ( -

    {provider.message}

    - ) : null} -
    + return ( +
    +
    + {label} + {remaining}% left +
    +
    +
    +
    + {reset ?

    Resets {reset}

    : null} +
    ); } -function SessionStatusCard({ +function CodexStatusCard({ + provider, thread, + session, cwd, contextWindow, }: { - readonly thread: EnvironmentThreadShell; + readonly provider: ServerProvider; + readonly thread: EnvironmentThreadShell | null; + readonly session: ProviderSession | undefined; readonly cwd: string; readonly contextWindow: ReturnType; }) { - const runtimeMode = thread.session?.runtimeMode ?? thread.runtimeMode; + const runtimeMode = thread?.runtimeMode ?? session?.runtimeMode ?? "full-access"; const runtimeStatus = describeCodexRuntimeMode(runtimeMode); - const sessionStatus = thread.session?.status ?? "idle"; + const model = session?.model ?? thread?.modelSelection.model ?? "Unknown"; + const reasoningEffort = + session?.codex?.reasoningEffort ?? + getModelSelectionStringOptionValue(thread?.modelSelection, "reasoningEffort"); + const modelValue = reasoningEffort + ? `${model} (reasoning ${reasoningEffort}, summaries auto)` + : `${model} (summaries auto)`; + const directory = session?.cwd ?? thread?.worktreePath ?? cwd; + const permissions = session?.codex?.sandbox + ? sandboxLabel(session.codex.sandbox) + : codexPermissionsLabel(runtimeMode); + const approvalPolicy = session?.codex?.approvalPolicy ?? runtimeStatus.approvalPolicy; + const instructionSources = session?.codex?.instructionSources ?? []; + const collaborationMode = thread + ? thread.interactionMode === "plan" + ? "Plan" + : "Default" + : "Unknown"; + const sessionId = session?.codex?.providerThreadId ?? "No active session"; const contextValue = contextWindow ? contextWindow.maxTokens == null ? `${formatContextWindowTokens(contextWindow.usedTokens)} used` : `${formatContextWindowTokens(contextWindow.usedTokens)} / ${formatContextWindowTokens(contextWindow.maxTokens)}${contextWindow.usedPercentage === null ? "" : ` (${Math.round(contextWindow.usedPercentage)}%)`}` - : "Not available yet"; + : null; + const rateLimits = provider.codexStatus?.rateLimits; return (
    - -

    {thread.title}

    +
    -

    Codex session ยท {sessionStatus}

    +

    + {provider.displayName ?? "Codex"} ยท {codexProviderStatusLabel(provider)} +

    - - {runtimeMode} + + {provider.version ? `v${provider.version.replace(/^v/, "")}` : "Version unknown"}
    -
    - - - - - - -
    +
    +
    + {modelValue} + {directory} + + {permissions} ({approvalPolicy}) + + + {instructionSources.length > 0 ? ( +
    + {instructionSources.map((source) => ( + {source} + ))} +
    + ) : ( + Not reported by the active session + )} +
    + {providerAccountLabel(provider)} + {collaborationMode} + + {sessionId} + + {contextValue ? {contextValue} : null} +
    +
    + + {rateLimits?.primary || rateLimits?.secondary ? ( +
    +
    +

    Rate limits

    + {rateLimits.limitName ? ( + {rateLimits.limitName} + ) : null} +
    + {rateLimits.primary ? ( + + ) : null} + {rateLimits.secondary ? ( + + ) : null} +
    + ) : null} - {thread.session?.lastError ? ( -

    {thread.session.lastError}

    + {provider.message ? ( +

    {provider.message}

    ) : null}
    ); @@ -183,6 +318,7 @@ export function StatusPage() { const primaryEnvironmentId = usePrimaryEnvironmentId(); const serverConfig = useAtomValue(primaryServerConfigAtom); const providers = useAtomValue(primaryServerProvidersAtom); + const codexStatus = useAtomValue(primaryCodexStatusAtom); const projects = useProjects(); const threadShells = useThreadShells(); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { @@ -226,6 +362,47 @@ export function StatusPage() { () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); + const threadById = useMemo( + () => new Map(codexSessions.map((thread) => [thread.id, thread])), + [codexSessions], + ); + + type StatusEntry = { + readonly provider: ServerProvider; + readonly session: ProviderSession | undefined; + readonly thread: EnvironmentThreadShell | null; + }; + const statusEntries = useMemo>(() => { + const sessions = codexStatus?.sessions ?? []; + const entries: StatusEntry[] = []; + for (const provider of codexProviders) { + const providerSessions = sessions.filter((session) => sessionForProvider(session, provider)); + if (providerSessions.length > 0) { + entries.push( + ...providerSessions.map((session) => ({ + provider, + session, + thread: threadById.get(session.threadId) ?? null, + })), + ); + continue; + } + + const providerThreads = codexSessions.filter( + (thread) => + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId) === + provider.instanceId, + ); + if (providerThreads.length > 0) { + entries.push( + ...providerThreads.map((thread) => ({ provider, session: undefined, thread })), + ); + } else { + entries.push({ provider, session: undefined, thread: null }); + } + } + return entries; + }, [codexProviders, codexSessions, codexStatus?.sessions, threadById]); const refresh = useCallback(async () => { if (!primaryEnvironmentId || isRefreshing) return; @@ -237,6 +414,9 @@ export function StatusPage() { input: { instanceId: provider.instanceId }, }); } + appAtomRegistry.refresh( + serverEnvironment.codexStatus({ environmentId: primaryEnvironmentId, input: {} }), + ); } finally { setIsRefreshing(false); } @@ -245,7 +425,7 @@ export function StatusPage() { const fallbackCwd = serverConfig?.cwd ?? primaryEnvironment?.serverConfig?.cwd ?? "Unknown"; return ( - +
    {!isElectron ? (
    -
    +

    Codex status

    - The current Codex provider and session settings, equivalent to the information - shown by /status. + The live session details reported by Codex app-server, matching the CLI{" "} + /status view.

    diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 1071d8209dfe..fa16df8673bf 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -1,6 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, type EditorId, + type ServerCodexStatus, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -80,6 +81,14 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); +export const primaryCodexStatusAtom = Atom.make((get): ServerCodexStatus | null => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) return null; + return Option.getOrNull( + AsyncResult.value(get(serverEnvironment.codexStatus({ environmentId, input: {} }))), + ); +}).pipe(Atom.withLabel("web-primary-codex-status")); + export const primaryServerKeybindingsAtom = Atom.make( (get): ServerConfig["keybindings"] => get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..9c99adf19e81 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -714,6 +714,11 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), + codexStatus: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:codex-status", + tag: WS_METHODS.serverGetCodexStatus, + staleTimeMs: 5_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index c84ad43c4e78..4d9bead34846 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { @@ -44,6 +45,20 @@ export const ProviderSession = Schema.Struct({ threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), activeTurnId: Schema.optional(TurnId), + /** Exact Codex thread configuration reported by thread/start or thread/resume. */ + codex: Schema.optionalKey( + Schema.Struct({ + providerThreadId: Schema.optional(TrimmedNonEmptyString), + modelProvider: Schema.optional(TrimmedNonEmptyString), + reasoningEffort: Schema.optional(TrimmedNonEmptyString), + approvalPolicy: Schema.optional(ProviderApprovalPolicy), + sandbox: Schema.optional(ProviderSandboxMode), + instructionSources: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + serviceTier: Schema.optional(TrimmedNonEmptyString), + }), + ), createdAt: IsoDateTime, updatedAt: IsoDateTime, lastError: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 115fc8a13114..63afbd30621f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,6 +152,7 @@ import { import { ServerConfigStreamEvent, ServerConfig, + ServerCodexStatus, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -252,6 +253,7 @@ export const WS_METHODS = { // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", + serverGetCodexStatus: "server.getCodexStatus", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", @@ -336,6 +338,12 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsServerGetCodexStatusRpc = Rpc.make(WS_METHODS.serverGetCodexStatus, { + payload: Schema.Struct({}), + success: ServerCodexStatus, + error: EnvironmentAuthorizationError, +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -976,6 +984,7 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, + WsServerGetCodexStatusRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..2ea97cc61b85 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -19,6 +19,7 @@ import { } from "./keybindings.ts"; import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; +import { ProviderSession } from "./provider.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -96,6 +97,39 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +/** + * The Codex app-server exposes these fields as part of its account/rate-limit + * response. Keep this projection provider-agnostic at the transport boundary + * so clients can render the same information as Codex's `/status` without + * depending on the generated app-server protocol package. + */ +export const ServerProviderCodexRateLimitWindow = Schema.Struct({ + usedPercent: Schema.Int, + resetsAt: Schema.optionalKey(Schema.NullOr(Schema.Int)), + windowDurationMins: Schema.optionalKey(Schema.NullOr(Schema.Int)), +}); +export type ServerProviderCodexRateLimitWindow = typeof ServerProviderCodexRateLimitWindow.Type; + +export const ServerProviderCodexStatus = Schema.Struct({ + account: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + type: TrimmedNonEmptyString, + email: Schema.optionalKey(Schema.NullOr(Schema.String)), + planType: Schema.optionalKey(TrimmedNonEmptyString), + }), + ), + ), + rateLimits: Schema.optionalKey( + Schema.Struct({ + limitName: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + primary: Schema.optionalKey(Schema.NullOr(ServerProviderCodexRateLimitWindow)), + secondary: Schema.optionalKey(Schema.NullOr(ServerProviderCodexRateLimitWindow)), + }), + ), +}); +export type ServerProviderCodexStatus = typeof ServerProviderCodexStatus.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -192,6 +226,8 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Live account and rate-limit data returned by Codex app-server. */ + codexStatus: Schema.optionalKey(ServerProviderCodexStatus), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); @@ -592,6 +628,11 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ }); export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; +export const ServerCodexStatus = Schema.Struct({ + sessions: Schema.Array(ProviderSession), +}); +export type ServerCodexStatus = typeof ServerCodexStatus.Type; + export const ServerProviderUpdateInput = Schema.Struct({ provider: ProviderDriverKind, instanceId: Schema.optionalKey(ProviderInstanceId), From 6555daa50c20cc92a8e306ec84b030fd7456da3e Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Wed, 12 Aug 2026 22:13:44 -0300 Subject: [PATCH 29/43] feat(status): add Claude rate limits --- apps/server/src/auth/RpcAuthorization.ts | 1 - .../Layers/ClaudeCapabilitiesProbe.test.ts | 75 +++ .../src/provider/Layers/ClaudeProvider.ts | 86 +++ .../provider/Layers/CodexSessionRuntime.ts | 30 - apps/server/src/provider/providerSnapshot.ts | 3 + apps/server/src/ws.ts | 16 - .../status/StatusPage.logic.test.ts | 6 + .../src/components/status/StatusPage.logic.ts | 75 +++ apps/web/src/components/status/StatusPage.tsx | 554 +++++++----------- apps/web/src/state/server.ts | 9 - packages/client-runtime/src/state/server.ts | 5 - packages/contracts/src/provider.ts | 15 - packages/contracts/src/rpc.ts | 9 - packages/contracts/src/server.ts | 26 +- 14 files changed, 473 insertions(+), 437 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 459c48813e54..36f348d6370a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -31,7 +31,6 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetCodexStatus]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 19907ece8884..ef82b25efcc0 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -10,6 +10,7 @@ import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, isLegacyClaudeModel, + mapClaudeUsageToStatus, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; @@ -54,6 +55,69 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); +it("maps Claude Code plan windows to the status snapshot", () => { + assert.deepEqual( + mapClaudeUsageToStatus( + { + session: { + total_cost_usd: 0, + total_api_duration_ms: 0, + total_duration_ms: 0, + total_lines_added: 0, + total_lines_removed: 0, + model_usage: {}, + }, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: 66.4, + resets_at: "2026-08-12T20:49:59.750101+00:00", + }, + seven_day: { + utilization: 12, + resets_at: "2026-08-17T02:59:59.750124+00:00", + }, + }, + behaviors: { + day: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + week: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + }, + }, + "+50% weekly limits promo through Aug 19 ยท clau.de/cc-50-promo", + ), + { + rateLimits: { + currentSession: { + usedPercent: 66, + resetsAt: "2026-08-12T20:49:59.750101+00:00", + }, + currentWeek: { + usedPercent: 12, + resetsAt: "2026-08-17T02:59:59.750124+00:00", + }, + currentWeekPromo: "+50% weekly limits promo through Aug 19 ยท clau.de/cc-50-promo", + }, + }, + ); +}); + it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { it.effect("serializes strict no-MCP options and still resolves account capabilities", () => Effect.gen(function* () { @@ -88,6 +152,17 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { "const lines = createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", + ' if (message.type === "control_request" && message.request?.subtype === "get_usage") {', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: { rate_limits_available: false, rate_limits: null },", + " },", + ' }) + "\\n");', + " return;", + " }", ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 0e019f003c7a..b1063980abc0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,8 @@ import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, + type ServerProviderClaudeRateLimitWindow, + type ServerProviderClaudeStatus, type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; @@ -22,6 +24,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type SDKControlGetUsageResponse, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, @@ -625,9 +628,85 @@ type ClaudeCapabilitiesProbe = { * the subscription/token fields are absent and auth is external AWS creds. */ readonly apiProvider: string | undefined; + readonly claudeStatus?: ServerProviderClaudeStatus; readonly slashCommands: ReadonlyArray; }; +type ClaudeUsageRateLimitWindow = NonNullable< + NonNullable["five_hour"] +>; + +type ClaudeRateLimitPromoNotice = { + readonly bar?: unknown; + readonly text?: unknown; +}; + +const readClaudeRateLimitPromo = (environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDirectories = [environment.CLAUDE_CONFIG_DIR, environment.HOME].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + + for (const directory of configDirectories) { + const configContents = yield* fs + .readFileString(path.join(directory, ".claude.json")) + .pipe(Effect.option); + if (Option.isNone(configContents)) continue; + + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - optional local Claude cache. + const config = JSON.parse(configContents.value) as { + readonly cachedGrowthBookFeatures?: Record; + }; + const notices = config.cachedGrowthBookFeatures?.tengu_rate_limit_promo_notices; + if (!Array.isArray(notices)) continue; + const notice = notices.find((entry): entry is ClaudeRateLimitPromoNotice => { + if (!entry || typeof entry !== "object") return false; + const candidate = entry as ClaudeRateLimitPromoNotice; + return candidate.bar === "seven_day" && typeof candidate.text === "string"; + }); + if (typeof notice?.text === "string" && notice.text.trim()) return notice.text.trim(); + } catch { + // The promotion is optional; an unavailable local cache must not fail status. + } + } + return undefined; + }); + +function mapClaudeUsageRateLimitWindow( + window: ClaudeUsageRateLimitWindow | null | undefined, +): ServerProviderClaudeRateLimitWindow | undefined { + if (!window || typeof window.utilization !== "number" || !Number.isFinite(window.utilization)) { + return undefined; + } + + return { + usedPercent: Math.max(0, Math.min(100, Math.floor(window.utilization))), + ...(window.resets_at !== null ? { resetsAt: window.resets_at } : {}), + }; +} + +export function mapClaudeUsageToStatus( + usage: SDKControlGetUsageResponse, + promo?: string, +): ServerProviderClaudeStatus | undefined { + if (!usage.rate_limits_available || usage.rate_limits == null) return undefined; + + const currentSession = mapClaudeUsageRateLimitWindow(usage.rate_limits.five_hour); + const currentWeek = mapClaudeUsageRateLimitWindow(usage.rate_limits.seven_day); + if (!currentSession && !currentWeek) return undefined; + + return { + rateLimits: { + ...(currentSession ? { currentSession } : {}), + ...(currentWeek ? { currentWeek } : {}), + ...(currentWeek && promo ? { currentWeekPromo: promo } : {}), + }, + }; +} + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -725,6 +804,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const claudePromo = yield* readClaudeRateLimitPromo(claudeEnvironment); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield โ€” we only need initialization data, not a conversation. @@ -741,6 +821,10 @@ const probeClaudeCapabilities = ( }), }); const init = await q.initializationResult(); + const claudeStatus = await q + .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() + .then((usage) => mapClaudeUsageToStatus(usage, claudePromo)) + .catch(() => undefined); const account = init.account as | { readonly email?: string; @@ -754,6 +838,7 @@ const probeClaudeCapabilities = ( subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, + ...(claudeStatus ? { claudeStatus } : {}), slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -953,6 +1038,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, + ...(capabilities.claudeStatus ? { claudeStatus: capabilities.claudeStatus } : {}), ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index dd9db4213cbc..58c012bd63ea 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -6,11 +6,9 @@ import { ProviderItemId, type ProviderInstanceId, type ProviderApprovalDecision, - type ProviderApprovalPolicy, type ProviderEvent, type ProviderInteractionMode, type ProviderRequestKind, - type ProviderSandboxMode, type ProviderSession, type ProviderTurnStartResult, type ProviderUserInputAnswers, @@ -447,33 +445,6 @@ type CodexThreadOpenResponse = | CodexRpc.ClientRequestResponsesByMethod["thread/start"] | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; -type CodexSessionDetails = NonNullable; - -function providerSandboxMode(sandbox: CodexThreadOpenResponse["sandbox"]): ProviderSandboxMode { - switch (sandbox.type) { - case "readOnly": - return "read-only"; - case "workspaceWrite": - return "workspace-write"; - case "dangerFullAccess": - return "danger-full-access"; - case "externalSandbox": - return "workspace-write"; - } -} - -function codexSessionDetails(opened: CodexThreadOpenResponse): CodexSessionDetails { - return { - providerThreadId: opened.thread.id, - modelProvider: opened.modelProvider, - ...(opened.reasoningEffort ? { reasoningEffort: opened.reasoningEffort } : {}), - approvalPolicy: opened.approvalPolicy as ProviderApprovalPolicy, - sandbox: providerSandboxMode(opened.sandbox), - instructionSources: opened.instructionSources ?? [], - ...(opened.serviceTier ? { serviceTier: opened.serviceTier } : {}), - }; -} - type CodexThreadOpenMethod = "thread/start" | "thread/resume"; interface CodexThreadOpenClient { @@ -1733,7 +1704,6 @@ export const makeCodexSessionRuntime = ( status: "ready", cwd: opened.cwd, model: opened.model, - codex: codexSessionDetails(opened), resumeCursor: { threadId: providerThreadId }, updatedAt: yield* nowIso, } satisfies ProviderSession; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 92a5ed12c5d6..d25c84be4eec 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -2,6 +2,7 @@ import type { ProviderDriverKind, ModelCapabilities, ServerProvider, + ServerProviderClaudeStatus, ServerProviderAuth, ServerProviderCodexStatus, ServerProviderSkill, @@ -52,6 +53,7 @@ export interface ProviderProbeResult { readonly auth: ServerProviderAuth; readonly message?: string; readonly codexStatus?: ServerProviderCodexStatus; + readonly claudeStatus?: ServerProviderClaudeStatus; } export interface ServerProviderPresentation { @@ -243,6 +245,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), ...(input.probe.codexStatus ? { codexStatus: input.probe.codexStatus } : {}), + ...(input.probe.claudeStatus ? { claudeStatus: input.probe.claudeStatus } : {}), models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 05c7afad1c57..56ea24a4a8b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -79,7 +79,6 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; -import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -382,7 +381,6 @@ const makeWsRpcLayer = ( const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const providerService = yield* Effect.serviceOption(ProviderService.ProviderService); const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); yield* Effect.addFinalizer(() => @@ -1447,20 +1445,6 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", }), - [WS_METHODS.serverGetCodexStatus]: (_input) => - observeRpcEffect( - WS_METHODS.serverGetCodexStatus, - Option.match(providerService, { - onNone: () => Effect.succeed({ sessions: [] }), - onSome: (service) => - service.listSessions().pipe( - Effect.map((sessions) => ({ - sessions: sessions.filter((session) => session.provider === "codex"), - })), - ), - }), - { "rpc.aggregate": "server" }, - ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, diff --git a/apps/web/src/components/status/StatusPage.logic.test.ts b/apps/web/src/components/status/StatusPage.logic.test.ts index d73931d2bf4c..bdd83a956d85 100644 --- a/apps/web/src/components/status/StatusPage.logic.test.ts +++ b/apps/web/src/components/status/StatusPage.logic.test.ts @@ -5,6 +5,7 @@ import { codexRateLimitWindowLabel, codexRemainingPercent, describeCodexRuntimeMode, + formatStatusTimestampWithTimeZone, } from "./StatusPage.logic"; describe("describeCodexRuntimeMode", () => { @@ -51,4 +52,9 @@ describe("Codex status presentation", () => { expect(codexRemainingPercent(120)).toBe(0); expect(codexRateLimitWindowLabel(10_080)).toBe("Weekly limit"); }); + + it("adds the local timezone to status timestamps", () => { + expect(formatStatusTimestampWithTimeZone("2026-08-12T19:22:00.000Z")).toMatch(/\(.+\)$/); + expect(formatStatusTimestampWithTimeZone("not-a-date")).toBe("not-a-date"); + }); }); diff --git a/apps/web/src/components/status/StatusPage.logic.ts b/apps/web/src/components/status/StatusPage.logic.ts index 16382b06e002..a0df4626e68c 100644 --- a/apps/web/src/components/status/StatusPage.logic.ts +++ b/apps/web/src/components/status/StatusPage.logic.ts @@ -89,3 +89,78 @@ export function formatStatusTimestamp(value: string): string { timeStyle: "short", }); } + +const BRASILIA_TIME_ZONES = new Set([ + "America/Araguaina", + "America/Bahia", + "America/Belem", + "America/Fortaleza", + "America/Maceio", + "America/Recife", + "America/Sao_Paulo", + "America/Santarem", +]); + +function formatUtcOffset(date: Date): string { + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absoluteMinutes = Math.abs(offsetMinutes); + const hours = Math.floor(absoluteMinutes / 60); + const minutes = absoluteMinutes % 60; + return `${sign}${hours}${minutes === 0 ? "" : `:${minutes.toString().padStart(2, "0")}`}`; +} + +function formatStatusTimeZone(date: Date): string { + const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; + const offset = formatUtcOffset(date); + if (BRASILIA_TIME_ZONES.has(timeZone) && offset === "-3") return "BRT-3"; + + const zoneName = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value; + if (!zoneName) return `UTC${offset}`; + if (/^[A-Za-z]{2,6}$/.test(zoneName)) return `${zoneName}${offset}`; + return zoneName; +} + +export function formatStatusTimestampWithTimeZone(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return `${formatStatusTimestamp(value)} (${formatStatusTimeZone(date)})`; +} + +function formatClaudeClock(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + }) + .formatToParts(date) + .reduce>((result, part) => { + if (part.type !== "literal") result[part.type] = part.value; + return result; + }, {}); + const minute = parts.minute === "00" ? "" : `:${parts.minute}`; + return `${parts.hour ?? ""}${minute}${(parts.dayPeriod ?? "").toLowerCase()}`; +} + +/** Format Claude Code's compact reset labels using the browser's local zone. */ +export function formatClaudeResetTimestamp(value: string, alwaysShowDate = false): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const clock = formatClaudeClock(date, timeZone); + const hoursUntilReset = (date.getTime() - Date.now()) / (60 * 60 * 1000); + const label = + alwaysShowDate || hoursUntilReset > 24 + ? `${new Intl.DateTimeFormat("en-US", { + timeZone, + month: "short", + day: "numeric", + }).format(date)}, ${clock}` + : clock; + + return `${label} (${timeZone})`; +} diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 8dd3d1507b95..663355ab1201 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -1,31 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { - ProviderSession, ServerProvider, + ServerProviderClaudeRateLimitWindow, ServerProviderCodexRateLimitWindow, } from "@t3tools/contracts"; import { ExternalLinkIcon, RefreshCwIcon, ShieldCheckIcon } from "lucide-react"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; - -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { useCallback, useMemo, useState } from "react"; import { isElectron } from "../../env"; -import { - deriveLatestContextWindowSnapshot, - formatContextWindowTokens, -} from "../../lib/contextWindow"; import { cn } from "../../lib/utils"; -import { appAtomRegistry } from "../../rpc/atomRegistry"; -import { useProjects, useThread, useThreadShells } from "../../state/entities"; -import { usePrimaryEnvironment, usePrimaryEnvironmentId } from "../../state/environments"; -import { - primaryCodexStatusAtom, - primaryServerConfigAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; +import { usePrimaryEnvironmentId } from "../../state/environments"; +import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; @@ -35,107 +20,40 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from ".. import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; import { - codexPermissionsLabel, - codexProviderStatusLabel, codexRateLimitWindowLabel, codexRemainingPercent, - describeCodexRuntimeMode, - formatStatusTimestamp, - isCodexSessionStatus, + formatClaudeResetTimestamp, + formatStatusTimestampWithTimeZone, } from "./StatusPage.logic"; const CODEX_USAGE_URL = "https://chatgpt.com/codex/settings/usage"; +const CLAUDE_USAGE_URL = "https://claude.ai/settings/usage?from=cc_cli_limit_message"; -function isCodexThread( - thread: EnvironmentThreadShell, - codexInstanceIds: ReadonlySet, -): boolean { - return ( - thread.session?.providerName === "codex" || - codexInstanceIds.has(thread.session?.providerInstanceId ?? thread.modelSelection.instanceId) - ); -} - -function sortSessions(left: EnvironmentThreadShell, right: EnvironmentThreadShell): number { - const leftUpdatedAt = left.session?.updatedAt ?? left.updatedAt; - const rightUpdatedAt = right.session?.updatedAt ?? right.updatedAt; - return rightUpdatedAt.localeCompare(leftUpdatedAt); -} - -function providerPlanLabel(provider: ServerProvider): string | null { - const planType = provider.codexStatus?.account?.planType; - switch (planType) { - case "free": - return "Free"; - case "go": - return "Go"; - case "plus": - return "Plus"; - case "pro": - return "Pro"; - case "prolite": - return "Pro"; - case "team": - return "Team"; - case "self_serve_business_usage_based": - case "business": - return "Business"; - case "enterprise_cbp_usage_based": - case "enterprise": - return "Enterprise"; - case "edu": - return "Edu"; - case "unknown": - return "ChatGPT"; - default: - return null; - } -} - -function providerAccountLabel(provider: ServerProvider): string { - const email = provider.codexStatus?.account?.email ?? provider.auth.email; - const plan = providerPlanLabel(provider); - if (email && plan) return `${email} (${plan})`; - if (email) return email; - if (plan) return plan; - if (provider.auth.status === "unauthenticated") return "Not authenticated"; - return provider.auth.label ?? provider.auth.type ?? "Unknown"; -} - -function sessionForProvider(session: ProviderSession, provider: ServerProvider): boolean { - return ( - session.providerInstanceId === provider.instanceId || - (session.providerInstanceId === undefined && session.provider === provider.driver) - ); -} - -function sandboxLabel(value: NonNullable["sandbox"]): string { - switch (value) { - case "read-only": - return "Read Only"; - case "workspace-write": - return "Workspace Write"; - case "danger-full-access": - return "Full Access"; - default: - return "Unknown"; - } +function formatResetTimestamp(timestamp: number | null | undefined): string | null { + if (timestamp == null) return null; + return formatStatusTimestampWithTimeZone(new Date(timestamp * 1000).toISOString()); } -function StatusRow({ label, children }: { readonly label: string; readonly children: ReactNode }) { +function StatusEmptyState({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { return ( -
    -
    {label}
    -
    {children}
    -
    + + + + + + {title} + {description} + + ); } -function formatResetTimestamp(timestamp: number | null | undefined): string | null { - if (timestamp == null) return null; - return formatStatusTimestamp(new Date(timestamp * 1000).toISOString()); -} - function RateLimitRow({ window, fallbackLabel, @@ -145,10 +63,8 @@ function RateLimitRow({ }) { const remaining = codexRemainingPercent(window.usedPercent); const reset = formatResetTimestamp(window.resetsAt); - const label = - codexRateLimitWindowLabel(window.windowDurationMins) === "Rate limit" - ? fallbackLabel - : codexRateLimitWindowLabel(window.windowDurationMins); + const durationLabel = codexRateLimitWindowLabel(window.windowDurationMins); + const label = durationLabel === "Rate limit" ? fallbackLabel : durationLabel; return (
    @@ -174,109 +90,71 @@ function RateLimitRow({ ); } -function CodexStatusCard({ +function ClaudeRateLimitRow({ + label, + window, +}: { + readonly label: string; + readonly window: ServerProviderClaudeRateLimitWindow; +}) { + const used = Math.max(0, Math.min(100, window.usedPercent)); + const reset = window.resetsAt + ? formatClaudeResetTimestamp(window.resetsAt, label === "Weekly limit") + : null; + + return ( +
    +
    + {label} + {used}% used +
    +
    +
    +
    + {reset ?

    Resets {reset}

    : null} +
    + ); +} + +function CodexRateLimitsCard({ provider, - thread, - session, - cwd, - contextWindow, + showProviderLabel, }: { readonly provider: ServerProvider; - readonly thread: EnvironmentThreadShell | null; - readonly session: ProviderSession | undefined; - readonly cwd: string; - readonly contextWindow: ReturnType; + readonly showProviderLabel: boolean; }) { - const runtimeMode = thread?.runtimeMode ?? session?.runtimeMode ?? "full-access"; - const runtimeStatus = describeCodexRuntimeMode(runtimeMode); - const model = session?.model ?? thread?.modelSelection.model ?? "Unknown"; - const reasoningEffort = - session?.codex?.reasoningEffort ?? - getModelSelectionStringOptionValue(thread?.modelSelection, "reasoningEffort"); - const modelValue = reasoningEffort - ? `${model} (reasoning ${reasoningEffort}, summaries auto)` - : `${model} (summaries auto)`; - const directory = session?.cwd ?? thread?.worktreePath ?? cwd; - const permissions = session?.codex?.sandbox - ? sandboxLabel(session.codex.sandbox) - : codexPermissionsLabel(runtimeMode); - const approvalPolicy = session?.codex?.approvalPolicy ?? runtimeStatus.approvalPolicy; - const instructionSources = session?.codex?.instructionSources ?? []; - const collaborationMode = thread - ? thread.interactionMode === "plan" - ? "Plan" - : "Default" - : "Unknown"; - const sessionId = session?.codex?.providerThreadId ?? "No active session"; - const contextValue = contextWindow - ? contextWindow.maxTokens == null - ? `${formatContextWindowTokens(contextWindow.usedTokens)} used` - : `${formatContextWindowTokens(contextWindow.usedTokens)} / ${formatContextWindowTokens(contextWindow.maxTokens)}${contextWindow.usedPercentage === null ? "" : ` (${Math.round(contextWindow.usedPercentage)}%)`}` - : null; const rateLimits = provider.codexStatus?.rateLimits; return (
    -
    -
    -

    - {provider.displayName ?? "Codex"} ยท {codexProviderStatusLabel(provider)} -

    + {showProviderLabel ? ( +

    + {provider.displayName ?? "Codex"} +

    + ) : null}
    - - {provider.version ? `v${provider.version.replace(/^v/, "")}` : "Version unknown"} + + Updated {formatStatusTimestampWithTimeZone(provider.checkedAt)}
    -
    -
    - {modelValue} - {directory} - - {permissions} ({approvalPolicy}) - - - {instructionSources.length > 0 ? ( -
    - {instructionSources.map((source) => ( - {source} - ))} -
    - ) : ( - Not reported by the active session - )} -
    - {providerAccountLabel(provider)} - {collaborationMode} - - {sessionId} - - {contextValue ? {contextValue} : null} -
    -
    - {rateLimits?.primary || rateLimits?.secondary ? (
    -
    -

    Rate limits

    - {rateLimits.limitName ? ( - {rateLimits.limitName} - ) : null} -
    + {rateLimits.limitName ? ( + {rateLimits.limitName} + ) : null} {rateLimits.primary ? ( ) : null} @@ -284,43 +162,63 @@ function CodexStatusCard({ ) : null}
    - ) : null} - - {provider.message ? ( -

    {provider.message}

    - ) : null} + ) : ( +

    + Rate limit data is not available yet. Click Update to try again. +

    + )}
    ); } -function StatusEmptyState({ - title, - description, +function ClaudeRateLimitsCard({ + provider, + showProviderLabel, }: { - readonly title: string; - readonly description: string; + readonly provider: ServerProvider; + readonly showProviderLabel: boolean; }) { + const rateLimits = provider.claudeStatus?.rateLimits; + return ( - - - - - - {title} - {description} - - + +
    +
    + {showProviderLabel ? ( +

    + {provider.displayName ?? "Claude"} +

    + ) : null} +
    + + Updated {formatStatusTimestampWithTimeZone(provider.checkedAt)} + +
    + + {rateLimits?.currentSession || rateLimits?.currentWeek ? ( +
    + {rateLimits.currentSession ? ( + + ) : null} + {rateLimits.currentWeek ? ( + + ) : null} + {rateLimits.currentWeekPromo ? ( +

    {rateLimits.currentWeekPromo}

    + ) : null} +
    + ) : ( +

    + Claude rate limit data is not available yet. Click Update to try again. +

    + )} +
    ); } export function StatusPage() { - const primaryEnvironment = usePrimaryEnvironment(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const serverConfig = useAtomValue(primaryServerConfigAtom); const providers = useAtomValue(primaryServerProvidersAtom); - const codexStatus = useAtomValue(primaryCodexStatusAtom); - const projects = useProjects(); - const threadShells = useThreadShells(); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); @@ -330,99 +228,33 @@ export function StatusPage() { () => providers.filter((provider) => provider.driver === "codex"), [providers], ); - const codexInstanceIds = useMemo( - () => new Set(codexProviders.map((provider) => provider.instanceId)), - [codexProviders], - ); - const codexSessions = useMemo( - () => - threadShells - .filter( - (thread) => - thread.environmentId === primaryEnvironmentId && - thread.session !== null && - isCodexSessionStatus(thread.session.status) && - isCodexThread(thread, codexInstanceIds), - ) - .toSorted(sortSessions), - [codexInstanceIds, primaryEnvironmentId, threadShells], + const claudeProviders = useMemo( + () => providers.filter((provider) => provider.driver === "claudeAgent"), + [providers], ); - const inspectedThread = codexSessions[0] ?? null; - const inspectedThreadRef = useMemo( + const statusProviders = useMemo( () => - inspectedThread ? scopeThreadRef(inspectedThread.environmentId, inspectedThread.id) : null, - [inspectedThread], - ); - const inspectedThreadDetail = useThread(inspectedThreadRef); - const contextWindow = useMemo( - () => deriveLatestContextWindowSnapshot(inspectedThreadDetail?.activities ?? []), - [inspectedThreadDetail?.activities], - ); - const projectByKey = useMemo( - () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), - [projects], - ); - const threadById = useMemo( - () => new Map(codexSessions.map((thread) => [thread.id, thread])), - [codexSessions], + providers.filter( + (provider) => provider.driver === "codex" || provider.driver === "claudeAgent", + ), + [providers], ); - - type StatusEntry = { - readonly provider: ServerProvider; - readonly session: ProviderSession | undefined; - readonly thread: EnvironmentThreadShell | null; - }; - const statusEntries = useMemo>(() => { - const sessions = codexStatus?.sessions ?? []; - const entries: StatusEntry[] = []; - for (const provider of codexProviders) { - const providerSessions = sessions.filter((session) => sessionForProvider(session, provider)); - if (providerSessions.length > 0) { - entries.push( - ...providerSessions.map((session) => ({ - provider, - session, - thread: threadById.get(session.threadId) ?? null, - })), - ); - continue; - } - - const providerThreads = codexSessions.filter( - (thread) => - (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId) === - provider.instanceId, - ); - if (providerThreads.length > 0) { - entries.push( - ...providerThreads.map((thread) => ({ provider, session: undefined, thread })), - ); - } else { - entries.push({ provider, session: undefined, thread: null }); - } - } - return entries; - }, [codexProviders, codexSessions, codexStatus?.sessions, threadById]); - const refresh = useCallback(async () => { if (!primaryEnvironmentId || isRefreshing) return; setIsRefreshing(true); try { - for (const provider of codexProviders) { - await refreshProviders({ - environmentId: primaryEnvironmentId, - input: { instanceId: provider.instanceId }, - }); - } - appAtomRegistry.refresh( - serverEnvironment.codexStatus({ environmentId: primaryEnvironmentId, input: {} }), + await Promise.all( + statusProviders.map((provider) => + refreshProviders({ + environmentId: primaryEnvironmentId, + input: { instanceId: provider.instanceId }, + }), + ), ); } finally { setIsRefreshing(false); } - }, [codexProviders, isRefreshing, primaryEnvironmentId, refreshProviders]); - - const fallbackCwd = serverConfig?.cwd ?? primaryEnvironment?.serverConfig?.cwd ?? "Unknown"; + }, [isRefreshing, primaryEnvironmentId, refreshProviders, statusProviders]); return ( @@ -452,64 +284,94 @@ export function StatusPage() { )} -
    +
    -

    Codex status

    +

    Status

    - The live session details reported by Codex app-server, matching the CLI{" "} - /status view. + Account health and usage across your AI providers.

    - {codexProviders.length === 0 ? ( - - ) : ( - <> +
    +
    +

    Codex

    +

    + Current rate-limit windows and remaining credits. +

    +
    + {codexProviders.length === 0 ? ( + + ) : (
    - {statusEntries.map(({ provider, session, thread }) => { - const project = thread - ? projectByKey.get(`${thread.environmentId}:${thread.projectId}`) - : undefined; - const cwd = - session?.cwd ?? thread?.worktreePath ?? project?.workspaceRoot ?? fallbackCwd; - return ( - - ); - })} + {codexProviders.map((provider) => ( + 1} + /> + ))}
    + )} - - View up-to-date rate limits and credits - - - - )} + + View up-to-date rate limits and credits + + +
    + +
    +
    +

    Claude

    +

    + Current rate-limit windows and remaining credits. +

    +
    + {claudeProviders.length === 0 ? ( + + ) : ( +
    + {claudeProviders.map((provider) => ( + 1} + /> + ))} +
    + )} + + + View up-to-date rate limits and credits + + +
    diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index fa16df8673bf..1071d8209dfe 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -1,7 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, type EditorId, - type ServerCodexStatus, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, @@ -81,14 +80,6 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); -export const primaryCodexStatusAtom = Atom.make((get): ServerCodexStatus | null => { - const environmentId = get(primaryEnvironmentIdAtom); - if (environmentId === null) return null; - return Option.getOrNull( - AsyncResult.value(get(serverEnvironment.codexStatus({ environmentId, input: {} }))), - ); -}).pipe(Atom.withLabel("web-primary-codex-status")); - export const primaryServerKeybindingsAtom = Atom.make( (get): ServerConfig["keybindings"] => get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 9c99adf19e81..f579453c27fc 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -714,11 +714,6 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), - codexStatus: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:server:codex-status", - tag: WS_METHODS.serverGetCodexStatus, - staleTimeMs: 5_000, - }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 4d9bead34846..c84ad43c4e78 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,4 +1,3 @@ -import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { @@ -45,20 +44,6 @@ export const ProviderSession = Schema.Struct({ threadId: ThreadId, resumeCursor: Schema.optional(Schema.Unknown), activeTurnId: Schema.optional(TurnId), - /** Exact Codex thread configuration reported by thread/start or thread/resume. */ - codex: Schema.optionalKey( - Schema.Struct({ - providerThreadId: Schema.optional(TrimmedNonEmptyString), - modelProvider: Schema.optional(TrimmedNonEmptyString), - reasoningEffort: Schema.optional(TrimmedNonEmptyString), - approvalPolicy: Schema.optional(ProviderApprovalPolicy), - sandbox: Schema.optional(ProviderSandboxMode), - instructionSources: Schema.Array(TrimmedNonEmptyString).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - ), - serviceTier: Schema.optional(TrimmedNonEmptyString), - }), - ), createdAt: IsoDateTime, updatedAt: IsoDateTime, lastError: Schema.optional(TrimmedNonEmptyString), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 63afbd30621f..115fc8a13114 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -152,7 +152,6 @@ import { import { ServerConfigStreamEvent, ServerConfig, - ServerCodexStatus, ServerProviderUpdateError, ServerProviderUpdateInput, ServerLifecycleStreamEvent, @@ -253,7 +252,6 @@ export const WS_METHODS = { // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", - serverGetCodexStatus: "server.getCodexStatus", serverRefreshProviders: "server.refreshProviders", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", @@ -338,12 +336,6 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerGetCodexStatusRpc = Rpc.make(WS_METHODS.serverGetCodexStatus, { - payload: Schema.Struct({}), - success: ServerCodexStatus, - error: EnvironmentAuthorizationError, -}); - export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -984,7 +976,6 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, - WsServerGetCodexStatusRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 2ea97cc61b85..0061d439e023 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -19,7 +19,6 @@ import { } from "./keybindings.ts"; import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; -import { ProviderSession } from "./provider.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -130,6 +129,24 @@ export const ServerProviderCodexStatus = Schema.Struct({ }); export type ServerProviderCodexStatus = typeof ServerProviderCodexStatus.Type; +/** Live Claude Code plan rate-limit windows returned by the Agent SDK. */ +export const ServerProviderClaudeRateLimitWindow = Schema.Struct({ + usedPercent: Schema.Int, + resetsAt: Schema.optionalKey(Schema.NullOr(IsoDateTime)), +}); +export type ServerProviderClaudeRateLimitWindow = typeof ServerProviderClaudeRateLimitWindow.Type; + +export const ServerProviderClaudeStatus = Schema.Struct({ + rateLimits: Schema.optionalKey( + Schema.Struct({ + currentSession: Schema.optionalKey(Schema.NullOr(ServerProviderClaudeRateLimitWindow)), + currentWeek: Schema.optionalKey(Schema.NullOr(ServerProviderClaudeRateLimitWindow)), + currentWeekPromo: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + }), + ), +}); +export type ServerProviderClaudeStatus = typeof ServerProviderClaudeStatus.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -228,6 +245,8 @@ export const ServerProvider = Schema.Struct({ skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), /** Live account and rate-limit data returned by Codex app-server. */ codexStatus: Schema.optionalKey(ServerProviderCodexStatus), + /** Live plan rate-limit data returned by Claude Code's Agent SDK. */ + claudeStatus: Schema.optionalKey(ServerProviderClaudeStatus), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); @@ -628,11 +647,6 @@ export const ServerProviderUpdatedPayload = Schema.Struct({ }); export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; -export const ServerCodexStatus = Schema.Struct({ - sessions: Schema.Array(ProviderSession), -}); -export type ServerCodexStatus = typeof ServerCodexStatus.Type; - export const ServerProviderUpdateInput = Schema.Struct({ provider: ProviderDriverKind, instanceId: Schema.optionalKey(ProviderInstanceId), From 42a37e949b53da54ab3c80346e88ed9d58716f5d Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Thu, 13 Aug 2026 08:16:42 -0300 Subject: [PATCH 30/43] fix(status): address review feedback --- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../Layers/ClaudeCapabilitiesProbe.test.ts | 54 +++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 2 +- .../src/provider/Layers/CodexProvider.test.ts | 54 +++++++++++++++++++ .../src/provider/Layers/CodexProvider.ts | 4 +- .../makeManagedServerProvider.test.ts | 34 ++++++++++++ .../src/provider/makeManagedServerProvider.ts | 3 ++ .../status/StatusPage.logic.test.ts | 11 ++++ .../src/components/status/StatusPage.logic.ts | 4 +- apps/web/src/components/status/StatusPage.tsx | 38 +++++++++---- 10 files changed, 193 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..259e37c194f7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -184,6 +184,7 @@ export const ClaudeDriver: ProviderDriver = { initialSnapshot: (settings) => makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, + beforeRefresh: Cache.invalidate(capabilitiesProbeCache, capabilitiesCacheKey), enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ef82b25efcc0..fc992b7224e7 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -118,6 +118,60 @@ it("maps Claude Code plan windows to the status snapshot", () => { ); }); +it("omits a Claude reset timestamp when the SDK does not provide one", () => { + const usage = { + session: { + total_cost_usd: 0, + total_api_duration_ms: 0, + total_duration_ms: 0, + total_lines_added: 0, + total_lines_removed: 0, + model_usage: {}, + }, + subscription_type: "pro", + rate_limits_available: true, + rate_limits: { + five_hour: { + utilization: 10, + // Older SDK responses can omit this field even though the current + // generated type declares it as required. + resets_at: undefined as unknown as string, + }, + seven_day: { + utilization: 20, + resets_at: null, + }, + }, + behaviors: { + day: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + week: { + request_count: 0, + session_count: 0, + behaviors: [], + agents: [], + skills: [], + plugins: [], + mcp_servers: [], + }, + }, + }; + + assert.deepEqual(mapClaudeUsageToStatus(usage), { + rateLimits: { + currentSession: { usedPercent: 10 }, + currentWeek: { usedPercent: 20 }, + }, + }); +}); + it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { it.effect("serializes strict no-MCP options and still resolves account capabilities", () => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b1063980abc0..098d4a969ab0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -684,7 +684,7 @@ function mapClaudeUsageRateLimitWindow( return { usedPercent: Math.max(0, Math.min(100, Math.floor(window.utilization))), - ...(window.resets_at !== null ? { resetsAt: window.resets_at } : {}), + ...(window.resets_at != null ? { resetsAt: window.resets_at } : {}), }; } diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 26e77f82a79e..5b8efffd03aa 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -3,7 +3,9 @@ import { assert, it } from "@effect/vitest"; import { applyPreferredCodexDefaultModel, isLegacyCodexModel, + mapCodexStatus, mapCodexModelCapabilities, + type CodexAppServerProviderSnapshot, } from "./CodexProvider.ts"; it("keeps only the GPT-5.6 Codex family out of legacy models", () => { @@ -21,6 +23,58 @@ it("keeps only the GPT-5.6 Codex family out of legacy models", () => { ); }); +it("projects Codex account and rate-limit snapshots into provider status", () => { + const snapshot = { + account: { + account: { + type: "chatgpt", + email: "dev@example.com", + planType: "plus", + }, + requiresOpenaiAuth: false, + }, + rateLimits: { + rateLimits: { + limitName: "codex", + primary: { + usedPercent: 24, + resetsAt: 1_781_654_400, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 12, + resetsAt: null, + windowDurationMins: null, + }, + }, + }, + version: "1.0.0", + models: [], + skills: [], + } satisfies CodexAppServerProviderSnapshot; + + assert.deepStrictEqual(mapCodexStatus(snapshot), { + account: { + type: "chatgpt", + email: "dev@example.com", + planType: "plus", + }, + rateLimits: { + limitName: "codex", + primary: { + usedPercent: 24, + resetsAt: 1_781_654_400, + windowDurationMins: 300, + }, + secondary: { + usedPercent: 12, + resetsAt: null, + windowDurationMins: null, + }, + }, + }); +}); + it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index fb02446a9a7a..c697f1baf2ca 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -127,7 +127,9 @@ function mapCodexRateLimitWindow( }; } -function mapCodexStatus(snapshot: CodexAppServerProviderSnapshot): ServerProviderCodexStatus { +export function mapCodexStatus( + snapshot: CodexAppServerProviderSnapshot, +): ServerProviderCodexStatus { const account = snapshot.account.account; const rateLimits = snapshot.rateLimits?.rateLimits; const primary = rateLimits ? mapCodexRateLimitWindow(rateLimits.primary) : undefined; diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 5bfd3e14cfd7..b33d67a7de50 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -192,6 +192,40 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("runs the refresh hook before an explicit provider refresh", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Ref.make>([]); + const initialCheckDone = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Ref.update( + events, + (previous) => [...previous, "check"] as Array<"beforeRefresh" | "check">, + ).pipe( + Effect.tap(() => Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore)), + Effect.as(refreshedSnapshot), + ), + beforeRefresh: Ref.update( + events, + (previous) => [...previous, "beforeRefresh"] as Array<"beforeRefresh" | "check">, + ), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(initialCheckDone); + yield* Ref.set(events, []); + yield* provider.refresh; + + assert.deepStrictEqual(yield* Ref.get(events), ["beforeRefresh", "check"]); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index d2b6b52e8f1c..ae8ec4138e0b 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -33,6 +33,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + /** Run immediately before a manual or periodic refresh check. */ + readonly beforeRefresh?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -143,6 +145,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { + yield* input.beforeRefresh ?? Effect.void; const nextSettings = yield* input.getSettings; return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); diff --git a/apps/web/src/components/status/StatusPage.logic.test.ts b/apps/web/src/components/status/StatusPage.logic.test.ts index bdd83a956d85..eaeae7bd2ae5 100644 --- a/apps/web/src/components/status/StatusPage.logic.test.ts +++ b/apps/web/src/components/status/StatusPage.logic.test.ts @@ -6,6 +6,7 @@ import { codexRemainingPercent, describeCodexRuntimeMode, formatStatusTimestampWithTimeZone, + isCodexSessionStatus, } from "./StatusPage.logic"; describe("describeCodexRuntimeMode", () => { @@ -53,6 +54,16 @@ describe("Codex status presentation", () => { expect(codexRateLimitWindowLabel(10_080)).toBe("Weekly limit"); }); + it("does not count terminal Codex sessions as active", () => { + expect(isCodexSessionStatus("starting")).toBe(true); + expect(isCodexSessionStatus("running")).toBe(true); + expect(isCodexSessionStatus("ready")).toBe(true); + expect(isCodexSessionStatus("idle")).toBe(false); + expect(isCodexSessionStatus("interrupted")).toBe(false); + expect(isCodexSessionStatus("stopped")).toBe(false); + expect(isCodexSessionStatus("error")).toBe(false); + }); + it("adds the local timezone to status timestamps", () => { expect(formatStatusTimestampWithTimeZone("2026-08-12T19:22:00.000Z")).toMatch(/\(.+\)$/); expect(formatStatusTimestampWithTimeZone("not-a-date")).toBe("not-a-date"); diff --git a/apps/web/src/components/status/StatusPage.logic.ts b/apps/web/src/components/status/StatusPage.logic.ts index a0df4626e68c..e0364e072093 100644 --- a/apps/web/src/components/status/StatusPage.logic.ts +++ b/apps/web/src/components/status/StatusPage.logic.ts @@ -68,7 +68,9 @@ export function codexRemainingPercent(usedPercent: number): number { } export function isCodexSessionStatus(status: OrchestrationSessionStatus): boolean { - return status !== "stopped" && status !== "idle"; + return ( + status !== "stopped" && status !== "idle" && status !== "interrupted" && status !== "error" + ); } export function codexProviderStatusLabel(provider: ServerProvider): string { diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 663355ab1201..f967d9ee19fd 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -1,4 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { ServerProvider, ServerProviderClaudeRateLimitWindow, @@ -19,6 +24,7 @@ import { Card } from "../ui/card"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { codexRateLimitWindowLabel, codexRemainingPercent, @@ -243,14 +249,28 @@ export function StatusPage() { if (!primaryEnvironmentId || isRefreshing) return; setIsRefreshing(true); try { - await Promise.all( - statusProviders.map((provider) => - refreshProviders({ - environmentId: primaryEnvironmentId, - input: { instanceId: provider.instanceId }, + const result = await refreshProviders({ + environmentId: primaryEnvironmentId, + input: {}, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + console.warn("Failed to refresh status providers", { + operation: "refresh-status-providers", + environmentId: primaryEnvironmentId, + ...safeErrorLogAttributes(error), + }); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not refresh provider status", + description: + error instanceof Error + ? error.message + : "The provider refresh command could not be completed.", }), - ), - ); + ); + } } finally { setIsRefreshing(false); } @@ -342,7 +362,7 @@ export function StatusPage() {

    Claude

    - Current rate-limit windows and remaining credits. + Current usage windows and percentage used.

    {claudeProviders.length === 0 ? ( @@ -368,7 +388,7 @@ export function StatusPage() { rel="noreferrer" target="_blank" > - View up-to-date rate limits and credits + View up-to-date usage and rate limits From 7e6fe12833f6f5340a04836ca69e8e3adcb04846 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Thu, 13 Aug 2026 08:25:08 -0300 Subject: [PATCH 31/43] fix(provider): serialize refresh hooks --- .../makeManagedServerProvider.test.ts | 60 +++++++++++++++++++ .../src/provider/makeManagedServerProvider.ts | 10 +++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index b33d67a7de50..10de680c2a17 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -226,6 +226,66 @@ describe("makeManagedServerProvider", () => { ).pipe(Effect.provide(AlwaysRunTestLayer)), ); + it.effect("serializes refresh hooks with the provider check", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Ref.make([]); + const beforeRefreshCalls = yield* Ref.make(0); + const checkCalls = yield* Ref.make(0); + const initialCheckDone = yield* Deferred.make(); + const firstRefreshCheckStarted = yield* Deferred.make(); + const releaseFirstRefresh = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const call = yield* Ref.updateAndGet(checkCalls, (count) => count + 1); + yield* Ref.update(events, (previous) => [...previous, `check:${call}`]); + if (call === 1) { + yield* Deferred.succeed(initialCheckDone, undefined).pipe(Effect.ignore); + } else if (call === 2) { + yield* Deferred.succeed(firstRefreshCheckStarted, undefined).pipe(Effect.ignore); + yield* Deferred.await(releaseFirstRefresh); + } + return call === 2 ? refreshedSnapshot : refreshedSnapshotSecond; + }), + beforeRefresh: Effect.gen(function* () { + const call = yield* Ref.updateAndGet(beforeRefreshCalls, (count) => count + 1); + yield* Ref.update(events, (previous) => [...previous, `before:${call}`]); + }), + refreshInterval: "1 hour", + }); + + yield* Deferred.await(initialCheckDone); + yield* Ref.set(events, []); + + const firstRefresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Deferred.await(firstRefreshCheckStarted); + + const secondRefresh = yield* provider.refresh.pipe(Effect.forkChild); + yield* Effect.yieldNow; + + assert.strictEqual(yield* Ref.get(beforeRefreshCalls), 1); + assert.deepStrictEqual(yield* Ref.get(events), ["before:1", "check:2"]); + + yield* Deferred.succeed(releaseFirstRefresh, undefined); + yield* Fiber.join(firstRefresh); + yield* Fiber.join(secondRefresh); + + assert.strictEqual(yield* Ref.get(beforeRefreshCalls), 2); + assert.deepStrictEqual(yield* Ref.get(events), [ + "before:1", + "check:2", + "before:2", + "check:3", + ]); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); + it.effect("skips periodic provider refreshes without foreground provider-status demand", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index ae8ec4138e0b..93bf9f17bd8a 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -145,9 +145,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { - yield* input.beforeRefresh ?? Effect.void; - const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* input.beforeRefresh ?? Effect.void; + const nextSettings = yield* input.getSettings; + return yield* applySnapshotBase(nextSettings, { forceRefresh: true }); + }), + ); }); const hasProviderStatusDemand = Effect.gen(function* () { From c88dcacb545bdbf3ada21d5ec7491b27ea8ebb3e Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Thu, 13 Aug 2026 08:32:55 -0300 Subject: [PATCH 32/43] fix(status): address follow-up review feedback --- .../Layers/ClaudeCapabilitiesProbe.test.ts | 4 +++- apps/server/src/provider/Layers/ClaudeProvider.ts | 15 +++++++++++---- apps/web/src/components/status/StatusPage.tsx | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index fc992b7224e7..44a9d9f7bc63 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -173,7 +173,7 @@ it("omits a Claude reset timestamp when the SDK does not provide one", () => { }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { - it.effect("serializes strict no-MCP options and still resolves account capabilities", () => + it.effect("keeps account capabilities when the usage lookup stalls", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -207,6 +207,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { 'lines.on("line", (line) => {', " const message = JSON.parse(line);", ' if (message.type === "control_request" && message.request?.subtype === "get_usage") {', + ' if (process.env.T3_PROBE_STALL_USAGE === "true") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -245,6 +246,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { { ...process.env, T3_PROBE_INVOCATION_PATH: invocationPath, + T3_PROBE_STALL_USAGE: "true", ENABLE_CLAUDEAI_MCP_SERVERS: "true", }, workspaceCwd, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 098d4a969ab0..39be562c1fb4 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -574,6 +574,7 @@ function apiProviderAuthMetadata( // account info. The previous 8s budget expired mid-init, so the probe returned // `undefined` and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +const CLAUDE_USAGE_LOOKUP_TIMEOUT_MS = 5_000; /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -821,10 +822,16 @@ const probeClaudeCapabilities = ( }), }); const init = await q.initializationResult(); - const claudeStatus = await q - .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() - .then((usage) => mapClaudeUsageToStatus(usage, claudePromo)) - .catch(() => undefined); + const claudeStatus = await Promise.race([ + q + .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() + .then((usage) => mapClaudeUsageToStatus(usage, claudePromo)) + .catch(() => undefined), + new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off - Promise.race needs a native timer. + globalThis.setTimeout(resolve, CLAUDE_USAGE_LOOKUP_TIMEOUT_MS); + }), + ]); const account = init.account as | { readonly email?: string; diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index f967d9ee19fd..7bc4f3983e6c 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -304,7 +304,7 @@ export function StatusPage() { )} -
    +

    Status

    @@ -392,7 +392,7 @@ export function StatusPage() { -
    +
    From 664515a936d7c97370239f2fadeffcd71e662174 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 08:35:51 -0300 Subject: [PATCH 33/43] fix(server): decouple claude capability initialization and usage timeouts - Bound initializationResult independently from usage lookup. - Prevent slow usage fetches from timing out successful initialization results. Gemini 3.7 Flash via Antigravity CLI --- apps/server/src/provider/Layers/ClaudeProvider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 39be562c1fb4..b26d117e8202 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -821,7 +821,14 @@ const probeClaudeCapabilities = ( cwd, }), }); - const init = await q.initializationResult(); + const init = await Promise.race([ + q.initializationResult(), + new Promise((resolve) => { + // @effect-diagnostics-next-line globalTimers:off - Promise.race needs a native timer. + globalThis.setTimeout(resolve, CAPABILITIES_PROBE_TIMEOUT_MS); + }), + ]); + if (!init) return undefined; const claudeStatus = await Promise.race([ q .usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET() @@ -855,7 +862,7 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), - Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS + CLAUDE_USAGE_LOOKUP_TIMEOUT_MS + 2_000), Effect.result, Effect.map((result) => { if (Result.isFailure(result)) return undefined; From e865ee374d5813607042e4a3730bd588c0cd69ab Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 08:46:02 -0300 Subject: [PATCH 34/43] fix(web): unify rate-limit meter rows, timestamp format, and card highlight radii - Share RateLimitRow between Codex and Claude status views. - Unify reset timestamp formatting with standard timezone output. - Add motion-reduce:transition-none to topbar and match before:rounded card highlight radii. Gemini 3.7 Flash via Antigravity CLI --- .../src/components/status/StatusPage.logic.ts | 50 --------- apps/web/src/components/status/StatusPage.tsx | 104 +++++++++--------- 2 files changed, 50 insertions(+), 104 deletions(-) diff --git a/apps/web/src/components/status/StatusPage.logic.ts b/apps/web/src/components/status/StatusPage.logic.ts index e0364e072093..18627e0673e9 100644 --- a/apps/web/src/components/status/StatusPage.logic.ts +++ b/apps/web/src/components/status/StatusPage.logic.ts @@ -92,17 +92,6 @@ export function formatStatusTimestamp(value: string): string { }); } -const BRASILIA_TIME_ZONES = new Set([ - "America/Araguaina", - "America/Bahia", - "America/Belem", - "America/Fortaleza", - "America/Maceio", - "America/Recife", - "America/Sao_Paulo", - "America/Santarem", -]); - function formatUtcOffset(date: Date): string { const offsetMinutes = -date.getTimezoneOffset(); const sign = offsetMinutes >= 0 ? "+" : "-"; @@ -113,10 +102,7 @@ function formatUtcOffset(date: Date): string { } function formatStatusTimeZone(date: Date): string { - const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone; const offset = formatUtcOffset(date); - if (BRASILIA_TIME_ZONES.has(timeZone) && offset === "-3") return "BRT-3"; - const zoneName = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }) .formatToParts(date) .find((part) => part.type === "timeZoneName")?.value; @@ -130,39 +116,3 @@ export function formatStatusTimestampWithTimeZone(value: string): string { if (Number.isNaN(date.getTime())) return value; return `${formatStatusTimestamp(value)} (${formatStatusTimeZone(date)})`; } - -function formatClaudeClock(date: Date, timeZone: string): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - hour: "numeric", - minute: "2-digit", - hour12: true, - }) - .formatToParts(date) - .reduce>((result, part) => { - if (part.type !== "literal") result[part.type] = part.value; - return result; - }, {}); - const minute = parts.minute === "00" ? "" : `:${parts.minute}`; - return `${parts.hour ?? ""}${minute}${(parts.dayPeriod ?? "").toLowerCase()}`; -} - -/** Format Claude Code's compact reset labels using the browser's local zone. */ -export function formatClaudeResetTimestamp(value: string, alwaysShowDate = false): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const clock = formatClaudeClock(date, timeZone); - const hoursUntilReset = (date.getTime() - Date.now()) / (60 * 60 * 1000); - const label = - alwaysShowDate || hoursUntilReset > 24 - ? `${new Intl.DateTimeFormat("en-US", { - timeZone, - month: "short", - day: "numeric", - }).format(date)}, ${clock}` - : clock; - - return `${label} (${timeZone})`; -} diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 7bc4f3983e6c..9d80b22e2bf5 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -28,7 +28,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { codexRateLimitWindowLabel, codexRemainingPercent, - formatClaudeResetTimestamp, formatStatusTimestampWithTimeZone, } from "./StatusPage.logic"; @@ -61,16 +60,15 @@ function StatusEmptyState({ } function RateLimitRow({ - window, - fallbackLabel, + label, + usedPercent, + resetTimestamp, }: { - readonly window: ServerProviderCodexRateLimitWindow; - readonly fallbackLabel: string; + readonly label: string; + readonly usedPercent: number; + readonly resetTimestamp: string | null; }) { - const remaining = codexRemainingPercent(window.usedPercent); - const reset = formatResetTimestamp(window.resetsAt); - const durationLabel = codexRateLimitWindowLabel(window.windowDurationMins); - const label = durationLabel === "Rate limit" ? fallbackLabel : durationLabel; + const remaining = codexRemainingPercent(usedPercent); return (
    @@ -91,43 +89,9 @@ function RateLimitRow({ style={{ width: `${remaining}%` }} />
    - {reset ?

    Resets {reset}

    : null} -
    - ); -} - -function ClaudeRateLimitRow({ - label, - window, -}: { - readonly label: string; - readonly window: ServerProviderClaudeRateLimitWindow; -}) { - const used = Math.max(0, Math.min(100, window.usedPercent)); - const reset = window.resetsAt - ? formatClaudeResetTimestamp(window.resetsAt, label === "Weekly limit") - : null; - - return ( -
    -
    - {label} - {used}% used -
    -
    -
    -
    - {reset ?

    Resets {reset}

    : null} + {resetTimestamp ? ( +

    Resets {resetTimestamp}

    + ) : null}
    ); } @@ -142,7 +106,7 @@ function CodexRateLimitsCard({ const rateLimits = provider.codexStatus?.rateLimits; return ( - +
    {showProviderLabel ? ( @@ -162,10 +126,26 @@ function CodexRateLimitsCard({ {rateLimits.limitName} ) : null} {rateLimits.primary ? ( - + ) : null} {rateLimits.secondary ? ( - + ) : null}
    ) : ( @@ -187,7 +167,7 @@ function ClaudeRateLimitsCard({ const rateLimits = provider.claudeStatus?.rateLimits; return ( - +
    {showProviderLabel ? ( @@ -204,10 +184,26 @@ function ClaudeRateLimitsCard({ {rateLimits?.currentSession || rateLimits?.currentWeek ? (
    {rateLimits.currentSession ? ( - + ) : null} {rateLimits.currentWeek ? ( - + ) : null} {rateLimits.currentWeekPromo ? (

    {rateLimits.currentWeekPromo}

    @@ -282,7 +278,7 @@ export function StatusPage() { {!isElectron ? (
    @@ -293,7 +289,7 @@ export function StatusPage() { ) : (
    From 716154085066bc26630bb3f03cb5c296457d2b24 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 08:49:44 -0300 Subject: [PATCH 35/43] fix(web): render % used for Claude and % left for Codex in RateLimitRow Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/status/StatusPage.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 9d80b22e2bf5..bc81183b42be 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -61,32 +61,38 @@ function StatusEmptyState({ function RateLimitRow({ label, - usedPercent, + valuePercent, resetTimestamp, + kind = "remaining", }: { readonly label: string; - readonly usedPercent: number; + readonly valuePercent: number; readonly resetTimestamp: string | null; + readonly kind?: "remaining" | "used"; }) { - const remaining = codexRemainingPercent(usedPercent); + const percent = Math.max(0, Math.min(100, valuePercent)); + const suffix = kind === "remaining" ? "% left" : "% used"; return (
    {label} - {remaining}% left + + {percent} + {suffix} +
    {resetTimestamp ? ( @@ -132,8 +138,9 @@ function CodexRateLimitsCard({ ? "Primary limit" : codexRateLimitWindowLabel(rateLimits.primary.windowDurationMins) } - usedPercent={rateLimits.primary.usedPercent} + valuePercent={codexRemainingPercent(rateLimits.primary.usedPercent)} resetTimestamp={formatResetTimestamp(rateLimits.primary.resetsAt)} + kind="remaining" /> ) : null} {rateLimits.secondary ? ( @@ -143,8 +150,9 @@ function CodexRateLimitsCard({ ? "Secondary limit" : codexRateLimitWindowLabel(rateLimits.secondary.windowDurationMins) } - usedPercent={rateLimits.secondary.usedPercent} + valuePercent={codexRemainingPercent(rateLimits.secondary.usedPercent)} resetTimestamp={formatResetTimestamp(rateLimits.secondary.resetsAt)} + kind="remaining" /> ) : null}
    @@ -186,23 +194,25 @@ function ClaudeRateLimitsCard({ {rateLimits.currentSession ? ( ) : null} {rateLimits.currentWeek ? ( ) : null} {rateLimits.currentWeekPromo ? ( From af14f74ec7ab6074c7534b31546fff205b37daf6 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 08:50:34 -0300 Subject: [PATCH 36/43] fix(web): add motion-reduce to rate-limit progress bar Gemini 3.7 Flash via Antigravity CLI --- apps/web/src/components/status/StatusPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index bc81183b42be..32aafb982fe8 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -91,7 +91,7 @@ function RateLimitRow({ aria-valuenow={percent} >
    From 2d01e02aaa2e4e3098f8674976b93102930c18df Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Tue, 11 Aug 2026 21:48:08 -0300 Subject: [PATCH 37/43] fix(desktop): restore Linux dock icon association --- .../src/app/DesktopAppIdentity.test.ts | 30 ++++++++++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 3 ++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index de945054c893..da0250073bfb 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -31,6 +31,10 @@ type TestEnvironmentInput = Partial; readonly setAboutPanelOptions: Array; readonly setDockIcon: string[]; readonly setName: string[]; @@ -62,7 +66,10 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => Effect.sync(() => { calls.setDockIcon.push(iconPath); }), - appendCommandLineSwitch: () => Effect.void, + appendCommandLineSwitch: (name, value) => + Effect.sync(() => { + calls.appendCommandLineSwitch.push({ name, value }); + }), onBeforeQuitForUpdate: () => Effect.void, removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, @@ -114,6 +121,7 @@ const withIdentity = ( } = {}, ) => { const calls: ElectronAppCalls = input.calls ?? { + appendCommandLineSwitch: [], setAboutPanelOptions: [], setDockIcon: [], setName: [], @@ -184,6 +192,7 @@ describe("DesktopAppIdentity", () => { it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { + appendCommandLineSwitch: [], setAboutPanelOptions: [], setDockIcon: [], setName: [], @@ -211,4 +220,23 @@ describe("DesktopAppIdentity", () => { }, ); }); + + it.effect("sets the Linux window class used by the packaged desktop entry", () => { + const calls: ElectronAppCalls = { + appendCommandLineSwitch: [], + setAboutPanelOptions: [], + setDockIcon: [], + setName: [], + }; + + return withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + yield* identity.configure; + + assert.deepEqual(calls.appendCommandLineSwitch, [{ name: "class", value: "t3code" }]); + }), + { calls, environment: { platform: "linux" } }, + ); + }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 0be55d633e61..8b1842ab8ccc 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -131,6 +131,9 @@ export const make = Effect.gen(function* () { } if (environment.platform === "linux") { + // Match the packaged desktop entry's StartupWMClass so Linux shells can + // associate the running window with its launcher and icon. + yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); } From 4e56082731ab3a332d90baf60a96892124b7b037 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Tue, 11 Aug 2026 22:23:06 -0300 Subject: [PATCH 38/43] test(desktop): cover Linux window class pre-ready --- .../src/app/DesktopAppIdentity.test.ts | 30 +------------------ apps/desktop/src/app/DesktopAppIdentity.ts | 3 -- .../src/app/DesktopPreReadyPlatform.test.ts | 23 +++++++++++++- 3 files changed, 23 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index da0250073bfb..de945054c893 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -31,10 +31,6 @@ type TestEnvironmentInput = Partial; readonly setAboutPanelOptions: Array; readonly setDockIcon: string[]; readonly setName: string[]; @@ -66,10 +62,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => Effect.sync(() => { calls.setDockIcon.push(iconPath); }), - appendCommandLineSwitch: (name, value) => - Effect.sync(() => { - calls.appendCommandLineSwitch.push({ name, value }); - }), + appendCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, removeCommandLineSwitch: () => Effect.void, on: () => Effect.void, @@ -121,7 +114,6 @@ const withIdentity = ( } = {}, ) => { const calls: ElectronAppCalls = input.calls ?? { - appendCommandLineSwitch: [], setAboutPanelOptions: [], setDockIcon: [], setName: [], @@ -192,7 +184,6 @@ describe("DesktopAppIdentity", () => { it.effect("configures app identity from the environment commit override", () => { const calls: ElectronAppCalls = { - appendCommandLineSwitch: [], setAboutPanelOptions: [], setDockIcon: [], setName: [], @@ -220,23 +211,4 @@ describe("DesktopAppIdentity", () => { }, ); }); - - it.effect("sets the Linux window class used by the packaged desktop entry", () => { - const calls: ElectronAppCalls = { - appendCommandLineSwitch: [], - setAboutPanelOptions: [], - setDockIcon: [], - setName: [], - }; - - return withIdentity( - Effect.gen(function* () { - const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - yield* identity.configure; - - assert.deepEqual(calls.appendCommandLineSwitch, [{ name: "class", value: "t3code" }]); - }), - { calls, environment: { platform: "linux" } }, - ); - }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 8b1842ab8ccc..0be55d633e61 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -131,9 +131,6 @@ export const make = Effect.gen(function* () { } if (environment.platform === "linux") { - // Match the packaged desktop entry's StartupWMClass so Linux shells can - // associate the running window with its launcher and icon. - yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); yield* electronApp.setDesktopName(environment.linuxDesktopEntryName); } diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a6e2cc69cb60 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -3,7 +3,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; const { appendSwitchMock, getSwitchValueMock, hasSwitchMock, registerSchemesMock } = vi.hoisted( () => ({ @@ -37,6 +37,10 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("reads an explicit Electron command-line switch value", () => { const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( { @@ -78,6 +82,23 @@ describe("DesktopPreReadyPlatform", () => { assert.isNull(value); }); + it.effect("appends the packaged Linux window class before Electron is ready", () => + Effect.gen(function* () { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + hasSwitchMock.mockReturnValue(false); + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ); + const options = yield* Effect.gen(function* () { + return yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + }).pipe(Effect.provide(preReadyLayer)); + + assert.equal(options.linux?.linuxWmClass, "t3code"); + assert.deepEqual(appendSwitchMock.mock.calls[0], ["class", "t3code"]); + }), + ); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => From 5a69626ab96a3c0cfd264fc9f6b39dadde033b05 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 10:54:05 -0300 Subject: [PATCH 39/43] chore: clean up unused imports and variables in nightly build Google Antigravity --- apps/server/src/textGeneration/AntigravityTextGeneration.ts | 2 +- apps/web/src/components/Sidebar.tsx | 2 -- apps/web/src/components/chat/providerIconUtils.ts | 1 - apps/web/src/components/settings/providerDriverMeta.ts | 1 - apps/web/src/components/status/StatusPage.tsx | 6 +----- 5 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index abdfb3195031..6cf6b2923d09 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -108,7 +108,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ), ); - const [stdout, stderr, exitCode] = yield* Effect.all( + const [stdout, _stderr, exitCode] = yield* Effect.all( [ readStreamAsString(operation, processHandle.stdout), readStreamAsString(operation, processHandle.stderr), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 332d9dbc524a..7558ae666461 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -6,7 +6,6 @@ import { DndContext, DragOverlay, PointerSensor, - closestCenter, pointerWithin, rectIntersection, useDroppable, @@ -25,7 +24,6 @@ import { import { CSS } from "@dnd-kit/utilities"; import { snapCenterToCursor } from "@dnd-kit/modifiers"; import { - canSettle, canSnooze, changeRequestAutoSettles, effectiveSettled, diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 0c4587210d98..e364044d7f93 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -3,7 +3,6 @@ import { AntigravityIcon, ClaudeAI, CursorIcon, - Gemini, GrokIcon, Icon, OpenAI, diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 1cd82047e175..cb7242049a84 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -12,7 +12,6 @@ import { AntigravityIcon, ClaudeAI, CursorIcon, - Gemini, GrokIcon, type Icon, OpenAI, diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx index 32aafb982fe8..fc39f372fdc4 100644 --- a/apps/web/src/components/status/StatusPage.tsx +++ b/apps/web/src/components/status/StatusPage.tsx @@ -4,11 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { - ServerProvider, - ServerProviderClaudeRateLimitWindow, - ServerProviderCodexRateLimitWindow, -} from "@t3tools/contracts"; +import type { ServerProvider } from "@t3tools/contracts"; import { ExternalLinkIcon, RefreshCwIcon, ShieldCheckIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; From e55a6cccdf2489624a4affe029ca1b8114fccbf5 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sat, 15 Aug 2026 11:21:33 -0300 Subject: [PATCH 40/43] feat(web): add cross-provider model handoff with structured context --- apps/web/src/components/ChatView.tsx | 211 +++++++++++++++--- apps/web/src/components/chat/ModelListRow.tsx | 9 + .../components/chat/ModelPickerContent.tsx | 16 +- .../components/chat/ProviderModelPicker.tsx | 2 + .../chat/ThreadHandoffDialog.test.tsx | 97 ++++++++ .../components/chat/ThreadHandoffDialog.tsx | 202 +++++++++++++++++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/handoff/index.ts | 1 + .../src/handoff/threadHandoff.test.ts | 139 ++++++++++++ .../src/handoff/threadHandoff.ts | 164 ++++++++++++++ 10 files changed, 814 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/chat/ThreadHandoffDialog.test.tsx create mode 100644 apps/web/src/components/chat/ThreadHandoffDialog.tsx create mode 100644 packages/client-runtime/src/handoff/index.ts create mode 100644 packages/client-runtime/src/handoff/threadHandoff.test.ts create mode 100644 packages/client-runtime/src/handoff/threadHandoff.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index feac2d5978ce..34bc4dfeea6e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -251,6 +251,7 @@ import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { ThreadHandoffDialog } from "./chat/ThreadHandoffDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; @@ -321,6 +322,7 @@ import { revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, + threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; @@ -1382,6 +1384,9 @@ function ChatViewContent(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); + const [isHandoffDialogOpen, setIsHandoffDialogOpen] = useState(false); + const [handoffTargetModelSelection, setHandoffTargetModelSelection] = + useState(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< @@ -5875,11 +5880,141 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + const handleConfirmHandoff = useCallback( + async (handoffMarkdown: string, targetModelSelection: ModelSelection) => { + if (!activeProject || !activeThread) return; + const nextThreadId = newThreadId(); + const nextThreadTitle = `${activeThread.title} (Handoff)`; + const createdAt = new Date().toISOString(); + + sendInFlightRef.current = true; + beginLocalDispatch({ preparingWorktree: false }); + const finish = () => { + sendInFlightRef.current = false; + resetLocalDispatch(); + }; + + const createResult = await createThread({ + environmentId, + input: { + threadId: nextThreadId, + projectId: activeProject.id, + title: nextThreadTitle, + modelSelection: targetModelSelection, + runtimeMode, + interactionMode: "default", + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + createdAt, + }, + }); + let failure: AtomCommandResult | null = + createResult._tag === "Failure" ? createResult : null; + + if (failure === null) { + const startResult = await startThreadTurn({ + environmentId, + input: { + threadId: nextThreadId, + message: { + messageId: newMessageId(), + role: "user", + text: handoffMarkdown, + attachments: [], + }, + modelSelection: targetModelSelection, + titleSeed: nextThreadTitle, + runtimeMode, + interactionMode: "default", + createdAt, + }, + }); + failure = startResult._tag === "Failure" ? startResult : null; + } + + if (failure === null) { + const startedResult = await settlePromise(() => + waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)), + ); + failure = startedResult._tag === "Failure" ? startedResult : null; + } + + if (failure === null) { + const navigateResult = await settlePromise(() => + navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, + }), + ); + failure = navigateResult._tag === "Failure" ? navigateResult : null; + } + + if (failure !== null) { + const cleanupResult = await deleteThread({ + environmentId, + input: { + threadId: nextThreadId, + }, + }); + if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) { + console.warn( + "Failed to clean up handoff thread after start failure.", + squashAtomCommandFailure(cleanupResult), + ); + } + if (!isAtomCommandInterrupted(failure)) { + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not start handoff thread", + description: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Failed to continue conversation in new thread.", + }), + ); + } + finish(); + return; + } + + finish(); + toastManager.add({ + type: "success", + title: "Thread started with handoff", + description: `Continuing conversation with ${targetModelSelection.model}`, + }); + }, + [ + activeProject, + activeThread, + activeThreadBranch, + beginLocalDispatch, + createThread, + deleteThread, + environmentId, + navigate, + resetLocalDispatch, + runtimeMode, + startThreadTurn, + waitForStartedServerThread, + ], + ); + const getModelDisabledReason = useCallback( (instanceId: ProviderInstanceId, model: string): string | null => { if (!activeThread) { return null; } + if (isServerThread && activeServerThread && threadHasStarted(activeServerThread)) { + return null; + } const reason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, @@ -5889,38 +6024,14 @@ function ChatViewContent(props: ChatViewProps) { }); return reason ? `${reason.description} Start a new thread to use this model.` : null; }, - [activeThread, providerStatuses], + [activeServerThread, activeThread, isServerThread, providerStatuses], ); const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; - // Look up the configured instance so model normalization and custom - // model lookup stay scoped to that exact instance. Unknown instance ids - // are rejected by returning early; the server remains authoritative too. const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); const resolvedDriverKind = entry?.driver ?? null; - if ( - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - scheduleComposerFocus(); - return; - } - if (lockedProvider !== null && activeThread.session?.providerInstanceId) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - scheduleComposerFocus(); - return; - } - } const resolvedModel = resolveAppModelSelectionForInstance( instanceId, settings, @@ -5935,6 +6046,28 @@ function ChatViewContent(props: ChatViewProps) { instanceId, model: resolvedModel, }; + + const isCrossProvider = + isServerThread && + activeServerThread !== null && + threadHasStarted(activeServerThread) && + ((lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider) || + (lockedProvider !== null && + activeServerThread.session?.providerInstanceId && + (() => { + const currentEntry = providerStatuses.find( + (snapshot) => + snapshot.instanceId === activeServerThread.session?.providerInstanceId, + ); + return Boolean( + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey, + ); + })())); + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, @@ -5942,6 +6075,19 @@ function ChatViewContent(props: ChatViewProps) { currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, nextModelSelection, }); + + if ( + isCrossProvider || + (modelChangeBlockReason && + isServerThread && + activeServerThread !== null && + threadHasStarted(activeServerThread)) + ) { + setHandoffTargetModelSelection(nextModelSelection); + setIsHandoffDialogOpen(true); + return; + } + if (modelChangeBlockReason) { toastManager.add({ type: "warning", @@ -5959,12 +6105,14 @@ function ChatViewContent(props: ChatViewProps) { scheduleComposerFocus(); }, [ + activeServerThread, activeThread, + isServerThread, lockedProvider, + providerStatuses, scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - providerStatuses, settings, ], ); @@ -6594,6 +6742,17 @@ function ChatViewContent(props: ChatViewProps) { onPrepared={handlePreparedPullRequestThread} /> ) : null} + + {activeServerThread && handoffTargetModelSelection ? ( + + ) : null}
    {/* end chat column */}
    diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 70475ffd0380..17c76cc068c0 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -34,6 +34,7 @@ export const ModelListRow = memo(function ModelListRow(props: { preferShortName?: boolean; useTriggerLabel?: boolean; showNewBadge?: boolean; + isHandoff?: boolean; jumpLabel?: string | null; disabledReason?: string | null; onToggleFavorite: () => void; @@ -75,6 +76,14 @@ export const ModelListRow = memo(function ModelListRow(props: { New ) : null} + {props.isHandoff ? ( + + Handoff + + ) : null}
    {props.showProvider && (
    diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7ffb2bf077da..1dd8cd6f5e02 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -90,6 +90,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { modelOptionsByInstance: ReadonlyMap>; terminalOpen: boolean; onRequestClose?: () => void; + allowHandoff?: boolean; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -97,6 +98,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { keybindings: providedKeybindings, modelOptionsByInstance, instanceEntries, + allowHandoff = true, getModelDisabledReason, onInstanceModelChange, } = props; @@ -235,7 +237,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return out; }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); - const isLocked = props.lockedProvider !== null; + const isLocked = props.lockedProvider !== null && !allowHandoff; const isSearching = searchQuery.trim().length > 0; const lockedDisabledInstanceIds = useMemo(() => { if (!isLocked) { @@ -314,7 +316,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // When searching, we only respect locked provider (by driver kind), // ignoring sidebar selection so account-scoped searches can find a // model before the user chooses a specific instance rail item. - if (props.lockedProvider !== null) { + if (!allowHandoff && props.lockedProvider !== null) { const lockedProviderMatches: Array<(typeof rankedMatches)[number]> = []; for (const rankedModel of rankedMatches) { if (matchesLockedProvider(rankedModel.model)) { @@ -349,7 +351,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .map((rankedModel) => rankedModel.model); } - if (props.lockedProvider !== null) { + if (!allowHandoff && props.lockedProvider !== null) { result = result.filter((m) => matchesLockedProvider(m)); if (selectedInstanceId === "favorites") { result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); @@ -747,8 +749,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } - const disabledReason = - getModelDisabledReason?.(model.instanceId, model.slug) ?? null; + const isCrossProvider = + props.lockedProvider !== null && !matchesLockedProvider(model); + const disabledReason = isCrossProvider + ? null + : (getModelDisabledReason?.(model.instanceId, model.slug) ?? null); return ( toggleFavorite(model.instanceId, model.slug)} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index bd374a0fd6f5..8e311969a75a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -41,6 +41,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerClassName?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; + allowHandoff?: boolean; getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null; onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void; }) { @@ -194,6 +195,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model={props.model} lockedProvider={props.lockedProvider} lockedContinuationGroupKey={props.lockedContinuationGroupKey ?? null} + allowHandoff={props.allowHandoff} instanceEntries={props.instanceEntries} {...(props.keybindings ? { keybindings: props.keybindings } : {})} modelOptionsByInstance={props.modelOptionsByInstance} diff --git a/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx b/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx new file mode 100644 index 000000000000..29662614bd5a --- /dev/null +++ b/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx @@ -0,0 +1,97 @@ +import { + IsoDateTime, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import { ThreadHandoffContent } from "./ThreadHandoffDialog"; +import type { ProviderInstanceEntry } from "../../providerInstances"; + +describe("ThreadHandoffContent", () => { + const dummyInstanceEntries: ReadonlyArray = [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + displayName: "Claude Code", + driverKind: ProviderDriverKind.make("claudeAgent"), + enabled: true, + isAvailable: true, + configured: true, + hasCredentials: true, + isDefault: true, + models: [], + snapshot: null, + authStatus: "authorized", + }, + { + instanceId: ProviderInstanceId.make("codex"), + displayName: "Codex", + driverKind: ProviderDriverKind.make("codex"), + enabled: true, + isAvailable: true, + configured: true, + hasCredentials: true, + isDefault: false, + models: [], + snapshot: null, + authStatus: "authorized", + }, + ]; + + it("renders handoff content with source and target model indicators and markdown preview", () => { + const markup = renderToStaticMarkup( + {}} + />, + ); + + expect(markup).toContain("Continue in new thread"); + expect(markup).toContain("claude-3-7-sonnet"); + expect(markup).toContain("gpt-5.3-codex"); + expect(markup).toContain("Handoff context (editable)"); + expect(markup).toContain("Continue with gpt-5.3-codex"); + expect(markup).toContain("Add index to user_id column"); + }); +}); diff --git a/apps/web/src/components/chat/ThreadHandoffDialog.tsx b/apps/web/src/components/chat/ThreadHandoffDialog.tsx new file mode 100644 index 000000000000..cf12d60f91e2 --- /dev/null +++ b/apps/web/src/components/chat/ThreadHandoffDialog.tsx @@ -0,0 +1,202 @@ +import { type ModelSelection, type OrchestrationThread } from "@t3tools/contracts"; +import { buildThreadHandoffMarkdown } from "@t3tools/client-runtime/handoff"; +import { ArrowRightIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "../ui/button"; +import { Dialog, DialogFooter, DialogPanel, DialogPopup } from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Spinner } from "../ui/spinner"; +import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; +import type { ProviderInstanceEntry } from "../../providerInstances"; + +export interface ThreadHandoffContentProps { + readonly sourceThread: OrchestrationThread; + readonly targetModelSelection: ModelSelection; + readonly providerInstanceEntries: ReadonlyArray; + readonly isSubmitting?: boolean; + readonly onCancel?: () => void; + readonly onConfirmHandoff: ( + handoffMarkdown: string, + targetModelSelection: ModelSelection, + ) => Promise | void; +} + +export function ThreadHandoffContent({ + sourceThread, + targetModelSelection, + providerInstanceEntries, + isSubmitting = false, + onCancel, + onConfirmHandoff, +}: ThreadHandoffContentProps) { + const [handoffText, setHandoffText] = useState(() => + buildThreadHandoffMarkdown({ + thread: sourceThread, + targetModelSelection, + }), + ); + + const sourceEntry = useMemo(() => { + const instanceId = sourceThread.modelSelection?.instanceId; + return providerInstanceEntries.find((entry) => entry.instanceId === instanceId) ?? null; + }, [providerInstanceEntries, sourceThread.modelSelection?.instanceId]); + + const targetEntry = useMemo(() => { + return ( + providerInstanceEntries.find( + (entry) => entry.instanceId === targetModelSelection.instanceId, + ) ?? null + ); + }, [providerInstanceEntries, targetModelSelection]); + + useEffect(() => { + const generated = buildThreadHandoffMarkdown({ + thread: sourceThread, + targetModelSelection, + }); + setHandoffText(generated); + }, [sourceThread, targetModelSelection]); + + const handleConfirm = useCallback(() => { + return onConfirmHandoff(handoffText, targetModelSelection); + }, [handoffText, onConfirmHandoff, targetModelSelection]); + + const sourceModelLabel = sourceThread.modelSelection?.model ?? "Current model"; + const targetModelLabel = targetModelSelection.model; + + return ( + <> +
    +

    + Continue in new thread +

    +

    + Switch to a different model with a structured handoff of your current progress. The + original thread remains untouched. +

    +
    + + +
    +
    + {sourceEntry && ( + + )} +
    + From + {sourceModelLabel} +
    +
    + + + +
    + {targetEntry && ( + + )} +
    + To + {targetModelLabel} +
    +
    +
    + +
    + +