@@ -3554,7 +4067,8 @@ export default function Sidebar() {
aria-label="Thread search results"
className="flex flex-col gap-px"
>
- {threadSearchResults.map((thread, index) => {
+ {threadSearchResults.map((result, index) => {
+ const { thread, contentMatch } = result;
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
);
@@ -3562,6 +4076,7 @@ export default function Sidebar() {
-
- {(() => {
- 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);
+ }}
+ >
+
{
+ listContainerRef.current = el;
+ attachListAutoAnimateRef(el);
+ }}
+ role="list"
+ className="flex flex-col gap-px"
+ >
+ {(() => {
+ 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 ? (
- -
-
-
- Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
-
-
- ) : null}
-
+
+ Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more
+
+
+ ) : null}
+
+ {typeof document !== "undefined"
+ ? createPortal(
+
+ {activeDragThread ? (
+
+ {renderThreadRow(activeDragThread, activeDragSection, undefined, true)}
+
+ ) : null}
+ ,
+ document.body,
+ )
+ : null}
+
) : null}
{!isSearchingThreads &&
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx
index 08e0422dd255..b71b59c74afa 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,112 @@ 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 ? (
+ {
+ renameCommittedRef.current = true;
+ setRenaming(null);
+ }}
+ onRenameKeyDown={handleRenameKeyDown}
+ onOpenThreadMenu={(rect) => {
+ if (rect) {
+ openMenu({ x: rect.left, y: rect.bottom + 4 });
+ } else {
+ openMenuFromTitle();
+ }
+ }}
+ />
+ ) : (
+
+ {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}
+
+ )}
+
+
+ )}
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..f96784b8f941 100644
--- a/apps/web/src/components/chat/ModelPickerContent.tsx
+++ b/apps/web/src/components/chat/ModelPickerContent.tsx
@@ -73,30 +73,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
* between the default Codex and a custom Codex Personal).
*/
lockedProvider: ProviderDriverKind | null;
- lockedContinuationGroupKey?: string | null;
- /**
- * All configured provider instances in display order. Used to render
- * the sidebar (one button per instance) and to resolve display names
- * for the locked-mode header.
- */
+ lockedContinuationGroupKey?: string | null | undefined;
instanceEntries: ReadonlyArray
;
- keybindings?: ResolvedKeybindingsConfig;
- /**
- * Model options per instance. Keyed by `ProviderInstanceId` so the
- * default Codex instance and any custom Codex instances each have their
- * own list (custom instances typically start with the same built-in
- * model set but are free to diverge via customModels).
- */
+ keybindings?: ResolvedKeybindingsConfig | undefined;
modelOptionsByInstance: ReadonlyMap>;
terminalOpen: boolean;
- onRequestClose?: () => void;
- getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
+ onRequestClose?: (() => void) | undefined;
+ allowHandoff?: boolean | undefined;
+ getModelDisabledReason?:
+ | ((instanceId: ProviderInstanceId, model: string) => string | null)
+ | undefined;
onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
}) {
const {
keybindings: providedKeybindings,
modelOptionsByInstance,
instanceEntries,
+ allowHandoff = true,
getModelDisabledReason,
onInstanceModelChange,
} = props;
@@ -235,7 +228,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 +307,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 +342,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 +740,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..fcb7da9b733b
--- /dev/null
+++ b/apps/web/src/components/chat/ThreadHandoffDialog.test.tsx
@@ -0,0 +1,117 @@
+import {
+ IsoDateTime,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ ThreadId,
+} from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+import { Dialog } from "../ui/dialog";
+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,
+ installed: true,
+ status: "ready",
+ isAvailable: true,
+ isDefault: true,
+ models: [],
+ snapshot: {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude Code",
+ enabled: true,
+ installed: true,
+ status: "ready",
+ isDefault: true,
+ models: [],
+ } as any,
+ },
+ {
+ instanceId: ProviderInstanceId.make("codex"),
+ displayName: "Codex",
+ driverKind: ProviderDriverKind.make("codex"),
+ enabled: true,
+ installed: true,
+ status: "ready",
+ isAvailable: true,
+ isDefault: false,
+ models: [],
+ snapshot: {
+ instanceId: ProviderInstanceId.make("codex"),
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Codex",
+ enabled: true,
+ installed: true,
+ status: "ready",
+ isDefault: false,
+ models: [],
+ } as any,
+ },
+ ];
+
+ 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("handoff-context-textarea");
+ 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..795a82913cab
--- /dev/null
+++ b/apps/web/src/components/chat/ThreadHandoffDialog.tsx
@@ -0,0 +1,231 @@
+import { type ModelSelection, type OrchestrationThread } from "@t3tools/contracts";
+import { buildThreadHandoffMarkdown } from "@t3tools/client-runtime/handoff";
+import { ArrowRightIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "../ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} 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 | undefined;
+ readonly isSubmitting?: boolean | undefined;
+ readonly onCancel?: (() => void) | undefined;
+ 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 isUserDirtyRef = useRef(false);
+ const targetKeyRef = useRef(`${targetModelSelection.instanceId}:${targetModelSelection.model}`);
+
+ 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 currentTargetKey = `${targetModelSelection.instanceId}:${targetModelSelection.model}`;
+ if (targetKeyRef.current !== currentTargetKey || !isUserDirtyRef.current) {
+ targetKeyRef.current = currentTargetKey;
+ 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}
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isSubmitting ? (
+ <>
+
+ Starting thread...
+ >
+ ) : (
+ `Continue with ${targetModelLabel}`
+ )}
+
+
+ >
+ );
+}
+
+export interface ThreadHandoffDialogProps {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+ readonly sourceThread: OrchestrationThread;
+ readonly targetModelSelection: ModelSelection | null;
+ readonly providerInstanceEntries?: ReadonlyArray | undefined;
+ readonly onConfirmHandoff: (
+ handoffMarkdown: string,
+ targetModelSelection: ModelSelection,
+ ) => Promise | void;
+}
+
+export function ThreadHandoffDialog({
+ open,
+ onOpenChange,
+ sourceThread,
+ targetModelSelection,
+ providerInstanceEntries,
+ onConfirmHandoff,
+}: ThreadHandoffDialogProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleConfirm = useCallback(
+ async (handoffMarkdown: string, selection: ModelSelection) => {
+ if (isSubmitting) return;
+ setIsSubmitting(true);
+ try {
+ await onConfirmHandoff(handoffMarkdown, selection);
+ onOpenChange(false);
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [isSubmitting, onConfirmHandoff, onOpenChange],
+ );
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (isSubmitting) return;
+ onOpenChange(nextOpen);
+ },
+ [isSubmitting, onOpenChange],
+ );
+
+ if (!targetModelSelection) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/chat/WorkspaceTabs.test.tsx b/apps/web/src/components/chat/WorkspaceTabs.test.tsx
new file mode 100644
index 000000000000..39df45c58ac5
--- /dev/null
+++ b/apps/web/src/components/chat/WorkspaceTabs.test.tsx
@@ -0,0 +1,61 @@
+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+import { WorkspaceTabs } from "./WorkspaceTabs";
+import { serverTabKey, useWorkspaceTabsStore } from "~/workspaceTabsStore";
+
+vi.mock("@tanstack/react-router", () => ({
+ 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..646f1e903432
--- /dev/null
+++ b/apps/web/src/components/chat/WorkspaceTabs.tsx
@@ -0,0 +1,518 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { useNavigate } from "@tanstack/react-router";
+import { ChevronDown, Pin, Plus, X } from "lucide-react";
+import {
+ type DragEvent as ReactDragEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
+ 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 { Button } from "../ui/button";
+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;
+ readonly renamingTitle?: string | null | undefined;
+ readonly onCommitRename?: ((title: string) => void) | undefined;
+ readonly onCancelRename?: (() => void) | undefined;
+ readonly onRenameKeyDown?: ((event: ReactKeyboardEvent) => void) | undefined;
+ readonly onOpenThreadMenu?: ((targetRect?: DOMRect) => void) | undefined;
+}
+
+function ServerThreadTabItem({
+ tab,
+ isActive,
+ isDragged,
+ isDragOver,
+ renamingTitle,
+ onCommitRename,
+ onRenameKeyDown,
+ onOpenThreadMenu,
+ onActivate,
+ onClose,
+ onAuxClick,
+ onContextMenu,
+ onDragStart,
+ onDragOver,
+ onDragLeave,
+ onDrop,
+ onDragEnd,
+}: {
+ readonly tab: WorkspaceTab;
+ 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 onOpenThreadMenu?: ((targetRect?: DOMRect) => void) | undefined;
+ 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 triggerRef = 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]);
+
+ 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}
+ onDrop={onDrop}
+ onDragEnd={onDragEnd}
+ onAuxClick={onAuxClick}
+ onContextMenu={onContextMenu}
+ className={cn(
+ "group/tab relative flex h-7 max-w-48 min-w-24 shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 text-xs transition-all duration-150 [-webkit-app-region:no-drag]",
+ isActive
+ ? "border border-border/80 bg-accent text-foreground shadow-2xs font-medium"
+ : "border border-transparent text-muted-foreground hover:bg-accent/60 hover:text-foreground",
+ isDragged && "opacity-40 scale-95",
+ isDragOver && !isDragged && "ring-2 ring-primary/80 bg-accent/90",
+ )}
+ >
+ {isRenaming ? (
+
{
+ onCommitRename?.(event.currentTarget.value);
+ }}
+ onFocus={(event) => event.currentTarget.select()}
+ onKeyDown={onRenameKeyDown}
+ />
+ ) : (
+
+
+
+ {title}} />
+ {fullLabel}
+
+ {isActive ? (
+
+ ) : null}
+ {tab.pinned ? : null}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function WorkspaceTabs({
+ activeThreadEnvironmentId,
+ activeThreadId,
+ draftId,
+ activeThreadTitle,
+ activeProjectName,
+ activeProjectCwd,
+ activeProjectFaviconPath,
+ onNewTab,
+ renamingTitle,
+ onCommitRename,
+ onRenameKeyDown,
+ onOpenThreadMenu,
+}: 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) => {
+ const api = readLocalApi();
+ if (!api) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ 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);
+ const remainingTabs = useWorkspaceTabsStore.getState().tabs;
+ const isCurrentStillOpen = remainingTabs.some((t) => t.key === currentTabKey);
+ if (!isCurrentStillOpen) {
+ handleNavigateToTab(tab);
+ }
+ break;
+ }
+ case "close-to-right": {
+ closeTabsToRight(tab.key);
+ const remainingTabs = useWorkspaceTabsStore.getState().tabs;
+ const isCurrentStillOpen = remainingTabs.some((t) => t.key === currentTabKey);
+ if (!isCurrentStillOpen) {
+ handleNavigateToTab(tab);
+ }
+ break;
+ }
+ case "close-all": {
+ closeAllTabs();
+ const { tabs: remainingTabs, activeTabKey } = useWorkspaceTabsStore.getState();
+ 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;
+ }
+ 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) {
+ reorderTabs(draggedKey, targetKey);
+ }
+ setDraggedKey(null);
+ setDragOverKey(null);
+ },
+ [draggedKey, reorderTabs],
+ );
+
+ const handleDragEnd = useCallback(() => {
+ setDraggedKey(null);
+ setDragOverKey(null);
+ }, []);
+
+ const handleWheel = useCallback((e: ReactWheelEvent) => {
+ 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 (
+
+
+
+
+
+
+ New thread
+
+ }
+ />
+ New thread
+
+
+ {effectiveTabs.map((tab) => {
+ const isActive = tab.key === currentTabKey;
+ const isDragged = tab.key === draggedKey;
+ const isDragOver = tab.key === dragOverKey;
+
+ return (
+
{
+ if (rect && onOpenThreadMenu) {
+ onOpenThreadMenu(rect);
+ }
+ }}
+ onActivate={() => 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/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts
index 842c616fe1fe..e364044d7f93 100644
--- a/apps/web/src/components/chat/providerIconUtils.ts
+++ b/apps/web/src/components/chat/providerIconUtils.ts
@@ -1,5 +1,13 @@
import { ProviderDriverKind } from "@t3tools/contracts";
-import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
+import {
+ AntigravityIcon,
+ ClaudeAI,
+ CursorIcon,
+ GrokIcon,
+ Icon,
+ OpenAI,
+ OpenCodeIcon,
+} from "../Icons";
import { PROVIDER_OPTIONS } from "../../session-logic";
export const PROVIDER_ICON_BY_PROVIDER: Partial> = {
@@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial
[ProviderDriverKind.make("opencode")]: OpenCodeIcon,
[ProviderDriverKind.make("cursor")]: CursorIcon,
[ProviderDriverKind.make("grok")]: GrokIcon,
+ [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
};
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index d57a4da1c2f0..64ffe56b356d 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
@@ -529,6 +530,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit
? ["Quit confirmation"]
: []),
+ ...(settings.soundNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.soundNotificationsEnabled
+ ? ["Turn completion chime"]
+ : []),
...(isTextGenerationModelDirty ? ["Text generation model"] : []),
],
[
@@ -537,6 +541,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.confirmQuit,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
+ settings.soundNotificationsEnabled,
settings.addProjectBaseDirectory,
settings.defaultThreadEnvMode,
settings.newWorktreesStartFromOrigin,
@@ -557,6 +562,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarAutoSettleOnMerge,
settings.sidebarProjectGroupingMode,
settings.sidebarThreadPreviewCount,
+ settings.tabsEnabled,
settings.timestampFormat,
settings.wordWrap,
followSystem,
@@ -637,6 +643,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,
@@ -649,6 +656,7 @@ export function useSettingsRestore(onRestored?: () => void) {
confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
+ soundNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.soundNotificationsEnabled,
textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
@@ -1838,6 +1846,28 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({ tabsEnabled: DEFAULT_UNIFIED_SETTINGS.tabsEnabled })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ tabsEnabled: Boolean(checked) })}
+ aria-label="Workspace tabs"
+ />
+ }
+ />
+
) : null}
+
+ updateSettings({
+ soundNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.soundNotificationsEnabled,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ soundNotificationsEnabled: Boolean(checked) })
+ }
+ aria-label="Play sound when agent completes a turn"
+ />
+ }
+ />
+
>;
@@ -47,6 +56,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
icon: ClaudeAI,
settingsSchema: ClaudeSettings,
},
+ {
+ value: ProviderDriverKind.make("antigravity"),
+ label: "Antigravity",
+ icon: AntigravityIcon,
+ settingsSchema: AntigravitySettings,
+ },
{
value: ProviderDriverKind.make("cursor"),
label: "Cursor",
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index e3aef6705665..b93569d84c46 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -160,6 +160,11 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "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/settings/general",
desktopOnly: true,
},
+ {
+ id: "sound-notifications",
+ title: "Turn completion chime",
+ to: "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/settings/general",
+ },
{
id: "text-generation-model",
title: "Text generation model",
@@ -185,6 +190,11 @@ export const SETTINGS_SEARCH_ITEMS = [
title: "Sidebar (legacy)",
to: "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/settings/general",
},
+ {
+ id: "workspace-tabs",
+ title: "Workspace tabs",
+ to: "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/settings/general",
+ },
{
id: "keybindings",
title: "Keybindings",
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx
index f4a98dec86c7..b930b79f5cb6 100644
--- a/apps/web/src/components/sidebar/SidebarChrome.tsx
+++ b/apps/web/src/components/sidebar/SidebarChrome.tsx
@@ -1,5 +1,6 @@
import {
ArrowLeftIcon,
+ ActivityIcon,
ChartNoAxesColumnIcon,
GitPullRequestIcon,
SettingsIcon,
@@ -30,6 +31,7 @@ import {
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
+import { useUnreadBackgroundThreadCount } from "../../hooks/useUnreadBackgroundThreadCount";
export const SidebarChromeHeader = memo(function SidebarChromeHeader({
isElectron,
@@ -46,6 +48,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
environmentIdentificationMode === "pill"
? resolveEnvironmentIdentificationPillLabel(stageLabel)
: null;
+ const unreadCount = useUnreadBackgroundThreadCount();
return (
{backdropVariant ? : null}
- location.pathname === "/usage"
- ? "usage"
- : location.pathname === "/pull-requests"
- ? "pull-requests"
- : null,
+ location.pathname === "/status"
+ ? "status"
+ : location.pathname === "/usage"
+ ? "usage"
+ : location.pathname === "/pull-requests"
+ ? "pull-requests"
+ : null,
});
const { environments } = useEnvironments();
// The page reads every connected server, so one of them offering pull requests is enough for
@@ -208,6 +214,25 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
) : null}
+
+
+ {
+ closeMobileSidebar();
+ void navigate({ to: "/status" });
+ }}
+ size="icon"
+ >
+
+
+ }
+ />
+ Status
+
+
{
+ it("matches the read-only Codex policy", () => {
+ expect(describeCodexRuntimeMode("approval-required")).toEqual({
+ approvalPolicy: "untrusted",
+ sandbox: "read-only",
+ writableRoots: "none",
+ });
+ });
+
+ it("matches the workspace-write Codex policies", () => {
+ expect(describeCodexRuntimeMode("auto-accept-edits")).toEqual({
+ approvalPolicy: "on-request",
+ sandbox: "workspace-write",
+ writableRoots: "workspace",
+ });
+ expect(describeCodexRuntimeMode("auto")).toEqual({
+ approvalPolicy: "on-request",
+ sandbox: "workspace-write",
+ writableRoots: "workspace",
+ });
+ });
+
+ it("matches the unrestricted Codex policy", () => {
+ expect(describeCodexRuntimeMode("full-access")).toEqual({
+ approvalPolicy: "never",
+ sandbox: "danger-full-access",
+ writableRoots: "all paths",
+ });
+ });
+});
+
+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");
+ });
+
+ 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
new file mode 100644
index 000000000000..18627e0673e9
--- /dev/null
+++ b/apps/web/src/components/status/StatusPage.logic.ts
@@ -0,0 +1,118 @@
+import type { OrchestrationSessionStatus, RuntimeMode, ServerProvider } from "@t3tools/contracts";
+
+export interface CodexRuntimeStatus {
+ readonly approvalPolicy: "untrusted" | "on-request" | "never";
+ readonly sandbox: "read-only" | "workspace-write" | "danger-full-access";
+ readonly writableRoots: "none" | "workspace" | "all paths";
+}
+
+/**
+ * T3's runtime modes are the persisted representation of the Codex thread
+ * settings. Keep this mapping next to the status surface so the values shown
+ * here stay aligned with the arguments sent to Codex App Server.
+ */
+export function describeCodexRuntimeMode(runtimeMode: RuntimeMode): CodexRuntimeStatus {
+ switch (runtimeMode) {
+ case "approval-required":
+ return {
+ approvalPolicy: "untrusted",
+ sandbox: "read-only",
+ writableRoots: "none",
+ };
+ case "auto-accept-edits":
+ return {
+ approvalPolicy: "on-request",
+ sandbox: "workspace-write",
+ writableRoots: "workspace",
+ };
+ case "auto":
+ return {
+ approvalPolicy: "on-request",
+ sandbox: "workspace-write",
+ writableRoots: "workspace",
+ };
+ case "full-access":
+ default:
+ return {
+ approvalPolicy: "never",
+ sandbox: "danger-full-access",
+ writableRoots: "all paths",
+ };
+ }
+}
+
+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" && status !== "interrupted" && status !== "error"
+ );
+}
+
+export function codexProviderStatusLabel(provider: ServerProvider): string {
+ if (!provider.enabled) return "Disabled";
+ if (!provider.installed) return "Not installed";
+ if (provider.auth.status === "unauthenticated") return "Not authenticated";
+ if (provider.status === "error") return "Unavailable";
+ if (provider.status === "warning") return "Needs attention";
+ if (provider.status === "ready") return "Ready";
+ return "Checking";
+}
+
+export function formatStatusTimestamp(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return date.toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+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 offset = formatUtcOffset(date);
+ 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)})`;
+}
diff --git a/apps/web/src/components/status/StatusPage.tsx b/apps/web/src/components/status/StatusPage.tsx
new file mode 100644
index 000000000000..fc39f372fdc4
--- /dev/null
+++ b/apps/web/src/components/status/StatusPage.tsx
@@ -0,0 +1,402 @@
+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 } from "@t3tools/contracts";
+import { ExternalLinkIcon, RefreshCwIcon, ShieldCheckIcon } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+
+import { isElectron } from "../../env";
+import { cn } from "../../lib/utils";
+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";
+import { Button } from "../ui/button";
+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,
+ 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 formatResetTimestamp(timestamp: number | null | undefined): string | null {
+ if (timestamp == null) return null;
+ return formatStatusTimestampWithTimeZone(new Date(timestamp * 1000).toISOString());
+}
+
+function StatusEmptyState({
+ title,
+ description,
+}: {
+ readonly title: string;
+ readonly description: string;
+}) {
+ return (
+
+
+
+
+
+ {title}
+ {description}
+
+
+ );
+}
+
+function RateLimitRow({
+ label,
+ valuePercent,
+ resetTimestamp,
+ kind = "remaining",
+}: {
+ readonly label: string;
+ readonly valuePercent: number;
+ readonly resetTimestamp: string | null;
+ readonly kind?: "remaining" | "used";
+}) {
+ const percent = Math.max(0, Math.min(100, valuePercent));
+ const suffix = kind === "remaining" ? "% left" : "% used";
+
+ return (
+
+
+ {label}
+
+ {percent}
+ {suffix}
+
+
+
+ {resetTimestamp ? (
+
Resets {resetTimestamp}
+ ) : null}
+
+ );
+}
+
+function CodexRateLimitsCard({
+ provider,
+ showProviderLabel,
+}: {
+ readonly provider: ServerProvider;
+ readonly showProviderLabel: boolean;
+}) {
+ const rateLimits = provider.codexStatus?.rateLimits;
+
+ return (
+
+
+
+ {showProviderLabel ? (
+
+ {provider.displayName ?? "Codex"}
+
+ ) : null}
+
+
+ Updated {formatStatusTimestampWithTimeZone(provider.checkedAt)}
+
+
+
+ {rateLimits?.primary || rateLimits?.secondary ? (
+
+ {rateLimits.limitName ? (
+ {rateLimits.limitName}
+ ) : null}
+ {rateLimits.primary ? (
+
+ ) : null}
+ {rateLimits.secondary ? (
+
+ ) : null}
+
+ ) : (
+
+ Rate limit data is not available yet. Click Update to try again.
+
+ )}
+
+ );
+}
+
+function ClaudeRateLimitsCard({
+ provider,
+ showProviderLabel,
+}: {
+ readonly provider: ServerProvider;
+ readonly showProviderLabel: boolean;
+}) {
+ const rateLimits = provider.claudeStatus?.rateLimits;
+
+ return (
+
+
+
+ {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 primaryEnvironmentId = usePrimaryEnvironmentId();
+ const providers = useAtomValue(primaryServerProvidersAtom);
+ const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
+ reportFailure: false,
+ });
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
+ const codexProviders = useMemo(
+ () => providers.filter((provider) => provider.driver === "codex"),
+ [providers],
+ );
+ const claudeProviders = useMemo(
+ () => providers.filter((provider) => provider.driver === "claudeAgent"),
+ [providers],
+ );
+ const statusProviders = useMemo(
+ () =>
+ providers.filter(
+ (provider) => provider.driver === "codex" || provider.driver === "claudeAgent",
+ ),
+ [providers],
+ );
+ const refresh = useCallback(async () => {
+ if (!primaryEnvironmentId || isRefreshing) return;
+ setIsRefreshing(true);
+ try {
+ 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);
+ }
+ }, [isRefreshing, primaryEnvironmentId, refreshProviders, statusProviders]);
+
+ return (
+
+
+ {!isElectron ? (
+
+ ) : (
+
+
+ Status
+
+
+ )}
+
+
+
+
+
+
Status
+
+ Account health and usage across your AI providers.
+
+
+
void refresh()}
+ >
+
+ Update
+
+
+
+
+
+
Codex
+
+ Current rate-limit windows and remaining credits.
+
+
+ {codexProviders.length === 0 ? (
+
+ ) : (
+
+ {codexProviders.map((provider) => (
+ 1}
+ />
+ ))}
+
+ )}
+
+
+ View up-to-date rate limits and credits
+
+
+
+
+
+
+
Claude
+
+ Current usage windows and percentage used.
+
+
+ {claudeProviders.length === 0 ? (
+
+ ) : (
+
+ {claudeProviders.map((provider) => (
+ 1}
+ />
+ ))}
+
+ )}
+
+
+ View up-to-date usage and rate limits
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx
index e2d29d607e13..e0a1c7a980da 100644
--- a/apps/web/src/components/ui/sidebar.test.tsx
+++ b/apps/web/src/components/ui/sidebar.test.tsx
@@ -50,6 +50,28 @@ describe("sidebar interactive cursors", () => {
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");
+ expect(html).toContain('aria-label="Toggle Sidebar (3 unread)"');
+ });
+
+ it("preserves explicit aria-label when unreadCount is present", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+
+ expect(html).toContain('aria-label="Expand sidebar (3 unread)"');
+ });
+
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..41c8ab3eeefc 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -318,18 +318,30 @@ function Sidebar({
);
}
-function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) {
+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 (
{
onClick?.(event);
@@ -340,6 +352,15 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps
{isOpen ? : }
+ {unreadCount !== undefined && unreadCount > 0 ? (
+
+ {unreadCount > 9 ? "9+" : unreadCount}
+
+ ) : null}
Toggle Sidebar
);
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index 590949c09d9b..2c7cb2cc4495 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -278,6 +278,12 @@ export function useLegacySidebarEnabled(): boolean {
return settingsHydrated && legacySidebarEnabled;
}
+export function useTabsEnabled(): boolean {
+ const settingsHydrated = useClientSettingsHydrated();
+ const tabsEnabled = useClientSettingsValue().tabsEnabled;
+ return !settingsHydrated || tabsEnabled;
+}
+
/** Read current settings for one environment, merged with client-local preferences. */
export function useEnvironmentSettings(
environmentId: EnvironmentId,
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({
diff --git a/apps/web/src/hooks/useTurnCompletionSound.test.ts b/apps/web/src/hooks/useTurnCompletionSound.test.ts
new file mode 100644
index 000000000000..93029ca32ddd
--- /dev/null
+++ b/apps/web/src/hooks/useTurnCompletionSound.test.ts
@@ -0,0 +1,174 @@
+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 { detectNewTurnCompletions } from "./useTurnCompletionSound";
+
+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-15T07:00:00.000Z",
+ updatedAt: "2026-08-15T07:00:00.000Z",
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ session: null,
+ latestUserMessageAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ hasActionableProposedPlan: false,
+ ...overrides,
+ };
+}
+
+describe("detectNewTurnCompletions", () => {
+ it("does not trigger chime on initial snapshot or environment hydration of settled threads", () => {
+ const thread = createMockThread("thread-1", {
+ session: { status: "idle", activeTurnId: null, updatedAt: "2026-08-15T07:02:00.000Z" } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-1"),
+ state: "completed",
+ requestedAt: "2026-08-15T07:01:00.000Z",
+ startedAt: "2026-08-15T07:01:01.000Z",
+ completedAt: "2026-08-15T07:02:00.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+
+ const result = detectNewTurnCompletions([thread], {});
+ expect(result.hasNewCompletion).toBe(false);
+ expect(result.nextCompletions[key]).toBe("2026-08-15T07:02:00.000Z");
+ });
+
+ it("does not trigger chime during mid-turn checkpoints while state or session is running", () => {
+ const thread = createMockThread("thread-1", {
+ session: {
+ status: "running",
+ activeTurnId: TurnId.make("turn-1"),
+ updatedAt: "2026-08-15T07:01:30.000Z",
+ } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-1"),
+ state: "running",
+ requestedAt: "2026-08-15T07:01:00.000Z",
+ startedAt: "2026-08-15T07:01:01.000Z",
+ completedAt: "2026-08-15T07:01:30.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+
+ const result = detectNewTurnCompletions([thread], {});
+ expect(result.hasNewCompletion).toBe(false);
+ expect(result.nextCompletions[key]).toBe("running");
+ });
+
+ it("triggers chime when a running thread settles and completes", () => {
+ const thread = createMockThread("thread-1", {
+ session: { status: "idle", activeTurnId: null, updatedAt: "2026-08-15T07:02:00.000Z" } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-1"),
+ state: "completed",
+ requestedAt: "2026-08-15T07:01:00.000Z",
+ startedAt: "2026-08-15T07:01:01.000Z",
+ completedAt: "2026-08-15T07:02:00.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const previous = { [key]: "running" };
+
+ const result = detectNewTurnCompletions([thread], previous);
+ expect(result.hasNewCompletion).toBe(true);
+ expect(result.nextCompletions[key]).toBe("2026-08-15T07:02:00.000Z");
+ });
+
+ it("triggers chime when a previously completed thread completes a new turn", () => {
+ const thread = createMockThread("thread-1", {
+ session: { status: "idle", activeTurnId: null, updatedAt: "2026-08-15T07:06:00.000Z" } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-2"),
+ state: "completed",
+ requestedAt: "2026-08-15T07:05:00.000Z",
+ startedAt: "2026-08-15T07:05:01.000Z",
+ completedAt: "2026-08-15T07:06:00.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const previous = { [key]: "2026-08-15T07:02:00.000Z" };
+
+ const result = detectNewTurnCompletions([thread], previous);
+ expect(result.hasNewCompletion).toBe(true);
+ expect(result.nextCompletions[key]).toBe("2026-08-15T07:06:00.000Z");
+ });
+
+ it("ignores unchanged completed threads on subsequent renders", () => {
+ const thread = createMockThread("thread-1", {
+ session: { status: "idle", activeTurnId: null, updatedAt: "2026-08-15T07:06:00.000Z" } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-2"),
+ state: "completed",
+ requestedAt: "2026-08-15T07:05:00.000Z",
+ startedAt: "2026-08-15T07:05:01.000Z",
+ completedAt: "2026-08-15T07:06:00.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const previous = { [key]: "2026-08-15T07:06:00.000Z" };
+
+ const result = detectNewTurnCompletions([thread], previous);
+ expect(result.hasNewCompletion).toBe(false);
+ });
+
+ it("clears tracked state when thread is archived and does not chime on unarchiving an already-settled thread", () => {
+ const archivedThread = createMockThread("thread-1", {
+ archivedAt: "2026-08-15T07:04:00.000Z",
+ session: { status: "idle", activeTurnId: null, updatedAt: "2026-08-15T07:06:00.000Z" } as any,
+ latestTurn: {
+ turnId: TurnId.make("turn-2"),
+ state: "completed",
+ requestedAt: "2026-08-15T07:05:00.000Z",
+ startedAt: "2026-08-15T07:05:01.000Z",
+ completedAt: "2026-08-15T07:06:00.000Z",
+ assistantMessageId: null,
+ },
+ });
+ const key = scopedThreadKey(scopeThreadRef(archivedThread.environmentId, archivedThread.id));
+ const previous = { [key]: "running" };
+
+ const archivedResult = detectNewTurnCompletions([archivedThread], previous);
+ expect(archivedResult.hasNewCompletion).toBe(false);
+ expect(archivedResult.nextCompletions[key]).toBeUndefined();
+
+ const unarchivedThread = { ...archivedThread, archivedAt: null };
+ const unarchivedResult = detectNewTurnCompletions(
+ [unarchivedThread],
+ archivedResult.nextCompletions,
+ );
+ expect(unarchivedResult.hasNewCompletion).toBe(false);
+ expect(unarchivedResult.nextCompletions[key]).toBe("2026-08-15T07:06:00.000Z");
+ });
+});
diff --git a/apps/web/src/hooks/useTurnCompletionSound.ts b/apps/web/src/hooks/useTurnCompletionSound.ts
new file mode 100644
index 000000000000..f94170c5964a
--- /dev/null
+++ b/apps/web/src/hooks/useTurnCompletionSound.ts
@@ -0,0 +1,73 @@
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import { useEffect, useRef } from "react";
+
+import { playTurnCompletionSound } from "~/audio/turnChime";
+import { useClientSettings } from "./useSettings";
+import { useThreadShells } from "~/state/entities";
+import { isLatestTurnSettled } from "~/session-logic";
+
+export function detectNewTurnCompletions(
+ threads: ReadonlyArray,
+ previousCompletions: Readonly>,
+): {
+ readonly hasNewCompletion: boolean;
+ readonly nextCompletions: Record;
+} {
+ const nextCompletions: Record = { ...previousCompletions };
+ let hasNewCompletion = false;
+
+ for (const thread of threads) {
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ if (thread.archivedAt !== null) {
+ delete nextCompletions[threadKey];
+ continue;
+ }
+ const previousState = previousCompletions[threadKey];
+
+ const isSettled =
+ isLatestTurnSettled(thread.latestTurn, thread.session) &&
+ thread.latestTurn?.state !== "running" &&
+ Boolean(thread.latestTurn?.completedAt);
+
+ if (!isSettled) {
+ if (thread.session?.status === "running" || thread.latestTurn?.state === "running") {
+ nextCompletions[threadKey] = "running";
+ }
+ continue;
+ }
+
+ const completedAt = thread.latestTurn?.completedAt;
+ if (!completedAt) continue;
+
+ if (previousState === undefined) {
+ nextCompletions[threadKey] = completedAt;
+ continue;
+ }
+
+ if (previousState !== completedAt) {
+ nextCompletions[threadKey] = completedAt;
+ hasNewCompletion = true;
+ }
+ }
+
+ return { hasNewCompletion, nextCompletions };
+}
+
+export function useTurnCompletionSound(): void {
+ const settings = useClientSettings();
+ const threads = useThreadShells();
+ const knownCompletionsRef = useRef>({});
+
+ useEffect(() => {
+ const { hasNewCompletion, nextCompletions } = detectNewTurnCompletions(
+ threads,
+ knownCompletionsRef.current,
+ );
+ knownCompletionsRef.current = nextCompletions;
+
+ if (hasNewCompletion && settings.soundNotificationsEnabled) {
+ playTurnCompletionSound();
+ }
+ }, [threads, settings.soundNotificationsEnabled]);
+}
diff --git a/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts b/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts
new file mode 100644
index 000000000000..5fcbbe1907cb
--- /dev/null
+++ b/apps/web/src/hooks/useUnreadBackgroundThreadCount.test.ts
@@ -0,0 +1,160 @@
+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", () => {
+ 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)).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)).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")).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")).toBe(
+ false,
+ );
+ });
+
+ it("returns false when 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")).toBe(false);
+ });
+});
+
+describe("countUnreadBackgroundThreads", () => {
+ 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")).toBe(
+ 1,
+ );
+ expect(countUnreadBackgroundThreads([thread1, thread2, thread3], visits, key1)).toBe(0);
+ });
+});
diff --git a/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts b/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts
new file mode 100644
index 000000000000..126952a4eba9
--- /dev/null
+++ b/apps/web/src/hooks/useUnreadBackgroundThreadCount.ts
@@ -0,0 +1,58 @@
+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";
+
+export function isThreadUnreadBackground(
+ thread: EnvironmentThreadShell,
+ lastVisitedAt: string | undefined,
+ currentRouteThreadKey: string | null,
+): boolean {
+ if (thread.archivedAt !== null) return false;
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ if (threadKey === currentRouteThreadKey) return false;
+
+ return hasUnseenCompletion({ ...thread, lastVisitedAt });
+}
+
+export function countUnreadBackgroundThreads(
+ threads: ReadonlyArray,
+ threadLastVisitedAtById: Readonly>,
+ currentRouteThreadKey: string | null,
+): 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)) {
+ 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],
+ );
+}
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/__root.tsx b/apps/web/src/routes/__root.tsx
index c4f65564efc7..182abeefd227 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -31,6 +31,7 @@ import {
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { applyAppearanceFontVariables } from "~/appearanceFonts";
import { useClientSettings } from "../hooks/useSettings";
+import { useTurnCompletionSound } from "../hooks/useTurnCompletionSound";
import {
deriveLogicalProjectKeyFromSettings,
derivePhysicalProjectKeyFromPath,
@@ -132,6 +133,7 @@ function RootRouteView() {
+
{primaryEnvironmentAuthenticated ? : null}
@@ -150,6 +152,11 @@ function RootRouteView() {
);
}
+function TurnCompletionSoundSync() {
+ useTurnCompletionSound();
+ return null;
+}
+
function GlassAppearanceSync() {
const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
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/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,
+});
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index cd5e2aa11d74..d92698067710 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -34,6 +34,12 @@ export const PROVIDER_OPTIONS: Array<{
}> = [
{ value: ProviderDriverKind.make("codex"), label: "Codex", available: true },
{ value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true },
+ {
+ value: ProviderDriverKind.make("antigravity"),
+ label: "Antigravity",
+ available: true,
+ pickerSidebarBadge: "new",
+ },
{
value: ProviderDriverKind.make("opencode"),
label: "OpenCode",
diff --git a/apps/web/src/workspaceTabsStore.test.ts b/apps/web/src/workspaceTabsStore.test.ts
new file mode 100644
index 000000000000..4c092bb698d3
--- /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(tab3.key, tab1.key);
+
+ 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..67d7ec153598
--- /dev/null
+++ b/apps/web/src/workspaceTabsStore.ts
@@ -0,0 +1,210 @@
+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: (sourceKey: string, targetKey: string) => 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 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
+ : 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: (sourceKey: string, targetKey: string) => {
+ set((state) => {
+ 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];
+ 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/docs/internals/providers.md b/docs/internals/providers.md
index a309d70f03de..562c3c0f8bcd 100644
--- a/docs/internals/providers.md
+++ b/docs/internals/providers.md
@@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread.
## Built-in drivers
-[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
+[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
-| Driver kind | Driver source |
-| ------------- | --------------------------------------- |
-| `codex` | [`Drivers/CodexDriver.ts`][codex] |
-| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
-| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
-| `grok` | [`Drivers/GrokDriver.ts`][grok] |
-| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+| Driver kind | Driver source |
+| ------------- | --------------------------------------------- |
+| `codex` | [`Drivers/CodexDriver.ts`][codex] |
+| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
+| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
+| `grok` | [`Drivers/GrokDriver.ts`][grok] |
+| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
+| `antigravity` | [`Drivers/AntigravityDriver.ts`][antigravity] |
Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
adapter in a child scope. Adapter implementations live beside them in
@@ -81,6 +82,7 @@ when a request opens (approval) or user input is requested, via
[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
+[antigravity]: ../../apps/server/src/provider/Drivers/AntigravityDriver.ts
[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
[registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 01600af4699f..f5688fc09371 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -39,6 +39,10 @@
"types": "./src/relay/index.ts",
"default": "./src/relay/index.ts"
},
+ "./handoff": {
+ "types": "./src/handoff/index.ts",
+ "default": "./src/handoff/index.ts"
+ },
"./state/auth": {
"types": "./src/state/auth.ts",
"default": "./src/state/auth.ts"
diff --git a/packages/client-runtime/src/handoff/index.ts b/packages/client-runtime/src/handoff/index.ts
new file mode 100644
index 000000000000..de5ad64af511
--- /dev/null
+++ b/packages/client-runtime/src/handoff/index.ts
@@ -0,0 +1 @@
+export * from "./threadHandoff.ts";
diff --git a/packages/client-runtime/src/handoff/threadHandoff.test.ts b/packages/client-runtime/src/handoff/threadHandoff.test.ts
new file mode 100644
index 000000000000..2d54ba353030
--- /dev/null
+++ b/packages/client-runtime/src/handoff/threadHandoff.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from "vitest";
+import { buildThreadHandoffMarkdown } from "./threadHandoff.ts";
+
+describe("buildThreadHandoffMarkdown", () => {
+ it("generates structured handoff from Claude to Codex with full context", () => {
+ const markdown = buildThreadHandoffMarkdown({
+ thread: {
+ id: "thread-123",
+ title: "Implement Antigravity driver",
+ branch: "feat/antigravity",
+ worktreePath: null,
+ modelSelection: {
+ instanceId: "claudeAgent",
+ model: "claude-3-7-sonnet",
+ },
+ messages: [
+ {
+ role: "user",
+ text: "Create the Antigravity driver and fix the process cleanup.",
+ },
+ {
+ role: "assistant",
+ text: "I implemented AntigravityAdapter and added unit tests in AntigravityAdapter.test.ts.",
+ },
+ {
+ role: "user",
+ text: "Now make sure turn interruption emits turn.completed with state interrupted.",
+ },
+ ],
+ activities: [
+ {
+ tone: "tool",
+ kind: "file",
+ summary: "Edited AntigravityAdapter.ts",
+ payload: { filePath: "apps/server/src/provider/Layers/AntigravityAdapter.ts" },
+ },
+ {
+ tone: "tool",
+ kind: "terminal",
+ summary: "pnpm test run AntigravityAdapter.test.ts",
+ },
+ ],
+ proposedPlans: [
+ {
+ planMarkdown: "- [x] Add AntigravityAdapter\n- [ ] Update interruptTurn logic",
+ },
+ ],
+ checkpoints: [
+ {
+ files: [
+ {
+ path: "apps/server/src/provider/Layers/AntigravityAdapter.ts",
+ additions: 50,
+ deletions: 10,
+ },
+ ],
+ },
+ ],
+ },
+ targetModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.3-codex",
+ },
+ });
+
+ expect(markdown).toContain("# Task Continuation Context");
+ expect(markdown).toContain(
+ "Continuing from **claudeAgent (claude-3-7-sonnet)** to **codex (gpt-5.3-codex)**.",
+ );
+ expect(markdown).toContain('Source thread: "Implement Antigravity driver" (ID: `thread-123`)');
+ expect(markdown).toContain("## ๐ฏ Original Goal & Instructions");
+ expect(markdown).toContain("Create the Antigravity driver and fix the process cleanup.");
+ expect(markdown).toContain("## ๐ Work Completed & Key Decisions");
+ expect(markdown).toContain("I implemented AntigravityAdapter and added unit tests");
+ expect(markdown).toContain("## ๐ Modified & Relevant Files");
+ expect(markdown).toContain("- `apps/server/src/provider/Layers/AntigravityAdapter.ts`");
+ expect(markdown).toContain("## โ๏ธ Key Actions & Commands Executed");
+ expect(markdown).toContain("- pnpm test run AntigravityAdapter.test.ts");
+ expect(markdown).toContain("## ๐ Execution Plan");
+ expect(markdown).toContain("- [x] Add AntigravityAdapter");
+ expect(markdown).toContain("## โญ๏ธ Latest Request / Next Immediate Step");
+ expect(markdown).toContain(
+ "Now make sure turn interruption emits turn.completed with state interrupted.",
+ );
+ });
+
+ it("handles Codex to Claude transition cleanly", () => {
+ const markdown = buildThreadHandoffMarkdown({
+ thread: {
+ id: "thread-456",
+ title: "Fix Linux dock icon",
+ modelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.3-codex",
+ },
+ messages: [
+ {
+ role: "user",
+ text: "Fix the Linux dock icon grouping in Electron.",
+ },
+ ],
+ },
+ targetModelSelection: {
+ instanceId: "claudeAgent",
+ model: "claude-3-7-sonnet",
+ },
+ });
+
+ expect(markdown).toContain(
+ "Continuing from **codex (gpt-5.3-codex)** to **claudeAgent (claude-3-7-sonnet)**.",
+ );
+ expect(markdown).toContain("Fix the Linux dock icon grouping in Electron.");
+ });
+
+ it("handles source thread with missing model or offline provider", () => {
+ const markdown = buildThreadHandoffMarkdown({
+ thread: {
+ id: "thread-789",
+ title: "Offline source thread",
+ modelSelection: null,
+ messages: [
+ {
+ role: "user",
+ text: "Initial prompt before provider crash.",
+ },
+ ],
+ },
+ targetModelSelection: {
+ instanceId: "antigravity",
+ model: "gemini-3.7-flash",
+ },
+ });
+
+ expect(markdown).toContain(
+ "Continuing from **previous model** to **antigravity (gemini-3.7-flash)**.",
+ );
+ expect(markdown).toContain("Initial prompt before provider crash.");
+ });
+});
diff --git a/packages/client-runtime/src/handoff/threadHandoff.ts b/packages/client-runtime/src/handoff/threadHandoff.ts
new file mode 100644
index 000000000000..f7930e955193
--- /dev/null
+++ b/packages/client-runtime/src/handoff/threadHandoff.ts
@@ -0,0 +1,191 @@
+export interface ThreadHandoffInput {
+ readonly thread: {
+ readonly id: string;
+ readonly title: string;
+ readonly branch?: string | null;
+ readonly worktreePath?: string | null;
+ readonly modelSelection?:
+ | {
+ readonly instanceId: string;
+ readonly model: string;
+ }
+ | null
+ | undefined;
+ readonly messages?:
+ | ReadonlyArray<{
+ readonly role: "user" | "assistant" | "system";
+ readonly text: string;
+ readonly attachments?:
+ | ReadonlyArray<{
+ readonly name?: string | undefined;
+ readonly path?: string | undefined;
+ }>
+ | undefined;
+ }>
+ | undefined;
+ readonly activities?:
+ | ReadonlyArray<{
+ readonly tone: string;
+ readonly kind: string;
+ readonly summary: string;
+ readonly payload?: unknown | undefined;
+ }>
+ | undefined;
+ readonly proposedPlans?:
+ | ReadonlyArray<{
+ readonly planMarkdown: string;
+ }>
+ | undefined;
+ readonly checkpoints?:
+ | ReadonlyArray<{
+ readonly files: ReadonlyArray<{
+ readonly path: string;
+ readonly kind?: string | undefined;
+ readonly additions?: number | undefined;
+ readonly deletions?: number | undefined;
+ }>;
+ }>
+ | undefined;
+ };
+ readonly targetModelSelection?: {
+ readonly instanceId: string;
+ readonly model: string;
+ } | null;
+}
+
+export function buildThreadHandoffMarkdown(input: ThreadHandoffInput): string {
+ const { thread, targetModelSelection } = input;
+ const sections: string[] = [];
+
+ const sourceModelLabel = thread.modelSelection
+ ? `${thread.modelSelection.instanceId} (${thread.modelSelection.model})`
+ : "previous model";
+ const targetModelLabel = targetModelSelection
+ ? `${targetModelSelection.instanceId} (${targetModelSelection.model})`
+ : "new model";
+
+ sections.push(
+ `# Task Continuation Context\n` +
+ `> Continuing from **${sourceModelLabel}** to **${targetModelLabel}**.\n` +
+ `> Source thread: "${thread.title}" (ID: \`${thread.id}\`)`,
+ );
+
+ const messages = (thread.messages ?? []).filter(
+ (m) => m.text.trim().length > 0 || (m.attachments && m.attachments.length > 0),
+ );
+ const userMessages = messages.filter((m) => m.role === "user");
+ const assistantMessages = messages.filter((m) => m.role === "assistant");
+
+ if (userMessages.length > 0) {
+ const firstMsg = userMessages[0];
+ const originalGoal = firstMsg?.text.trim();
+ const attachmentNames = (firstMsg?.attachments ?? [])
+ .map((a) => a.name || a.path)
+ .filter((n): n is string => Boolean(n));
+
+ let goalContent = originalGoal || "";
+ if (attachmentNames.length > 0) {
+ goalContent += `\n\nAttachments: ${attachmentNames.map((n) => `\`${n}\``).join(", ")}`;
+ }
+
+ if (goalContent.trim().length > 0) {
+ sections.push(`## ๐ฏ Original Goal & Instructions\n${goalContent.trim()}`);
+ }
+ }
+
+ if (assistantMessages.length > 0) {
+ const decisions: string[] = [];
+ for (const msg of assistantMessages) {
+ const text = msg.text.trim();
+ if (text.length > 0) {
+ decisions.push(text);
+ }
+ }
+ if (decisions.length > 0) {
+ const decisionSummary = decisions
+ .map((d, index) => {
+ if (decisions.length === 1) return d;
+ return `### Step ${index + 1} Summary\n${d}`;
+ })
+ .join("\n\n");
+ sections.push(`## ๐ Work Completed & Key Decisions\n${decisionSummary}`);
+ }
+ }
+
+ const modifiedFiles = new Set();
+ if (thread.checkpoints) {
+ for (const checkpoint of thread.checkpoints) {
+ for (const file of checkpoint.files) {
+ if (file.path) {
+ modifiedFiles.add(file.path);
+ }
+ }
+ }
+ }
+ if (thread.activities) {
+ for (const activity of thread.activities) {
+ if (activity.payload && typeof activity.payload === "object") {
+ const payload = activity.payload as Record;
+ if (typeof payload.filePath === "string") {
+ modifiedFiles.add(payload.filePath);
+ } else if (typeof payload.path === "string") {
+ modifiedFiles.add(payload.path);
+ } else if (Array.isArray(payload.files)) {
+ for (const f of payload.files) {
+ if (typeof f === "string") modifiedFiles.add(f);
+ else if (
+ f &&
+ typeof f === "object" &&
+ typeof (f as { path?: unknown }).path === "string"
+ ) {
+ modifiedFiles.add((f as { path: string }).path);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (modifiedFiles.size > 0) {
+ const fileList = Array.from(modifiedFiles)
+ .sort()
+ .map((f) => `- \`${f}\``)
+ .join("\n");
+ sections.push(`## ๐ Modified & Relevant Files\n${fileList}`);
+ }
+
+ if (thread.activities && thread.activities.length > 0) {
+ const relevantCommands: string[] = [];
+ for (const act of thread.activities) {
+ if (
+ act.kind === "terminal" ||
+ act.tone === "tool" ||
+ act.summary.toLowerCase().includes("run") ||
+ act.summary.toLowerCase().includes("test")
+ ) {
+ relevantCommands.push(`- ${act.summary}`);
+ }
+ }
+ if (relevantCommands.length > 0) {
+ const deduped = Array.from(new Set(relevantCommands)).slice(-10);
+ sections.push(`## โ๏ธ Key Actions & Commands Executed\n${deduped.join("\n")}`);
+ }
+ }
+
+ if (thread.proposedPlans && thread.proposedPlans.length > 0) {
+ const latestPlan = thread.proposedPlans[thread.proposedPlans.length - 1];
+ if (latestPlan && latestPlan.planMarkdown.trim().length > 0) {
+ sections.push(`## ๐ Execution Plan\n${latestPlan.planMarkdown.trim()}`);
+ }
+ }
+
+ if (userMessages.length > 0) {
+ const latestUserMsg = userMessages[userMessages.length - 1];
+ const latestText = latestUserMsg?.text.trim();
+ if (latestText && (userMessages.length > 1 || !sections.some((s) => s.includes(latestText)))) {
+ sections.push(`## โญ๏ธ Latest Request / Next Immediate Step\n${latestText}`);
+ }
+ }
+
+ return sections.join("\n\n");
+}
diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts
index 9fcd0d266dd6..1a5f5ae7d20a 100644
--- a/packages/contracts/src/model.ts
+++ b/packages/contracts/src/model.ts
@@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
+const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity");
export const DEFAULT_MODEL = "gpt-5.6-sol";
@@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial>
[CURSOR_DRIVER_KIND]: "Cursor",
[GROK_DRIVER_KIND]: "Grok",
[OPENCODE_DRIVER_KIND]: "OpenCode",
+ [ANTIGRAVITY_DRIVER_KIND]: "Antigravity",
};
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 9791a4f62185..0061d439e023 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -96,6 +96,57 @@ 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;
+
+/** 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.
*
@@ -192,6 +243,10 @@ 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),
+ /** Live plan rate-limit data returned by Claude Code's Agent SDK. */
+ claudeStatus: Schema.optionalKey(ServerProviderClaudeStatus),
versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
updateState: Schema.optionalKey(ServerProviderUpdateState),
});
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 570157292b54..c2aed20fbe1a 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -33,6 +33,21 @@ describe("ClientSettings word wrap", () => {
});
});
+describe("ClientSettings sound notifications", () => {
+ it("defaults sound notifications on", () => {
+ expect(decodeClientSettings({}).soundNotificationsEnabled).toBe(true);
+ });
+
+ it("decodes explicit sound notifications setting", () => {
+ expect(
+ decodeClientSettings({ soundNotificationsEnabled: false }).soundNotificationsEnabled,
+ ).toBe(false);
+ expect(
+ decodeClientSettingsPatch({ soundNotificationsEnabled: false }).soundNotificationsEnabled,
+ ).toBe(false);
+ });
+});
+
describe("ClientSettings glass opacity", () => {
it("defaults to a readable translucent surface", () => {
expect(decodeClientSettings({}).glassOpacity).toBe(80);
@@ -71,10 +86,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..0f6367c77896 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)),
),
@@ -203,6 +204,7 @@ export const ClientSettingsSchema = Schema.Struct({
timestampFormat: TimestampFormat.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
),
+ soundNotificationsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
});
export type ClientSettings = typeof ClientSettingsSchema.Type;
@@ -475,6 +477,75 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
);
export type OpenCodeSettings = typeof OpenCodeSettings.Type;
+export const AntigravitySettings = makeProviderSettingsSchema(
+ {
+ enabled: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ binaryPath: makeBinaryPathSetting("agy").pipe(
+ Schema.annotateKey({
+ title: "Binary path",
+ description: "Path to the Antigravity (agy) binary.",
+ providerSettingsForm: {
+ placeholder: "agy",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ dangerouslySkipPermissions: Schema.Boolean.pipe(
+ Schema.withDecodingDefault(Effect.succeed(true)),
+ Schema.annotateKey({
+ title: "Skip permissions",
+ description: "Auto-approve all tool permission requests without prompting.",
+ }),
+ ),
+ effort: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("high")),
+ Schema.annotateKey({
+ title: "Reasoning effort",
+ description: "Reasoning effort for CLI sessions (low, medium, high).",
+ }),
+ ),
+ accountEmail: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ Schema.annotateKey({
+ title: "Account email",
+ description: "Account email displayed in status cards. Defaults to detected user account.",
+ providerSettingsForm: {
+ placeholder: "user@example.com",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ subscriptionLabel: TrimmedString.pipe(
+ Schema.withDecodingDefault(Effect.succeed("Gemini Pro Subscription")),
+ Schema.annotateKey({
+ title: "Subscription label",
+ description: "Subscription tier label displayed in status cards.",
+ providerSettingsForm: {
+ placeholder: "Gemini Pro Subscription",
+ clearWhenEmpty: "omit",
+ },
+ }),
+ ),
+ customModels: Schema.Array(Schema.String).pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
+ ),
+ },
+ {
+ order: [
+ "binaryPath",
+ "accountEmail",
+ "subscriptionLabel",
+ "effort",
+ "dangerouslySkipPermissions",
+ ],
+ },
+);
+export type AntigravitySettings = typeof AntigravitySettings.Type;
+
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -605,6 +676,7 @@ export const ServerSettings = Schema.Struct({
cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
}).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
// are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -708,6 +780,16 @@ const OpenCodeSettingsPatch = Schema.Struct({
customModels: Schema.optionalKey(Schema.Array(Schema.String)),
});
+const AntigravitySettingsPatch = Schema.Struct({
+ enabled: Schema.optionalKey(Schema.Boolean),
+ binaryPath: Schema.optionalKey(TrimmedString),
+ dangerouslySkipPermissions: Schema.optionalKey(Schema.Boolean),
+ effort: Schema.optionalKey(TrimmedString),
+ accountEmail: Schema.optionalKey(TrimmedString),
+ subscriptionLabel: Schema.optionalKey(TrimmedString),
+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
+});
+
export const ServerSettingsPatch = Schema.Struct({
// Server settings
enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
@@ -748,6 +830,7 @@ export const ServerSettingsPatch = Schema.Struct({
cursor: Schema.optionalKey(CursorSettingsPatch),
grok: Schema.optionalKey(GrokSettingsPatch),
opencode: Schema.optionalKey(OpenCodeSettingsPatch),
+ antigravity: Schema.optionalKey(AntigravitySettingsPatch),
}),
),
// Whole-map replacement for the new instance config. Patching individual
@@ -797,6 +880,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),
@@ -807,6 +891,7 @@ export const ClientSettingsPatch = Schema.Struct({
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
timestampFormat: Schema.optionalKey(TimestampFormat),
+ soundNotificationsEnabled: Schema.optionalKey(Schema.Boolean),
wordWrap: Schema.optionalKey(Schema.Boolean),
});
export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;