= new Set([
+ "/settings/archived",
+ "/settings/diagnostics",
]);
interface SettingsScopeMenuProps {
@@ -77,7 +83,9 @@ export function SettingsScopeSentence() {
return (
- Applying settings for
+
+ {SETTINGS_VIEW_ONLY_PATHS.has(pathname) ? "Showing" : "Applying settings for"}
+
diff --git a/apps/web/src/components/settings/SidebarVariantSetting.tsx b/apps/web/src/components/settings/SidebarVariantSetting.tsx
new file mode 100644
index 0000000000000..160c3647bab3e
--- /dev/null
+++ b/apps/web/src/components/settings/SidebarVariantSetting.tsx
@@ -0,0 +1,142 @@
+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
+import { DEFAULT_UNIFIED_SETTINGS, SidebarVariant } from "@t3tools/contracts/settings";
+import { CheckIcon } from "lucide-react";
+
+import { cn } from "~/lib/utils";
+import { RadioGroup } from "../ui/radio-group";
+import { SettingResetButton, SettingsRow } from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
+
+const OPTIONS: Record = {
+ default: {
+ label: "Default",
+ description: "T3 Code's sidebar. The settings below shape it.",
+ },
+ detailed: {
+ label: "Detailed",
+ description: "Threads stay in their project. Workers nest under them. Answer on the row.",
+ },
+ slim: {
+ label: "Slim",
+ description: "One line per thread in project folders. Workers fold to a count.",
+ },
+};
+
+function PreviewLine(props: { className?: string }) {
+ return ;
+}
+
+function PreviewRows({ variant }: { variant: SidebarVariant }) {
+ switch (variant) {
+ case "default":
+ return (
+ <>
+ {["w-4/5", "w-3/5"].map((width) => (
+
+
+
+
+
+ ))}
+
+ >
+ );
+ case "detailed":
+ return (
+ <>
+
+ {["w-4/5", "w-3/5"].map((width, index) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+ case "slim":
+ return (
+ <>
+
+ {["w-4/5", "w-2/3", "w-3/4", "w-3/5"].map((width, index) => (
+
+
+
+
+ ))}
+ >
+ );
+ }
+}
+
+/** The sidebar variant picker; it applies at once, and the stored Boring settings stay for Default. */
+export function SidebarVariantRow() {
+ const settings = useScopedSettings();
+ const updateSettings = useUpdateScopedSettings();
+ return (
+
+ updateSettings({ sidebarVariant: DEFAULT_UNIFIED_SETTINGS.sidebarVariant })
+ }
+ />
+ ) : null
+ }
+ >
+ updateSettings({ sidebarVariant: value as SidebarVariant })}
+ className="mt-3 mb-2 grid grid-cols-1 @min-[32rem]/settings-row:grid-cols-3"
+ >
+ {SidebarVariant.literals.map((variant) => (
+
+
+
+
+
+
+ {OPTIONS[variant].label}
+
+
+
+
+ {OPTIONS[variant].description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index f7492035874da..dab11282a1d1d 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -455,6 +455,18 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/threads",
searchTerms: ["usage quota rate limit reset recover continue"],
},
+ {
+ id: "sidebar-variant",
+ title: "Sidebar layout",
+ to: "/settings/general",
+ searchTerms: ["sidebar variant detailed slim default rows workers answers"],
+ },
+ {
+ id: "sidebar-hide-lone-provider",
+ title: "Hide lone provider logo",
+ to: "/settings/general",
+ searchTerms: ["provider logo icon sidebar row single one hide"],
+ },
{
id: "working-shelf",
title: "Working section",
@@ -664,12 +676,30 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["notification sound alert completion input approval desktop"],
},
+ {
+ id: "window-title-waiting-count",
+ title: "Waiting count in window title",
+ to: "/settings/general",
+ searchTerms: ["window title tab count waiting approval needs you badge"],
+ },
{
id: "in-app-notifications",
title: "In-app notifications",
to: "/settings/general",
searchTerms: ["notification toast popup completion input approval failure"],
},
+ {
+ id: "terminal-actions-strip",
+ title: "Terminal actions strip",
+ to: "/settings/general",
+ searchTerms: ["terminal actions buttons strip overlap output first line"],
+ },
+ {
+ id: "chat-working-shimmer",
+ title: "Working shimmer",
+ to: "/settings/general",
+ searchTerms: ["shimmer animation working row motion chat timeline"],
+ },
{
id: "time-format",
title: "Time format",
@@ -713,6 +743,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/chat",
searchTerms: ["command menu dollar $ slash /"],
},
+ {
+ id: "composer-quiet-stop",
+ title: "Quiet stop button",
+ to: "/settings/chat",
+ searchTerms: ["composer stop interrupt button red quiet neutral waiting approval"],
+ },
{
id: "composer-rich-text",
title: "Rich text composer",
diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
index 3a50e76d06bda..f16c5938367f4 100644
--- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
@@ -5,6 +5,7 @@ import { discardComposerDraft } from "../../lib/discardComposerDraft";
import { requestCustomSnooze } from "../CustomSnoozeDialog";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { useDeadlinePassed } from "~/hooks/useDeadline";
+import { useClientSettings } from "~/hooks/useSettings";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests";
import { useDraggable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
@@ -119,6 +120,8 @@ import { Menu, MenuItem, MenuItemDetail, MenuPopup, MenuSeparator, MenuTrigger }
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { MiddleTruncate } from "../ui/middle-truncate";
import { useThreadHasUnsentDraft } from "../../composerDraftStore";
+import type { VariantRowStyle } from "./variants/sidebarVariant.logic";
+import { ROW_PILL_CLASS_NAME, RowProviderMark, resolveVariantRow } from "./variants/variantRow";
// The fields thread rows show. Provider status pushes refresh `checkedAt`,
// status and auth on every provider; leaving those out keeps each
@@ -180,14 +183,25 @@ function terminalProcessLabel(count: number): string {
return `${count} terminal ${count === 1 ? "process" : "processes"} running`;
}
+function hidesLoneProvider(
+ thread: SidebarThreadSummary,
+ providerEntryByInstanceId: ReadonlyMap,
+ hideLone: boolean | undefined,
+): boolean {
+ const stack = resolveThreadProviderStack(thread);
+ return hideLone === true && stack.length === 1 && providerEntryByInstanceId.size < 2;
+}
+
function SidebarProviderStack(props: {
thread: SidebarThreadSummary;
providerEntryByInstanceId: ReadonlyMap;
+ hideLone?: boolean;
}) {
const stack = resolveThreadProviderStack(props.thread);
const currentInstanceId = stack[stack.length - 1]!;
const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null;
if (currentEntry === null) return null;
+ if (hidesLoneProvider(props.thread, props.providerEntryByInstanceId, props.hideLone)) return null;
const showInstanceBadge = shouldShowInstanceBadge(
currentEntry,
props.providerEntryByInstanceId.values(),
@@ -391,7 +405,7 @@ function SnoozeMenuButton(props: {
aria-label="Snooze thread"
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
- className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+ className="relative after:absolute after:-inset-y-0.5 after:inset-x-0 inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
/>
}
/>
@@ -537,7 +551,13 @@ type SidebarThreadRowProps = {
thread: SidebarThreadSummary;
// "folder" is a one-line row inside a boring sidebar project folder;
// "folder-multiline" adds the card's branch and PR line under it.
- variant: "card" | "slim" | "folder" | "folder-multiline";
+ variant: "card" | "slim" | "folder" | "folder-multiline" | VariantRowStyle;
+ nested?: boolean;
+ lastNested?: boolean;
+ workerCount?: number | null;
+ workerSummary?: string | null;
+ workersOpen?: boolean | null;
+ onToggleWorkers?: ((parentKey: string, open: boolean) => void) | undefined;
navigationParent?: string | undefined;
variantAction: SidebarSweepAction;
settlementSupported: boolean;
@@ -631,6 +651,7 @@ function ThreadRenameInput(props: {
}
const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
+ const hideLoneProvider = useClientSettings((settings) => settings.sidebarHideLoneProvider);
const {
isRenaming,
onCancelRename,
@@ -720,13 +741,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
wokeAtDate !== null &&
(lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
thread.settledOverride !== "settled";
- const shouldRecede = shouldRecedeSidebarThread({
- status,
- isUnread,
- isWoke,
- isActive: props.isActive,
- isSelected,
- });
+ const variantStyled = variant === "folder-detailed" || variant === "folder-slim";
+ const [answersOpen, setAnswersOpen] = useState(false);
+ const shouldRecede =
+ !variantStyled &&
+ shouldRecedeSidebarThread({
+ status,
+ isUnread,
+ isWoke,
+ isActive: props.isActive,
+ isSelected,
+ });
const topStatus =
status === "preparing" || status === "working"
? {
@@ -959,18 +984,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
variantAction === "unsettle" &&
!props.isActive &&
"[&:not(:hover):not(:focus-within)_*]:text-secondary-label",
- props.isActive
- ? "bg-sidebar-row-active text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70"
- : isSelected || props.sweepAction !== null
- ? "bg-sidebar-row-selected text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70"
- : hasUnsentDraft
- ? cn(draftSurfaceClassName, "text-sidebar-foreground")
- : shouldRecede
- ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
- : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
- shouldRecede &&
- (status === "preparing" || status === "working") &&
- "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none",
+ variantStyled
+ ? "text-sidebar-foreground"
+ : props.isActive
+ ? "bg-sidebar-row-active text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70"
+ : isSelected || props.sweepAction !== null
+ ? "bg-sidebar-row-selected text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70"
+ : hasUnsentDraft
+ ? cn(draftSurfaceClassName, "text-sidebar-foreground")
+ : shouldRecede
+ ? cn(
+ status === "working" || status === "preparing"
+ ? "text-sidebar-muted-foreground"
+ : "text-sidebar-muted-foreground/75",
+ "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
+ )
+ : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover",
+ !variantStyled &&
+ (status === "working" || status === "preparing") &&
+ "[&_.text-secondary-label]:text-sidebar-muted-foreground",
isFileDragOver && "ring-1 ring-inset ring-primary/70",
isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover",
props.sortable?.isDragging &&
@@ -1015,6 +1047,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
isActive: props.isActive,
});
+ const lookWantsUser =
+ status === "approval" ||
+ status === "input" ||
+ status === "request" ||
+ status === "failed" ||
+ status === "limited" ||
+ (!props.nested && (isUnread || isWoke));
const title = isRenaming ? (
}
>
@@ -1142,6 +1192,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
) : null;
const diff = latestRunDiff(thread);
+ const diffCount = diff ? (
+
+ +{formatDiffCount(diff.insertions)}{" "}
+ −{formatDiffCount(diff.deletions)}
+
+ ) : null;
// Only the trailing icons would remain, so the row moves them to the title line instead.
const metaLineEmpty =
!thread.branch &&
@@ -1150,7 +1206,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
diff === null &&
runningTerminalIds.length === 0;
const metaLine = metaLineEmpty ? null : (
-
+
{thread.branch ? (
<>
@@ -1172,12 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
)}
{terminalStatusIcon}
{prBadge}
- {diff ? (
-
- +{formatDiffCount(diff.insertions)}{" "}
- −{formatDiffCount(diff.deletions)}
-
- ) : null}
+ {diffCount}
@@ -1196,13 +1251,203 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
) : null;
if (variant !== "card") {
const multiline = variant === "folder-multiline";
- const inFolder = variant === "folder" || multiline;
+ const inFolder = variant !== "slim";
+ const statusSlot = (content: ReactNode, keepsLabel: boolean) => (
+ <>
+ {sortable?.isDragging ? (
+ dragDestination
+ ) : (
+
+
+ {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
+
+ {/* The clock tells a wake time from an age at a glance. */}
+
+ {props.snoozeWakeLabelText}
+
+ ) : (
+ content
+ )}
+
+ {variantAction === "unsnooze" ? (
+ !props.snoozeSupported ? null : (
+
+ )
+ ) : !props.settlementSupported || keepsLabel ? null : variantAction === "unsettle" ? (
+
+
+ }
+ >
+
+
+ Un-settle thread
+
+ ) : (
+
+
+ }
+ >
+
+
+ Settle thread
+
+ )}
+ {jumpHintLabel && !variantStyled ? : null}
+
+ )}
+ {props.sweepAction !== null ? dragDestination : null}
+ >
+ );
+ const relativeTime = (
+
+ );
+ const providerStack = resolveThreadProviderStack(thread);
+ const rowProviderEntry =
+ props.providerEntryByInstanceId.get(providerStack[providerStack.length - 1]!) ?? null;
+ const variantRow =
+ variant === "folder-detailed" || variant === "folder-slim"
+ ? resolveVariantRow({
+ style: variant,
+ threadKey,
+ thread,
+ status,
+ statusWord: topStatus && !isWokeStatus ? topStatus : null,
+ wokePill: isWokeStatus ? (
+
+
+
+ Woke
+
+
+ }
+ />
+ Dismiss Woke notification
+
+ ) : null,
+ time: relativeTime,
+ isUnread,
+ isWoke,
+ isActive: props.isActive,
+ isSelected: isSelected || props.sweepAction !== null,
+ hasDraftSurface: hasUnsentDraft,
+ nested: props.nested ?? false,
+ lastNested: props.lastNested ?? false,
+ inFolder:
+ props.navigationParent !== undefined && !props.navigationParent.startsWith("shelf:"),
+ repo: props.project ? (
+
+ ) : null,
+ mark: rowProviderEntry
+ ? (className) => (
+
+ )
+ : null,
+ title,
+ draft: draftIndicator,
+ pin: pinIndicator,
+ where: thread.branch ? (
+
+ {thread.branch}
+
+ ) : showsScratchMachine ? (
+
+ {props.scratchMachineLabel}
+
+ ) : (
+
+ ),
+ lineExtras: (
+ <>
+ {prBadge}
+ {diffCount}
+ >
+ ),
+ remote: remoteMachineIcon,
+ terminal: terminalStatusIcon,
+ jumpHint: jumpHintLabel ?
: null,
+ workerCount: props.workerCount ?? null,
+ workerSummary: props.workerSummary ?? null,
+ workersOpen: props.workersOpen ?? null,
+ onToggleWorkers: () => props.onToggleWorkers?.(threadKey, props.workersOpen ?? false),
+ answersOpen,
+ onAnswersOpenChange: setAnswersOpen,
+ onOpenThread: () => onThreadActivate(threadRef),
+ statusSlot,
+ })
+ : null;
return (
-
+
}
>
- {/* `contents` keeps one-line rows a single flex row. */}
-
- {accessibleTitle}
- {inFolder ? null : (
-
+ {accessibleTitle}
+ {isRegeneratingTitle ? (
+
+ Regenerating title
+
+ ) : null}
+ {variantRow.body}
+ >
+ ) : (
+ <>
+ {/* `contents` keeps one-line rows a single flex row. */}
+
+ {accessibleTitle}
+ {inFolder ? null : (
+
+ {props.project ? (
+
+ ) : null}
+
)}
- >
- {props.project ? (
-
+ {draftIndicator}
+ {title}
+ {multiline ? titleLineIcons : null}
+ {pinIndicator}
+ {multiline ? null : terminalStatusIcon}
+ {isRegeneratingTitle ? (
+
+ Regenerating title
+
) : null}
-
- )}
- {draftIndicator}
- {title}
- {multiline ? titleLineIcons : null}
- {pinIndicator}
- {multiline ? null : terminalStatusIcon}
- {isRegeneratingTitle ? (
-
- Regenerating title
-
- ) : null}
- {sortable?.isDragging ? (
- dragDestination
- ) : (
-
-
- {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? (
-
- {/* The clock tells a wake time from an age at a glance. */}
-
- {props.snoozeWakeLabelText}
-
- ) : (inFolder ? isWokeStatus : isWoke) ? (
+ {statusSlot(
+ (inFolder ? isWokeStatus : isWoke) ? (
) : (
-
-
-
- )}
-
- {variantAction === "unsnooze" ? (
- !props.snoozeSupported ? null : (
-
- )
- ) : !props.settlementSupported ? null : variantAction === "unsettle" ? (
-
-
- }
- >
-
-
- Un-settle thread
-
- ) : (
-
-
- }
- >
-
-
- Settle thread
-
+ {relativeTime}
+ ),
+ false,
)}
- {jumpHintLabel ? : null}
-
- )}
- {props.sweepAction !== null ? dragDestination : null}
-
- {multiline ? metaLine : null}
+
+ {multiline ? metaLine : null}
+ >
+ )}
{detailsTooltip}
@@ -1566,7 +1742,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
aria-label="Settle thread"
onClick={handleSettleClick}
onPointerDown={handleActionPointerDown}
- className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
+ className="group-any-hover/sidebar-row:relative group-any-hover/sidebar-row:after:absolute group-any-hover/sidebar-row:after:-inset-y-0.5 group-any-hover/sidebar-row:after:inset-x-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground"
/>
}
>
diff --git a/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx
new file mode 100644
index 0000000000000..c66f2d0530ce2
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx
@@ -0,0 +1,170 @@
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import {
+ ChevronDownIcon,
+ MessageCircleQuestionIcon,
+ ShieldQuestionIcon,
+ UserRoundIcon,
+} from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "~/lib/utils";
+import { resolveSidebarThreadStatus, type SidebarSection } from "../../Sidebar.logic";
+import { sidebarFolderRowId } from "../SidebarProjectFolderHeader";
+import type { DetailedFolderItem } from "./sidebarVariant.logic";
+
+const SECONDARY_ROW_CLASS_NAME =
+ "sidebar-variant-tokens w-full cursor-pointer rounded-md text-left text-xs outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";
+
+function ParkedToggle(props: {
+ rowId: string;
+ parentRow: string;
+ label: string;
+ open: boolean;
+ onToggle: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+function waitingIcon(thread: EnvironmentThreadShell): ReactNode {
+ const status = resolveSidebarThreadStatus(thread);
+ if (status === "input") {
+ return (
+
+ );
+ }
+ if (status === "request") {
+ return ;
+ }
+ return ;
+}
+
+/** Sticks to the list's bottom edge, so a new ask moves no row above it. */
+function WaitingOnYou(props: {
+ threads: readonly EnvironmentThreadShell[];
+ routeThreadKey: string | null;
+ projectNameOf: (thread: EnvironmentThreadShell) => string | null;
+ onNavigate: (ref: ScopedThreadRef) => void;
+}) {
+ if (props.threads.length === 0) return null;
+ return (
+
+
+ Waiting on you
+ {props.threads.length}
+
+
+ {props.threads.map((thread) => {
+ const ref = scopeThreadRef(thread.environmentId, thread.id);
+ const key = scopedThreadKey(ref);
+ return (
+ -
+
+
+ );
+ })}
+
+
+ );
+}
+
+/** Project groups that hold their threads whatever their state, plus the Waiting on you list. */
+export function DetailedSidebarGroups<
+ TFolder extends {
+ readonly group: { readonly projectKey: string };
+ readonly items: readonly DetailedFolderItem[];
+ },
+>(props: {
+ folders: readonly TFolder[];
+ waiting: readonly EnvironmentThreadShell[];
+ routeThreadKey: string | null;
+ projectNameOf: (thread: EnvironmentThreadShell) => string | null;
+ onNavigate: (ref: ScopedThreadRef) => void;
+ onToggleParked: (projectKey: string) => void;
+ renderHeader: (folder: TFolder) => ReactNode;
+ renderRow: (
+ thread: EnvironmentThreadShell,
+ section: SidebarSection,
+ folderRow: string,
+ item: { nested: boolean; lastNested: boolean; workersOpen: boolean | null },
+ ) => ReactNode;
+}) {
+ return (
+ <>
+ {props.folders.flatMap((folder) => {
+ const projectKey = folder.group.projectKey;
+ const folderRow = sidebarFolderRowId(projectKey);
+ return [
+ props.renderHeader(folder),
+ ...folder.items.map((item) => {
+ switch (item.kind) {
+ case "thread":
+ return props.renderRow(item.thread, item.section, folderRow, item);
+ case "parked":
+ return (
+ props.onToggleParked(projectKey)}
+ />
+ );
+ }
+ }),
+ ];
+ })}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx
new file mode 100644
index 0000000000000..1ab24edba5c89
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx
@@ -0,0 +1,373 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
+import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { useState } from "react";
+import { expect, it, vi } from "vite-plus/test";
+import { cdp, userEvent } from "vite-plus/test/browser";
+
+import "~/index.css";
+
+import { render } from "../../../../test/render";
+
+const state = vi.hoisted(() => ({
+ respondApproval: vi.fn(),
+ respondInput: vi.fn(),
+ pendingAtom: null as unknown,
+}));
+
+vi.mock("../../../state/session", () => ({ useEnvironmentScope: () => true }));
+vi.mock("../../../state/use-atom-command", () => ({
+ useAtomCommand: (command: unknown) =>
+ command === "approval" ? state.respondApproval : state.respondInput,
+}));
+vi.mock("../../../state/threads", () => ({
+ environmentThreadDetails: { pendingRequestsAtom: () => state.pendingAtom },
+ threadEnvironment: { respondToApproval: "approval", respondToUserInput: "input" },
+}));
+
+import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers";
+import { resolveVariantRow } from "./variantRow";
+
+const environmentId = EnvironmentId.make("environment-a");
+const threadId = ThreadId.make("thread-a");
+const requestId = RuntimeRequestId.make("request-a");
+const thread = { environmentId, id: threadId } as unknown as EnvironmentThreadShell;
+
+function setup(pending: Partial) {
+ state.respondApproval.mockReset();
+ state.respondInput.mockReset();
+ state.respondApproval.mockResolvedValue(AsyncResult.success(undefined));
+ state.respondInput.mockResolvedValue(AsyncResult.success(undefined));
+ state.pendingAtom = Atom.make({
+ approvals: [],
+ userInputs: [],
+ ...pending,
+ } as PendingThreadRequests);
+}
+
+const approval = {
+ requestId,
+ requestKind: "command",
+ detail: "psql -f rotate_keys.sql",
+ createdAt: "2026-11-01T00:00:00.000Z",
+ responseCapability: "live",
+} as PendingThreadRequests["approvals"][number];
+
+const question = (extra: Record = {}) =>
+ ({
+ requestId,
+ createdAt: "2026-11-01T00:00:00.000Z",
+ responseCapability: "live",
+ questions: [
+ {
+ id: "rounding",
+ header: "Rounding",
+ question: "Round per line or per total?",
+ options: [{ label: "Per line", description: "Totals can drift" }, { label: "Per total" }],
+ multiSelect: false,
+ ...extra,
+ },
+ ],
+ }) as unknown as PendingThreadRequests["userInputs"][number];
+
+it("sends an approval from the row without activating the row", async () => {
+ setup({ approvals: [approval] });
+ const onRowClick = vi.fn();
+ await using screen = await render(
+
+
+
,
+ );
+ await expect.element(screen.getByText("psql -f rotate_keys.sql")).toBeVisible();
+ await screen.getByRole("button", { name: "Approve", exact: true }).click();
+ expect(state.respondApproval).toHaveBeenCalledWith({
+ environmentId,
+ input: { threadId, requestId, decision: "accept" },
+ });
+ expect(onRowClick).not.toHaveBeenCalled();
+});
+
+it("shows a failed answer on the row and clears it on retry", async () => {
+ setup({ approvals: [approval] });
+ state.respondApproval.mockReset();
+ state.respondApproval
+ .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Environment offline"))))
+ .mockResolvedValueOnce(AsyncResult.success(undefined));
+ await using screen = await render();
+ await screen.getByRole("button", { name: "Approve", exact: true }).click();
+ await expect.element(screen.getByRole("alert")).toHaveTextContent("Environment offline");
+ await screen.getByRole("button", { name: "Approve", exact: true }).click();
+ await expect.element(screen.getByRole("alert")).not.toBeInTheDocument();
+});
+
+it("answers a one-question request with the option's value", async () => {
+ setup({ userInputs: [question()] });
+ await using screen = await render();
+ await screen.getByRole("button", { name: "Per total", exact: true }).click();
+ expect(state.respondInput).toHaveBeenCalledWith({
+ environmentId,
+ input: { threadId, requestId, answers: { rounding: "Per total" } },
+ });
+});
+
+it("draws nothing on the row for a question that needs the full thread", async () => {
+ setup({ userInputs: [question({ multiSelect: true })] });
+ await using screen = await render();
+ expect(screen.container.querySelector("[data-row-answers]")?.childElementCount).toBe(0);
+});
+
+it("closes the popover and focuses the question when opening its thread", async () => {
+ setup({ userInputs: [question({ multiSelect: true })] });
+ const onOpenThread = vi.fn();
+ function AnswerPopover() {
+ const [open, setOpen] = useState(true);
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+ await using screen = await render();
+ await screen.getByRole("button", { name: "Open thread" }).click();
+ expect(onOpenThread).toHaveBeenCalledOnce();
+ await expect.element(screen.getByRole("button", { name: "Open thread" })).not.toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Question", exact: true })).toHaveFocus();
+ await screen.getByRole("button", { name: "Input: answer without opening the thread" }).click();
+ await expect.element(screen.getByRole("button", { name: "Open thread" })).toBeVisible();
+ await userEvent.keyboard("{Escape}");
+ await expect.element(screen.getByRole("button", { name: "Open thread" })).not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: "Input: answer without opening the thread" }))
+ .toHaveFocus();
+});
+
+const rowShowingAnswers = (
+ lineWidth: number,
+ extra: Partial[0]> = {},
+) => (
+
+ {
+ resolveVariantRow({
+ style: "folder-detailed",
+ threadKey: "environment-a:thread-a",
+ thread: { ...thread, title: "A thread", hasPendingUserInput: true },
+ status: "ready",
+ statusWord: null,
+ wokePill: null,
+ time: "1m",
+ isUnread: false,
+ isWoke: false,
+ isActive: false,
+ isSelected: false,
+ hasDraftSurface: false,
+ nested: false,
+ lastNested: false,
+ inFolder: true,
+ repo: null,
+ mark: null,
+ title: Title,
+ draft: null,
+ pin: null,
+ where: branch,
+ lineExtras: null,
+ remote: null,
+ terminal: ,
+ jumpHint: null,
+ workerCount: 2,
+ workerSummary: "2 workers · 1 working",
+ workersOpen: false,
+ onToggleWorkers: () => {},
+ answersOpen: false,
+ onAnswersOpenChange: () => {},
+ onOpenThread: () => {},
+ statusSlot: (content) => (
+
+ {content}
+
+
+ ),
+ ...extra,
+ }).body
+ }
+
+);
+
+const longLabels = question({
+ options: [{ label: "Round every line on its own and accept drift" }, { label: "Per total" }],
+});
+
+it("gives answers that do not fit a full line under the chip, terminal and Settle", async () => {
+ setup({ userInputs: [longLabels] });
+ await using screen = await render(rowShowingAnswers(135));
+ const line = screen.container.querySelector("[data-row-line2]")!.getBoundingClientRect();
+ const answers = screen.container.querySelector("[data-row-answers]")!.getBoundingClientRect();
+ expect(answers.width).toBeCloseTo(line.width, 0);
+ for (const name of ["Open terminal", "Settle thread"]) {
+ const control = screen.getByRole("button", { name }).element().getBoundingClientRect();
+ expect(control.bottom).toBeLessThanOrEqual(answers.top);
+ }
+ const chip = screen.container.querySelector("[data-worker-fold]")!.getBoundingClientRect();
+ expect(chip.bottom).toBeLessThanOrEqual(answers.top);
+ const option = screen.getByRole("button", { name: /Round every line/ }).element();
+ const box = option.getBoundingClientRect();
+ expect(box.width).toBeGreaterThan(100);
+ expect(box.right).toBeLessThanOrEqual(line.right + 0.5);
+ expect(box.left).toBeGreaterThanOrEqual(line.left - 0.5);
+ const middle = box.top + box.height / 2;
+ // A wrapped pill's area meets this one's at the 24px edge, and hit tests snap fractional rows.
+ for (const offset of [-11, 11]) {
+ const hit = document.elementFromPoint(box.left + box.width / 2, middle + offset);
+ expect(option.contains(hit)).toBe(true);
+ }
+});
+
+it("keeps a coarse pointer's answer areas off the terminal and Settle beside them", async () => {
+ setup({ approvals: [approval] });
+ const session = cdp();
+ // Chromium's touch emulation is what flips (pointer: coarse); emulated media features cannot.
+ await session.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
+ try {
+ for (const workerCount of [null, 2]) {
+ await using screen = await render(
+ rowShowingAnswers(320, {
+ thread: { ...thread, title: "A thread", hasPendingApprovals: true },
+ workerCount,
+ workerSummary: workerCount === null ? null : "2 workers · 1 working",
+ terminal: (
+
+ ),
+ }),
+ );
+ expect(matchMedia("(pointer: coarse)").matches).toBe(true);
+ const answers = screen.container.querySelector("[data-row-answers]")!;
+ const controls = ["Open terminal", "Settle thread"].map((name) =>
+ screen.getByRole("button", { name }).element(),
+ );
+ if (workerCount !== null)
+ controls.push(screen.container.querySelector("[data-worker-fold]")!);
+ for (const control of controls) {
+ const box = control.getBoundingClientRect();
+ const middle = box.top + box.height / 2;
+ for (const x of [box.left + 0.5, box.left + box.width / 2, box.right - 0.5]) {
+ const hit = document.elementFromPoint(x, middle);
+ expect(answers.contains(hit), `${control.ariaLabel} at ${x}`).toBe(false);
+ }
+ }
+ const approve = screen.getByRole("button", { name: "Approve", exact: true }).element();
+ const box = approve.getBoundingClientRect();
+ for (const offset of [-11, 11]) {
+ const hit = document.elementFromPoint(
+ box.left + box.width / 2,
+ box.top + box.height / 2 + offset,
+ );
+ expect(approve.contains(hit)).toBe(true);
+ }
+ }
+ } finally {
+ await session.send("Emulation.setTouchEmulationEnabled", { enabled: false });
+ }
+});
+
+it("keeps answers on line 2 when they fit", async () => {
+ setup({ userInputs: [question()] });
+ await using screen = await render(rowShowingAnswers(400));
+ const answers = screen.container.querySelector("[data-row-answers]")!.getBoundingClientRect();
+ const settle = screen.getByRole("button", { name: "Settle thread" }).element();
+ expect(settle.getBoundingClientRect().left).toBeGreaterThanOrEqual(answers.right);
+ const chip = screen.container.querySelector("[data-worker-fold]")!.getBoundingClientRect();
+ expect(Math.abs(chip.top + chip.height / 2 - (answers.top + answers.height / 2))).toBeLessThan(2);
+});
+
+const slimRow = (extra: Partial[0]>) => (
+
+ {
+ resolveVariantRow({
+ style: "folder-slim",
+ threadKey: "environment-a:thread-a",
+ thread: { ...thread, title: "A thread", hasPendingUserInput: true },
+ status: "ready",
+ statusWord: { label: "Input", className: "text-indigo-600" },
+ wokePill: null,
+ time: "1m",
+ isUnread: false,
+ isWoke: false,
+ isActive: false,
+ isSelected: false,
+ hasDraftSurface: false,
+ nested: false,
+ lastNested: false,
+ inFolder: false,
+ repo: null,
+ mark: null,
+ title: Title,
+ draft: null,
+ pin: null,
+ where: null,
+ lineExtras: null,
+ remote: null,
+ terminal: null,
+ jumpHint: null,
+ workerCount: null,
+ workerSummary: null,
+ workersOpen: null,
+ onToggleWorkers: () => {},
+ answersOpen: false,
+ onAnswersOpenChange: () => {},
+ onOpenThread: () => {},
+ statusSlot: (content, keepsLabel) => (
+
+
+ {content}
+
+ {keepsLabel ? null : }
+
+ ),
+ ...extra,
+ }).body
+ }
+
+);
+
+it("keeps Slim's answer button visible while it has keyboard focus", async () => {
+ setup({ userInputs: [question()] });
+ await using screen = await render(slimRow({}));
+ const answer = screen.getByRole("button", { name: /answer without opening/ }).element();
+ // why: script focus only counts as focus-visible after keyboard input.
+ await userEvent.keyboard("{Shift}");
+ (answer as HTMLElement).focus();
+ expect(answer.matches(":focus-visible")).toBe(true);
+ expect(getComputedStyle(screen.getByTestId("label").element()).opacity).toBe("1");
+});
+
+it("hides the time only while the action beside it has keyboard focus", async () => {
+ setup({});
+ await using screen = await render(
+ slimRow({
+ thread: { ...thread, title: "A thread" },
+ statusWord: null,
+ wokePill: ,
+ }),
+ );
+ const label = screen.getByTestId("label").element();
+ await userEvent.keyboard("{Shift}");
+ (screen.getByRole("button", { name: "Woke" }).element() as HTMLElement).focus();
+ expect(getComputedStyle(label).opacity).toBe("1");
+ (screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement).focus();
+ expect(getComputedStyle(label).opacity).toBe("0");
+});
diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx
new file mode 100644
index 0000000000000..11e9c3c9b662b
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx
@@ -0,0 +1,284 @@
+import { useAtomValue } from "@effect/atom-react";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests";
+import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
+import { useRef, useState, type MouseEvent, type ReactNode } from "react";
+
+import { cn } from "~/lib/utils";
+import { useEnvironmentScope } from "../../../state/session";
+import { environmentThreadDetails, threadEnvironment } from "../../../state/threads";
+import { useAtomCommand } from "../../../state/use-atom-command";
+import { ComposerPendingApprovalActions } from "../../chat/ComposerPendingApprovalActions";
+import { Button } from "../../ui/button";
+import { Popover, PopoverPopup, PopoverTrigger } from "../../ui/popover";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../../ui/tooltip";
+
+const stopRowActivation = (event: MouseEvent) => event.stopPropagation();
+
+function AnswerBody(props: {
+ detail: string;
+ inline: boolean;
+ mono?: boolean;
+ note?: string | null;
+ error: string | null;
+ children: ReactNode;
+}) {
+ return (
+ <>
+ {props.inline ? (
+
+
+ }
+ >
+ {props.detail}
+
+ {props.detail}
+
+ ) : (
+
+ {props.detail}
+
+ )}
+ {props.note ? (
+
+ {props.note}
+
+ ) : null}
+
+ {props.children}
+
+ {props.error !== null ? (
+
+ {props.error}
+
+ ) : null}
+ >
+ );
+}
+
+/** The thread's first open request with its answer buttons, sent through `runtime-request.respond`. */
+function RowAnswerContent(props: {
+ thread: EnvironmentThreadShell;
+ onOpenThread: (() => void) | null;
+}) {
+ const { thread, onOpenThread } = props;
+ const inline = onOpenThread === null;
+ const pending = useAtomValue(
+ environmentThreadDetails.pendingRequestsAtom(scopeThreadRef(thread.environmentId, thread.id)),
+ );
+ const canOperate = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope);
+ const respondApproval = useAtomCommand(threadEnvironment.respondToApproval, {
+ reportFailure: false,
+ });
+ const respondInput = useAtomCommand(threadEnvironment.respondToUserInput, {
+ reportFailure: false,
+ });
+ const [responding, setResponding] = useState(false);
+ const [error, setError] = useState(null);
+
+ const finish = (result: Awaited>) => {
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ const failure = squashAtomCommandFailure(result);
+ setError(failure instanceof Error ? failure.message : "Could not send the answer.");
+ }
+ setResponding(false);
+ return result;
+ };
+
+ const approval = pending?.approvals[0] ?? null;
+ if (approval !== null) {
+ return (
+
+ {
+ setResponding(true);
+ setError(null);
+ return finish(
+ await respondApproval({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id, requestId, decision },
+ }),
+ );
+ }}
+ />
+
+ );
+ }
+
+ const input = pending?.userInputs[0] ?? null;
+ const question = input?.questions.length === 1 ? (input.questions[0] ?? null) : null;
+ if (
+ input === null ||
+ question === null ||
+ question.multiSelect ||
+ question.options.length === 0 ||
+ input.responseCapability !== "live"
+ ) {
+ return pending !== null && onOpenThread !== null ? (
+
+
+
+ ) : null;
+ }
+ return (
+
+ {question.options.map((option) => {
+ const button = (
+
+ );
+ return option.description ? (
+
+
+ {option.description}
+
+ ) : (
+ button
+ );
+ })}
+
+ );
+}
+
+/** Detailed: on line 2 of a row, or under a worker's title. */
+export function InlineRowAnswers(props: { thread: EnvironmentThreadShell; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+/** Slim: the pill opens the answers, so the rows below never move. */
+export function RowAnswersPopover(props: {
+ thread: EnvironmentThreadShell;
+ label: string;
+ className: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onOpenThread: () => void;
+}) {
+ const openingThread = useRef(false);
+ return (
+ {
+ if (open) openingThread.current = false;
+ props.onOpenChange(open);
+ }}
+ >
+
+ }
+ >
+ {props.label}
+
+ {/* A row click navigates, and portals still bubble React events to the row. */}
+
+ openingThread.current
+ ? (document.querySelector("[data-pending-user-input-panel] button") ??
+ false)
+ : true
+ }
+ onClick={stopRowActivation}
+ onDoubleClick={stopRowActivation}
+ >
+
+ {
+ openingThread.current = true;
+ props.onOpenChange(false);
+ props.onOpenThread();
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sidebar/variants/sidebarLayout.ts b/apps/web/src/components/sidebar/variants/sidebarLayout.ts
new file mode 100644
index 0000000000000..2740ffa296577
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/sidebarLayout.ts
@@ -0,0 +1,23 @@
+import type { ClientSettings } from "@t3tools/contracts/settings";
+
+/** What the sidebar settings add up to; Detailed and Slim lock the rest without rewriting them. */
+export function resolveSidebarLayout(
+ settings: Pick<
+ ClientSettings,
+ | "sidebarVariant"
+ | "boringSidebarEnabled"
+ | "boringSidebarMultilineRows"
+ | "sidebarWorkingShelfEnabled"
+ >,
+) {
+ const locked = settings.sidebarVariant !== "default";
+ const folders = locked || settings.boringSidebarEnabled;
+ return {
+ locked,
+ folders,
+ workingSection: !folders && settings.sidebarWorkingShelfEnabled,
+ twoLineRows: locked
+ ? settings.sidebarVariant === "detailed"
+ : settings.boringSidebarMultilineRows,
+ };
+}
diff --git a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts
new file mode 100644
index 0000000000000..305c46f5842a3
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts
@@ -0,0 +1,388 @@
+import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state/threads";
+import {
+ EnvironmentId,
+ ProjectId,
+ RuntimeRequestId,
+ ThreadId,
+ type OrchestrationV2ShellSnapshot,
+ type OrchestrationV2ThreadShell,
+} from "@t3tools/contracts";
+import { DEFAULT_CLIENT_SETTINGS, type SidebarVariant } from "@t3tools/contracts/settings";
+import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
+import { Atom, AtomRegistry } from "effect/reactivity";
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveSidebarLayout } from "./sidebarLayout";
+import {
+ arrangeDetailedFolder,
+ arrangeStableRows,
+ detailedFolderShows,
+ rowAttention,
+ threadsWaitingOnYou,
+ waitingOnYouCountAtom,
+ variantRowStyle,
+ workerSummary,
+ workersByParent,
+} from "./sidebarVariant.logic";
+
+const shell = (
+ id: string,
+ createdAt: string,
+ overrides: Record = {},
+): EnvironmentThreadShell =>
+ ({
+ id,
+ environmentId: "environment-a",
+ projectId: "project-a",
+ createdAt,
+ archivedAt: null,
+ pinnedAt: null,
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+ pendingGuestMessageCount: 0,
+ runtime: null,
+ lineage: { parentThreadId: null, relationshipToParent: null },
+ ...overrides,
+ }) as unknown as EnvironmentThreadShell;
+
+const worker = (id: string, parent: string, overrides: Record = {}) =>
+ shell(id, "2026-01-01T00:00:00.000Z", {
+ lineage: { parentThreadId: parent, relationshipToParent: "subagent" },
+ ...overrides,
+ });
+
+const arrange = (input: Partial[0]>) =>
+ arrangeDetailedFolder({
+ collapsed: false,
+ pinned: [],
+ active: [],
+ snoozed: [],
+ settled: [],
+ workers: new Map(),
+ workerFold: new Map(),
+ parkedOpen: false,
+ routeRowKey: null,
+ routeThreadKey: null,
+ ...input,
+ });
+
+const ids = (result: ReturnType) =>
+ result.items.flatMap((item): string[] =>
+ item.kind === "thread" ? [item.thread.id] : [item.kind],
+ );
+
+describe("resolveSidebarLayout", () => {
+ const variants: readonly SidebarVariant[] = ["default", "detailed", "slim"];
+ const flags = [false, true];
+
+ it("gives every combination of the sidebar settings one consistent layout", () => {
+ for (const sidebarVariant of variants) {
+ for (const boringSidebarEnabled of flags) {
+ for (const boringSidebarMultilineRows of flags) {
+ for (const sidebarWorkingShelfEnabled of flags) {
+ const layout = resolveSidebarLayout({
+ sidebarVariant,
+ boringSidebarEnabled,
+ boringSidebarMultilineRows,
+ sidebarWorkingShelfEnabled,
+ });
+ expect(layout.locked).toBe(sidebarVariant !== "default");
+ expect(layout.folders).toBe(sidebarVariant !== "default" || boringSidebarEnabled);
+ expect(layout.workingSection).toBe(
+ sidebarVariant === "default" && !boringSidebarEnabled && sidebarWorkingShelfEnabled,
+ );
+ expect(layout.twoLineRows).toBe(
+ sidebarVariant === "default"
+ ? boringSidebarMultilineRows
+ : sidebarVariant === "detailed",
+ );
+ expect(layout.folders && layout.workingSection).toBe(false);
+ }
+ }
+ }
+ }
+ });
+
+ it("matches what users have today when nothing is set", () => {
+ expect(resolveSidebarLayout(DEFAULT_CLIENT_SETTINGS)).toEqual({
+ locked: false,
+ folders: false,
+ workingSection: true,
+ twoLineRows: false,
+ });
+ });
+});
+
+describe("variantRowStyle", () => {
+ it("leaves every row to T3 under Default", () => {
+ for (const section of ["pinned", "active", "working", "snoozed", "settled"] as const) {
+ expect(variantRowStyle("default", section, true)).toBeNull();
+ expect(variantRowStyle("default", section, false)).toBeNull();
+ }
+ });
+
+ it("styles pinned rows in Slim and leaves its shelves to T3", () => {
+ expect(variantRowStyle("slim", "active", true)).toBe("folder-slim");
+ expect(variantRowStyle("slim", "pinned", false)).toBe("folder-slim");
+ expect(variantRowStyle("slim", "settled", false)).toBeNull();
+ expect(variantRowStyle("detailed", "settled", true)).toBe("folder-detailed");
+ });
+});
+
+describe("detailedFolderShows", () => {
+ it("keeps an empty folder only when the sidebar's predicate does", () => {
+ expect(detailedFolderShows({ active: [], settled: [] }, true)).toBe(true);
+ expect(detailedFolderShows({ active: [], settled: [] }, false)).toBe(false);
+ expect(detailedFolderShows({ active: [], settled: ["thread"] }, false)).toBe(true);
+ });
+});
+
+describe("rowAttention", () => {
+ it("marks asks, failures and unseen finishes only", () => {
+ expect(rowAttention("approval", false)).toBe("warning");
+ expect(rowAttention("limited", false)).toBe("warning");
+ expect(rowAttention("input", false)).toBe("input");
+ expect(rowAttention("request", false)).toBe("guest");
+ expect(rowAttention("failed", true)).toBe("error");
+ expect(rowAttention("ready", true)).toBe("success");
+ expect(rowAttention("ready", false)).toBeUndefined();
+ expect(rowAttention("working", true)).toBeUndefined();
+ });
+});
+
+describe("placement", () => {
+ it("keeps every row in its slot when only its state changes", () => {
+ const before = [
+ shell("a", "2026-01-01T00:00:00.000Z"),
+ shell("b", "2026-01-03T00:00:00.000Z", { hasPendingApprovals: true }),
+ shell("c", "2026-01-02T00:00:00.000Z"),
+ ];
+ const after = before.map((thread) =>
+ shell(thread.id, thread.createdAt, {
+ hasPendingUserInput: true,
+ runtime: { status: "idle" },
+ }),
+ );
+ expect(arrangeStableRows(after).map((thread) => thread.id)).toEqual(
+ arrangeStableRows(before).map((thread) => thread.id),
+ );
+ expect(arrangeStableRows(before).map((thread) => thread.id)).toEqual(["b", "c", "a"]);
+ });
+
+ it("puts pinned threads first, newest first inside each part", () => {
+ const threads = [
+ shell("old", "2026-01-01T00:00:00.000Z"),
+ shell("new", "2026-01-05T00:00:00.000Z"),
+ shell("pin", "2026-01-02T00:00:00.000Z", { pinnedAt: "2026-01-09T00:00:00.000Z" }),
+ ];
+ expect(arrangeStableRows(threads).map((thread) => thread.id)).toEqual(["pin", "new", "old"]);
+ });
+
+ it("parks snoozed and settled threads behind one row in their folder", () => {
+ const settled = [shell("s1", "2026-01-01T00:00:00.000Z")];
+ const snoozed = [shell("z1", "2026-01-01T00:00:00.000Z")];
+ const closed = arrange({ snoozed, settled });
+ expect(ids(closed)).toEqual(["parked"]);
+ expect(closed.items[0]).toMatchObject({ label: "1 snoozed · 1 settled", open: false });
+ expect(ids(arrange({ snoozed, settled, parkedOpen: true }))).toEqual(["parked", "z1", "s1"]);
+ });
+
+ it("still shows the open thread in a closed folder or parked group", () => {
+ const active = [shell("a", "2026-01-01T00:00:00.000Z"), shell("b", "2026-01-02T00:00:00.000Z")];
+ const key = "environment-a:a";
+ const closed = arrange({ active, collapsed: true, routeRowKey: key });
+ expect(ids(closed)).toEqual(["a"]);
+ expect(closed.hiddenThreads.map((thread) => thread.id)).toEqual(["b"]);
+ const settled = [shell("s", "2026-01-01T00:00:00.000Z")];
+ expect(ids(arrange({ settled, routeRowKey: "environment-a:s" }))).toEqual(["parked", "s"]);
+ });
+});
+
+describe("workers", () => {
+ const parent = shell("p", "2026-01-01T00:00:00.000Z");
+ const quiet = worker("w1", "p");
+ const asking = worker("w2", "p", { hasPendingApprovals: true });
+
+ it("folds workers until one needs the user, and honors a manual choice", () => {
+ const folded = workersByParent([parent, quiet]);
+ const closed = arrange({ active: [parent], workers: folded });
+ expect(ids(closed)).toEqual(["p"]);
+ expect(closed.items[0]).toMatchObject({ workersOpen: false });
+ const needs = workersByParent([parent, quiet, asking]);
+ const open = arrange({ active: [parent], workers: needs });
+ expect(ids(open)).toEqual(["p", "w1", "w2"]);
+ expect(open.items[0]).toMatchObject({ workersOpen: true });
+ expect(open.items[1]).toMatchObject({ nested: true, lastNested: false });
+ expect(open.items[2]).toMatchObject({ nested: true, lastNested: true });
+ expect(
+ ids(
+ arrange({
+ active: [parent],
+ workers: needs,
+ workerFold: new Map([["environment-a:p", false]]),
+ }),
+ ),
+ ).toEqual(["p"]);
+ });
+
+ it("shows an open worker under its parent while the row is folded", () => {
+ const workers = workersByParent([parent, quiet]);
+ expect(ids(arrange({ active: [parent], workers, routeThreadKey: "environment-a:w1" }))).toEqual(
+ ["p", "w1"],
+ );
+ });
+
+ it("counts workers and how many share the most urgent state", () => {
+ const running = worker("w3", "p", { runtime: { status: "running" } });
+ expect(workerSummary([quiet])).toBe("1 worker");
+ expect(workerSummary([quiet, running])).toBe("2 workers · 1 working");
+ expect(workerSummary([running, asking, quiet])).toBe("3 workers · 1 approval");
+ });
+
+ it("counts a thread as waiting only when it asks itself", () => {
+ const raised = shell("r", "2026-01-01T00:00:00.000Z", { pendingSubagentRequest: "approval" });
+ const archived = shell("x", "2026-01-01T00:00:00.000Z", {
+ hasPendingApprovals: true,
+ archivedAt: "2026-01-02T00:00:00.000Z",
+ });
+ const other = shell("o", "2026-01-01T00:00:00.000Z", {
+ hasPendingUserInput: true,
+ projectId: "project-b",
+ });
+ const threads = [raised, archived, asking, other];
+ expect(threadsWaitingOnYou(threads, null).map((thread) => thread.id)).toEqual(["w2", "o"]);
+ expect(
+ threadsWaitingOnYou(threads, new Set(["environment-a:project-a"])).map((thread) => thread.id),
+ ).toEqual(["w2"]);
+ });
+});
+
+describe("waitingOnYouCountAtom", () => {
+ const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
+ const projectId = ProjectId.make("project-a");
+ const here = EnvironmentId.make("environment-a");
+ const there = EnvironmentId.make("environment-b");
+ const approval = {
+ id: RuntimeRequestId.make("request-a"),
+ kind: "command" as const,
+ createdAt: now,
+ };
+ const wire = (
+ id: string,
+ overrides: Partial = {},
+ ): OrchestrationV2ThreadShell => ({
+ id: ThreadId.make(id),
+ projectId,
+ title: id,
+ providerInstanceId: "codex" as OrchestrationV2ThreadShell["providerInstanceId"],
+ modelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.4",
+ } as OrchestrationV2ThreadShell["modelSelection"],
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ activeProviderThreadId: null,
+ lineage: { rootThreadId: ThreadId.make(id), parentThreadId: null, relationshipToParent: null },
+ forkedFrom: null,
+ createdBy: "user",
+ creationSource: "web",
+ latestRunId: null,
+ activeRunId: null,
+ status: "idle",
+ pendingRuntimeRequest: null,
+ latestVisibleMessage: null,
+ latestUserMessageAt: null,
+ hasActionableProposedPlan: false,
+ itemCount: 0,
+ visibleItemCount: 0,
+ createdAt: now,
+ updatedAt: now,
+ archivedAt: null,
+ settledOverride: null,
+ settledAt: null,
+ lastVisitedAt: null,
+ deletedAt: null,
+ ...overrides,
+ });
+ const asking = (id: string, overrides: Partial = {}) =>
+ wire(id, { pendingRuntimeRequest: approval, ...overrides });
+ const worker = (id: string, parent: string) =>
+ asking(id, {
+ lineage: {
+ rootThreadId: ThreadId.make(parent),
+ parentThreadId: ThreadId.make(parent),
+ relationshipToParent: "subagent",
+ },
+ });
+
+ function count(
+ threads: Record,
+ options: { hiddenTags?: ReadonlyArray; disabled?: EnvironmentId } = {},
+ ) {
+ const ids = Object.keys(threads).map((id) => EnvironmentId.make(id));
+ const catalogValueAtom = Atom.make({
+ isReady: true,
+ entries: new Map(
+ ids.map((id) => [
+ id,
+ {
+ target: new PrimaryConnectionTarget({
+ environmentId: id,
+ label: "Environment",
+ httpBaseUrl: "https://example.test",
+ wsBaseUrl: "wss://example.test",
+ }),
+ profile: Option.none(),
+ enabled: id !== options.disabled,
+ },
+ ]),
+ ),
+ });
+ const shells = createEnvironmentThreadShellAtoms({
+ catalogValueAtom,
+ snapshotAtom: (id) =>
+ Atom.make({
+ schemaVersion: 1,
+ snapshotSequence: 0,
+ projects: [],
+ threads: threads[id] ?? [],
+ archivedThreads: [],
+ }),
+ hiddenThreadTagsAtom: () => Atom.make(options.hiddenTags ?? null),
+ });
+ const registry = AtomRegistry.make();
+ const value = registry.get(waitingOnYouCountAtom(shells));
+ registry.dispose();
+ return value;
+ }
+
+ it("counts each thread asking you, workers on their own", () => {
+ expect(
+ count({
+ [here]: [wire("parent"), worker("worker-a", "parent"), worker("worker-b", "parent")],
+ }),
+ ).toBe(2);
+ });
+
+ it("leaves out a thread the tag filter hides", () => {
+ expect(
+ count(
+ { [here]: [asking("shown"), asking("hidden", { tags: ["later"] })] },
+ { hiddenTags: ["later"] },
+ ),
+ ).toBe(1);
+ });
+
+ it("leaves out an archived thread", () => {
+ expect(count({ [here]: [asking("open"), asking("archived", { archivedAt: now })] })).toBe(1);
+ });
+
+ it("leaves out a switched off environment", () => {
+ expect(count({ [here]: [asking("a")], [there]: [asking("b")] }, { disabled: there })).toBe(1);
+ });
+});
diff --git a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts
new file mode 100644
index 0000000000000..613459b90d706
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts
@@ -0,0 +1,254 @@
+import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import type { SidebarVariant } from "@t3tools/contracts/settings";
+import { Atom } from "effect/reactivity";
+
+import {
+ hasUnseenCompletion,
+ resolveSidebarThreadStatus,
+ type SidebarSection,
+ type SidebarThreadStatus,
+} from "../../Sidebar.logic";
+
+export type VariantRowStyle = "folder-detailed" | "folder-slim";
+export type RowAttention = "warning" | "input" | "guest" | "error" | "success";
+
+/** The row a variant draws, or null for T3's own row in that place. */
+export function variantRowStyle(
+ variant: SidebarVariant,
+ section: SidebarSection,
+ inFolder: boolean,
+): VariantRowStyle | null {
+ if (variant === "detailed" && inFolder) return "folder-detailed";
+ if (variant === "slim" && (inFolder || section === "pinned")) return "folder-slim";
+ return null;
+}
+
+/** The state colour of a row that wants the user, or undefined for a quiet row. */
+export function rowAttention(
+ status: SidebarThreadStatus,
+ isUnread: boolean,
+): RowAttention | undefined {
+ switch (status) {
+ case "approval":
+ case "limited":
+ return "warning";
+ case "input":
+ return "input";
+ case "request":
+ return "guest";
+ case "failed":
+ return "error";
+ case "ready":
+ return isUnread ? "success" : undefined;
+ default:
+ return undefined;
+ }
+}
+
+function workerNeedsYou(thread: EnvironmentThreadShell): boolean {
+ const status = resolveSidebarThreadStatus(thread);
+ return status === "approval" || status === "input" || status === "request";
+}
+
+/** A worker's rank and the count's two word forms; a higher rank names the count. */
+function workerState(thread: EnvironmentThreadShell): readonly [number, string, string] | null {
+ switch (resolveSidebarThreadStatus(thread)) {
+ case "failed":
+ return [8, "error", "errors"];
+ case "limited":
+ return [8, "limited", "limited"];
+ case "approval":
+ return [7, "approval", "approvals"];
+ case "input":
+ return [6, "question", "questions"];
+ case "request":
+ return [6, "request", "requests"];
+ case "working":
+ case "preparing":
+ case "waiting":
+ return [5, "working", "working"];
+ default:
+ return thread.latestRun?.completedAt || hasUnseenCompletion(thread)
+ ? [2, "done", "done"]
+ : null;
+ }
+}
+
+export function workerTotal(count: number): string {
+ return `${count} ${count === 1 ? "worker" : "workers"}`;
+}
+
+/** The total, then how many share the most urgent state: "2 workers · 1 working". */
+export function workerSummary(workers: readonly EnvironmentThreadShell[]): string {
+ const total = workerTotal(workers.length);
+ const states = workers.map(workerState).filter((state) => state !== null);
+ const top = states.reduce<(typeof states)[number] | null>(
+ (best, state) => (best === null || state[0] > best[0] ? state : best),
+ null,
+ );
+ if (top === null) return total;
+ const count = states.filter((state) => state[1] === top[1]).length;
+ return `${total} · ${count} ${count === 1 ? top[1] : top[2]}`;
+}
+
+export type WorkerMap = ReadonlyMap;
+
+export function workersByParent(threads: readonly EnvironmentThreadShell[]): WorkerMap {
+ const byParent = new Map();
+ for (const thread of threads) {
+ const parent = thread.lineage.parentThreadId;
+ if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue;
+ if (thread.archivedAt !== null) continue;
+ const key = scopedThreadKey(scopeThreadRef(thread.environmentId, parent));
+ byParent.set(key, [...(byParent.get(key) ?? []), thread]);
+ }
+ return byParent;
+}
+
+const threadKeyOf = (thread: Pick) =>
+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+
+export const workersOf = (workers: WorkerMap, thread: EnvironmentThreadShell) =>
+ workers.get(threadKeyOf(thread)) ?? [];
+
+/** Creation order never changes, so nothing a thread does can move its row. */
+export function arrangeStableRows<
+ T extends { readonly id: string; readonly createdAt: string; readonly pinnedAt?: string | null },
+>(threads: readonly T[]): T[] {
+ const newestFirst = (left: T, right: T) =>
+ right.createdAt.localeCompare(left.createdAt) || left.id.localeCompare(right.id);
+ return [
+ ...threads.filter((thread) => thread.pinnedAt != null).toSorted(newestFirst),
+ ...threads.filter((thread) => thread.pinnedAt == null).toSorted(newestFirst),
+ ];
+}
+
+function parkedLabel(snoozed: number, settled: number): string {
+ return [snoozed > 0 ? `${snoozed} snoozed` : null, settled > 0 ? `${settled} settled` : null]
+ .filter((part) => part !== null)
+ .join(" · ");
+}
+
+/** A folder shows while it holds a thread, or when the sidebar keeps it empty. */
+export function detailedFolderShows(
+ lists: Readonly>,
+ keepEmpty: boolean,
+): boolean {
+ return keepEmpty || Object.values(lists).some((list) => list.length > 0);
+}
+
+export type DetailedFolderItem =
+ | {
+ readonly kind: "thread";
+ readonly thread: EnvironmentThreadShell;
+ readonly section: SidebarSection;
+ readonly nested: boolean;
+ /** The last worker shown under its parent, where the guide turns in. */
+ readonly lastNested: boolean;
+ /** Whether its worker rows show; null when the row cannot fold them. */
+ readonly workersOpen: boolean | null;
+ }
+ | { readonly kind: "parked"; readonly label: string; readonly open: boolean };
+
+export function arrangeDetailedFolder(input: {
+ readonly collapsed: boolean;
+ readonly pinned: readonly EnvironmentThreadShell[];
+ readonly active: readonly EnvironmentThreadShell[];
+ readonly snoozed: readonly EnvironmentThreadShell[];
+ readonly settled: readonly EnvironmentThreadShell[];
+ readonly workers: WorkerMap;
+ readonly workerFold: ReadonlyMap;
+ readonly parkedOpen: boolean;
+ /** The row standing for the open thread; a closed group still shows it. */
+ readonly routeRowKey: string | null;
+ /** The open thread itself, which may be a worker. */
+ readonly routeThreadKey: string | null;
+}): {
+ readonly items: readonly DetailedFolderItem[];
+ readonly hiddenThreads: readonly EnvironmentThreadShell[];
+} {
+ const { collapsed, routeRowKey } = input;
+ const isRouteRow = (thread: EnvironmentThreadShell) => threadKeyOf(thread) === routeRowKey;
+ const main = arrangeStableRows([...input.pinned, ...input.active]);
+ const items: DetailedFolderItem[] = [];
+ for (const thread of main) {
+ if (collapsed && !isRouteRow(thread)) continue;
+ const workers = workersOf(input.workers, thread);
+ const parentKey = threadKeyOf(thread);
+ const open =
+ !collapsed &&
+ (input.workerFold.get(parentKey) ?? workers.some((worker) => workerNeedsYou(worker)));
+ items.push({
+ kind: "thread",
+ thread,
+ section: thread.pinnedAt != null ? "pinned" : "active",
+ nested: false,
+ lastNested: false,
+ workersOpen: workers.length > 0 && !collapsed ? open : null,
+ });
+ const shown = arrangeStableRows(workers).filter(
+ (worker) => open || threadKeyOf(worker) === input.routeThreadKey,
+ );
+ shown.forEach((worker, index) => {
+ items.push({
+ kind: "thread",
+ thread: worker,
+ section: "active",
+ nested: true,
+ lastNested: index === shown.length - 1,
+ workersOpen: null,
+ });
+ });
+ }
+ const parkedCount = input.snoozed.length + input.settled.length;
+ const parkedOpen = !collapsed && input.parkedOpen;
+ if (!collapsed && parkedCount > 0) {
+ items.push({
+ kind: "parked",
+ label: parkedLabel(input.snoozed.length, input.settled.length),
+ open: parkedOpen,
+ });
+ }
+ for (const [list, section] of [
+ [input.snoozed, "snoozed"],
+ [input.settled, "settled"],
+ ] as const) {
+ for (const thread of list) {
+ if (parkedOpen || isRouteRow(thread)) {
+ items.push({
+ kind: "thread",
+ thread,
+ section,
+ nested: false,
+ lastNested: false,
+ workersOpen: null,
+ });
+ }
+ }
+ }
+ return { items, hiddenThreads: collapsed ? main.filter((thread) => !isRouteRow(thread)) : [] };
+}
+
+/** How many rows the Waiting on you list shows across every project, for the window title. */
+export function waitingOnYouCountAtom(shells: {
+ quietThreadShellsAtom: Atom.Atom>;
+}): Atom.Atom {
+ return Atom.make((get) => threadsWaitingOnYou(get(shells.quietThreadShellsAtom), null).length);
+}
+
+/** Threads asking the user something themselves; a parent raising a worker's ask is not one. */
+export function threadsWaitingOnYou(
+ threads: readonly EnvironmentThreadShell[],
+ scopedProjectKeys: ReadonlySet | null,
+): EnvironmentThreadShell[] {
+ return threads.filter(
+ (thread) =>
+ thread.archivedAt === null &&
+ (scopedProjectKeys === null ||
+ scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) &&
+ (thread.hasPendingApprovals ||
+ thread.hasPendingUserInput ||
+ (thread.pendingGuestMessageCount ?? 0) > 0),
+ );
+}
diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css
new file mode 100644
index 0000000000000..51257100303b9
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css
@@ -0,0 +1,419 @@
+/* Two-row-wide tiles slide four row widths left, as background-position -200% to 200% does. */
+@keyframes sidebar-row-sweep {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-66.6667%);
+ }
+}
+
+@keyframes sidebar-status-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ /* Pitwall's 0.5 stays under 3:1 on 8 of the 12 themes at any clamp. */
+ 50% {
+ opacity: var(--row-dot-dim);
+ }
+}
+
+/* Lightness is clamped to hold 4.5:1. On light themes chroma stays inside sRGB at that lightness, or the browser clips the hue: amber turns red. */
+@utility sidebar-variant-tokens {
+ --contrast-secondary-label: color-mix(in srgb, var(--foreground) 72%, transparent);
+ --row-muted: color-mix(in srgb, var(--foreground) 80%, transparent);
+ --row-state-floor: 0.75;
+ --row-dot-dim: 0.6;
+ --variant-warning: oklch(from var(--warning-foreground) min(l, 0.38) min(c, 0.1) h);
+ --variant-success: oklch(from var(--success-foreground) min(l, 0.38) min(c, 0.078) h);
+ --variant-info: oklch(from var(--info-foreground) min(l, 0.38) c h);
+ --variant-error: oklch(from var(--error-foreground) min(l, 0.38) min(c, 0.15) h);
+ --variant-guest: oklch(from var(--guest-foreground) min(l, 0.38) min(c, 0.18) h);
+ --input-state: var(--color-indigo-600);
+ --variant-input: oklch(from var(--input-state) min(l, 0.38) c h);
+
+ .dark & {
+ --variant-warning: oklch(from var(--warning-foreground) max(l, var(--row-state-floor)) c h);
+ --variant-success: oklch(from var(--success-foreground) max(l, var(--row-state-floor)) c h);
+ --variant-info: oklch(from var(--info-foreground) max(l, var(--row-state-floor)) c h);
+ --variant-error: oklch(from var(--error-foreground) max(l, var(--row-state-floor)) c h);
+ --variant-guest: oklch(from var(--guest-foreground) max(l, var(--row-state-floor)) c h);
+ --input-state: var(--color-indigo-300);
+ --variant-input: oklch(from var(--input-state) max(l, var(--row-state-floor)) c h);
+ }
+
+ /* These dark themes have lighter surfaces, so their colours need a higher floor to pass. */
+ .dark[data-theme-id="grove"] & {
+ --row-state-floor: 0.95;
+ --row-dot-dim: 0.7;
+ }
+ .dark[data-theme-id="ocean"] &,
+ .dark[data-theme-id="ember"] & {
+ --row-state-floor: 0.9;
+ --row-dot-dim: 0.7;
+ }
+ .dark[data-theme-id="iris"] & {
+ --row-state-floor: 0.85;
+ --row-dot-dim: 0.7;
+ }
+
+ & .text-warning-foreground {
+ --pill-state: var(--warning-foreground);
+ color: var(--variant-warning);
+ }
+ & .text-success-foreground {
+ --pill-state: var(--success-foreground);
+ color: var(--variant-success);
+ }
+ & .text-info-foreground {
+ --pill-state: var(--info-foreground);
+ color: var(--variant-info);
+ }
+ & .text-error-foreground {
+ --pill-state: var(--error-foreground);
+ color: var(--variant-error);
+ }
+ & .text-muted-foreground {
+ color: var(--row-muted);
+ }
+ & .text-guest-foreground {
+ --pill-state: var(--guest-foreground);
+ color: var(--variant-guest);
+ }
+ & .text-indigo-600,
+ & .sidebar-input-mark {
+ --pill-state: var(--input-state);
+ color: var(--variant-input);
+ }
+ /* The pill's dark: colour would otherwise outrank the token. */
+ && [class*="dark:text-indigo-300"] {
+ --pill-state: var(--input-state);
+ color: var(--variant-input);
+ }
+}
+
+/* The sheet paints the root sidebar token, while the docked sidebar rescopes it locally. */
+@utility sidebar-waiting-surface {
+ background-color: var(--sidebar);
+
+ [data-mobile="true"] & {
+ background-color: var(--color-sidebar);
+ }
+}
+
+@utility sidebar-input-mark {
+ color: var(--color-indigo-600);
+}
+
+/* Six row widths of two-row-wide tiles, so the slide never shows an edge. */
+@utility sidebar-row-sweep {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ border-radius: inherit;
+ contain: strict;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ width: 600%;
+ background-image: linear-gradient(
+ to right,
+ transparent 0%,
+ rgb(255 255 255 / 0.02) 40%,
+ rgb(255 255 255 / 0.04) 50%,
+ rgb(255 255 255 / 0.02) 60%,
+ transparent 100%
+ );
+ background-size: 33.3333% 100%;
+ animation: sidebar-row-sweep 3s ease-in-out infinite;
+ animation-play-state: var(--visible-animation-state, paused);
+ will-change: var(--visible-animation-will-change, auto);
+ }
+
+ /* A collapsed sidebar's rows skip rendering, so their visibility observer never reports them. */
+ [data-slot="sidebar"][data-state="collapsed"] &::before {
+ animation: none;
+ }
+
+ @media (prefers-reduced-motion: reduce), (forced-colors: active) {
+ display: none;
+ }
+}
+
+@utility sidebar-status-dot {
+ background-color: currentColor;
+
+ [data-slot="sidebar"][data-state="collapsed"] & {
+ animation: none;
+ }
+
+ @media (forced-colors: active) {
+ background-color: CanvasText;
+ forced-color-adjust: none;
+ }
+ animation: sidebar-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ animation-play-state: var(--visible-animation-state, paused);
+ will-change: var(--visible-animation-will-change, auto);
+
+ @media (prefers-reduced-motion: reduce), (forced-colors: active) {
+ animation: none;
+ will-change: auto;
+ }
+}
+
+/* As tall as line 2, with the state colour mixed 14% into the row. */
+@utility sidebar-row-pill {
+ height: 1.03125rem;
+ padding-inline: 0.4375rem;
+ background-color: color-mix(in srgb, var(--pill-state, currentColor) 14%, transparent);
+}
+
+/* Centres the capitals in the pill in any font, whatever fraction the row lands on. */
+@utility sidebar-row-pill-word {
+ line-height: 1.03125rem;
+ text-box: trim-both cap alphabetic;
+}
+
+@utility sidebar-row-title {
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+/* 55% primary painted over the row's fill is primary mixed 55% into that fill. */
+@utility sidebar-row-stroke {
+ --tw-ring-color: color-mix(in srgb, var(--primary) 55%, transparent);
+}
+
+@utility sidebar-row-look {
+ --row-fill: transparent;
+ --row-state: transparent;
+ --row-tint-state: transparent;
+ --row-tint: color-mix(in srgb, var(--row-tint-state) 10%, transparent);
+ background-color: var(--row-fill);
+ background-image: linear-gradient(var(--row-tint), var(--row-tint));
+
+ &:hover {
+ --row-fill: var(--sidebar-row-hover);
+ }
+
+ &[data-surface="draft"] {
+ --row-fill: color-mix(in srgb, var(--warning) 4%, transparent);
+ }
+
+ &[data-surface="draft"]:hover {
+ --row-fill: color-mix(in srgb, var(--warning) 8%, transparent);
+ }
+
+ &[data-surface="selected"] {
+ --row-fill: var(--sidebar-row-selected);
+ }
+
+ &[data-surface="active"] {
+ --row-fill: var(--sidebar-row-active);
+ --row-tint: transparent;
+ }
+
+ &[data-surface="worker-active"] {
+ --row-fill: color-mix(in srgb, var(--primary) 8%, transparent);
+ }
+
+ &[data-surface="worker-selected"] {
+ --row-fill: color-mix(in srgb, var(--primary) 7%, transparent);
+ }
+
+ &[data-surface^="worker-"]:hover {
+ --row-fill: var(--sidebar-row-hover);
+ }
+
+ &[data-attention="warning"] {
+ --row-state: var(--variant-warning);
+ --row-tint-state: var(--warning-foreground);
+ }
+
+ &[data-attention="input"] {
+ --row-state: var(--variant-input);
+ --row-tint-state: var(--input-state);
+ }
+
+ &[data-attention="guest"] {
+ --row-state: var(--variant-guest);
+ --row-tint-state: var(--guest-foreground);
+ }
+
+ &[data-attention="error"] {
+ --row-state: var(--variant-error);
+ --row-tint-state: var(--error-foreground);
+ }
+
+ &[data-attention="success"] {
+ --row-state: var(--variant-success);
+ --row-tint-state: var(--success-foreground);
+ }
+
+ &[data-attention]::after {
+ content: "";
+ position: absolute;
+ left: 2px;
+ inset-block: 10px;
+ width: 3px;
+ border-radius: 1.5px;
+ background: var(--row-state);
+ pointer-events: none;
+ }
+
+ /* Hover hides the time for Settle, so keyboard focus on that action has to as well. */
+ & [data-status-slot]:has(> button:focus-visible) > [data-yields-to-action] {
+ opacity: 0;
+ }
+
+ /* Forced colors drop fills and shadows, so the bar and the stroke need system colours. */
+ @media (forced-colors: active) {
+ &[data-attention]::after {
+ background: CanvasText;
+ forced-color-adjust: none;
+ }
+
+ &[data-surface="active"],
+ &[data-surface="selected"] {
+ outline: 1px solid Highlight;
+ outline-offset: -1px;
+ }
+ }
+}
+
+/* An opaque copy of the row's own layers, so the mark reads the same on every row state. */
+@utility sidebar-row-disc {
+ background-color: var(--background);
+ background-image:
+ linear-gradient(var(--row-tint), var(--row-tint)),
+ linear-gradient(var(--row-fill), var(--row-fill)),
+ linear-gradient(var(--sidebar), var(--sidebar));
+
+ [data-mobile="true"] & {
+ background-image:
+ linear-gradient(var(--row-tint), var(--row-tint)),
+ linear-gradient(var(--row-fill), var(--row-fill)),
+ linear-gradient(var(--color-sidebar), var(--color-sidebar));
+ }
+}
+
+/* While the row asks, the answers take line 2 and the fold chip only counts. */
+@utility sidebar-row-line2 {
+ line-height: 1.5;
+
+ /* Taller controls overhang the line, so the row keeps its height. */
+ & [data-line2-rest] > *,
+ & [data-status-slot] {
+ margin-block: -0.5rem;
+ }
+
+ & [data-chip-short] {
+ display: none;
+ }
+
+ &:has([data-row-answers]:not(:empty)) [data-line2-rest],
+ &:has([data-row-answers]:not(:empty)) [data-chip-full] {
+ display: none;
+ }
+
+ &:has([data-row-answers]:not(:empty)) [data-chip-short] {
+ display: inline;
+ }
+
+ /* Filled from the right, so answers that do not fit wrap alone to a full line under the rest. */
+ &:has([data-row-answers]:not(:empty)) {
+ flex-flow: row-reverse wrap;
+ row-gap: 0.25rem;
+ }
+
+ &:has([data-row-answers]:not(:empty)) > [data-status-slot] {
+ order: -1;
+ }
+
+ &:has([data-row-answers]:not(:empty)) > [data-worker-fold],
+ &:has([data-row-answers]:not(:empty)) > [data-worker-chip] {
+ order: 1;
+ }
+
+ /* Settle's slot overhangs the line, and must not take the top of the button below it. */
+ & > [data-row-answers] {
+ position: relative;
+ z-index: 1;
+ order: 2;
+ flex: 1 1 auto;
+ max-width: 100%;
+ }
+}
+
+/* The tree line from a row's icon down its workers, with an elbow into the last one. */
+@utility sidebar-worker-guide {
+ --guide: color-mix(in srgb, var(--muted-foreground) 35%, var(--sidebar));
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 18px;
+ width: 1px;
+ background: var(--guide);
+ }
+
+ &[data-last-worker]::before {
+ bottom: 50%;
+ }
+
+ &[data-last-worker]::after {
+ content: "";
+ position: absolute;
+ top: calc(50% - 1px);
+ left: 18px;
+ width: 4px;
+ height: 1px;
+ background: var(--guide);
+ }
+}
+
+/* Only the buttons decide whether the answers fit, and a long detail gets a line of its own. */
+[data-answer-detail] {
+ flex-basis: 1rem;
+ contain: inline-size;
+}
+
+[data-row-answers] button {
+ height: 1.125rem;
+ min-height: 0;
+ min-width: 1.5rem;
+ padding-inline: 0.4375rem;
+ border-radius: 9999px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ max-width: 100%;
+}
+
+[data-row-answers] button[aria-label="More approval options"] {
+ width: 1.125rem;
+ padding-inline: 0;
+}
+
+/* The pills are 18px tall with a 1px border, and this keeps their hit area at 24px,
+ also on a coarse pointer, where Button's 44px area would cover the terminal beside them. */
+[data-row-answers] button::after {
+ content: "";
+ position: absolute;
+ inset: -4px 0;
+ width: auto;
+ height: auto;
+ min-width: 0;
+ min-height: 0;
+}
+
+/* Keeps a focused or revealed row clear of the sticky Waiting on you list. */
+ul:has(> [data-testid="sidebar-waiting-on-you"]) > li > [data-sidebar-row] {
+ scroll-margin-bottom: 14rem;
+}
diff --git a/apps/web/src/components/sidebar/variants/useDetailedFolders.ts b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts
new file mode 100644
index 0000000000000..9db364e8fe5e5
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts
@@ -0,0 +1,99 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import * as Schema from "effect/Schema";
+import { useState } from "react";
+
+import { useLocalStorage } from "../../../hooks/useLocalStorage";
+import { resolveProjectExpanded } from "../../../uiStateStore";
+import type { SidebarProjectSnapshot } from "../../../sidebarProjectGrouping";
+import { groupSidebarThreadsByProject, sidebarFolderExpansionKeys } from "../../Sidebar.logic";
+import {
+ arrangeDetailedFolder,
+ detailedFolderShows,
+ workersByParent,
+} from "./sidebarVariant.logic";
+
+const PARKED_OPEN_KEY = "t3code:sidebar:detailed-parked-open";
+const ParkedOpenKeys = Schema.Array(Schema.String);
+const keepEveryGroup = () => true;
+
+/** Detailed keeps each project's threads in its folder, so it arranges pinned and parked rows too. */
+export function useDetailedFolders(
+ input: {
+ readonly groups: readonly SidebarProjectSnapshot[];
+ readonly threads: readonly EnvironmentThreadShell[];
+ readonly pinned: readonly EnvironmentThreadShell[];
+ readonly active: readonly EnvironmentThreadShell[];
+ readonly snoozed: readonly EnvironmentThreadShell[];
+ readonly settled: readonly EnvironmentThreadShell[];
+ readonly projectExpandedById: Readonly>;
+ readonly routeRowKey: string | null;
+ readonly routeThreadKey: string | null;
+ readonly keepEmpty: (group: SidebarProjectSnapshot) => boolean;
+ } | null,
+) {
+ const [workerFold, setWorkerFold] = useState>(new Map());
+ const [parkedOpenKeys, setParkedOpenKeys] = useLocalStorage(
+ PARKED_OPEN_KEY,
+ [] as readonly string[],
+ ParkedOpenKeys,
+ );
+ if (input === null) return null;
+
+ const workers = workersByParent(input.threads);
+ const inGroups = (list: readonly EnvironmentThreadShell[]) =>
+ new Map(
+ groupSidebarThreadsByProject(input.groups, list, keepEveryGroup).map((entry) => [
+ entry.group.projectKey,
+ entry.threads,
+ ]),
+ );
+ const pinned = inGroups(input.pinned);
+ const active = inGroups(input.active);
+ const snoozed = inGroups(input.snoozed);
+ const settled = inGroups(input.settled);
+
+ const folders = input.groups.flatMap((group) => {
+ const key = group.projectKey;
+ const lists = {
+ pinned: pinned.get(key) ?? [],
+ active: active.get(key) ?? [],
+ snoozed: snoozed.get(key) ?? [],
+ settled: settled.get(key) ?? [],
+ };
+ if (!detailedFolderShows(lists, input.keepEmpty(group))) return [];
+ const collapsed = !resolveProjectExpanded(
+ input.projectExpandedById,
+ sidebarFolderExpansionKeys(group),
+ );
+ return [
+ {
+ group,
+ collapsed,
+ ...arrangeDetailedFolder({
+ ...lists,
+ collapsed,
+ workers,
+ workerFold,
+ parkedOpen: parkedOpenKeys.includes(key),
+ routeRowKey: input.routeRowKey,
+ routeThreadKey: input.routeThreadKey,
+ }),
+ },
+ ];
+ });
+
+ return {
+ folders,
+ orderedThreads: folders.flatMap((folder) =>
+ folder.items.flatMap((item) => (item.kind === "thread" ? [item.thread] : [])),
+ ),
+ toggleWorkers: (parentKey: string, open: boolean) =>
+ setWorkerFold((current) => new Map(current).set(parentKey, !open)),
+ toggleParked: (projectKey: string) =>
+ setParkedOpenKeys((current) =>
+ current.includes(projectKey)
+ ? current.filter((key) => key !== projectKey)
+ : [...current, projectKey],
+ ),
+ };
+}
diff --git a/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx
new file mode 100644
index 0000000000000..489e93d1aa004
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx
@@ -0,0 +1,45 @@
+import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
+import { expect, it } from "vite-plus/test";
+
+import { render } from "../../../../test/render";
+import type { SidebarProviderEntry } from "../SidebarThreadRow";
+import { RowProviderMark } from "./variantRow";
+
+const entry = (instanceId: string, displayName: string) =>
+ ({
+ instanceId: ProviderInstanceId.make(instanceId),
+ driverKind: ProviderDriverKind.make("codex"),
+ displayName,
+ enabled: true,
+ models: [],
+ }) as unknown as SidebarProviderEntry;
+
+const personal = entry("codex", "Codex");
+const work = entry("codex_work", "Codex Work");
+
+it("tells two instances of one provider apart on the mark", async () => {
+ await using screen = await render(
+ ,
+ );
+ await expect.element(screen.getByText("CW")).toBeInTheDocument();
+});
+
+it("adds nothing when the provider has one instance", async () => {
+ await using screen = await render(
+ ,
+ );
+ expect(screen.container.textContent).toBe("");
+});
diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx
new file mode 100644
index 0000000000000..63f928d9a882a
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx
@@ -0,0 +1,224 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import type { ReactNode } from "react";
+import { expect, it, vi } from "vite-plus/test";
+
+import "~/index.css";
+
+import { render } from "../../../../test/render";
+import { installSidebarTree } from "../../../lib/sidebarTree";
+
+vi.mock("./RowAnswers", () => ({
+ InlineRowAnswers: () => Allow
,
+ RowAnswersPopover: () => null,
+}));
+
+import { WorkerFoldChip, resolveVariantRow } from "./variantRow";
+
+const thread = {
+ title: "A thread",
+ hasPendingApprovals: false,
+ hasPendingUserInput: false,
+} as unknown as EnvironmentThreadShell;
+
+const row = (overrides: Partial[0]>) =>
+ resolveVariantRow({
+ style: "folder-detailed",
+ threadKey: "env:parent",
+ thread,
+ status: "ready",
+ statusWord: null,
+ wokePill: null,
+ time: "1m",
+ isUnread: false,
+ isWoke: false,
+ isActive: false,
+ isSelected: false,
+ hasDraftSurface: false,
+ nested: false,
+ lastNested: false,
+ inFolder: true,
+ repo: null,
+ mark: null,
+ title: Title,
+ draft: null,
+ pin: null,
+ where: branch,
+ lineExtras: null,
+ remote: null,
+ terminal: null,
+ jumpHint: null,
+ workerCount: null,
+ workerSummary: null,
+ workersOpen: null,
+ onToggleWorkers: () => {},
+ answersOpen: false,
+ onAnswersOpenChange: () => {},
+ onOpenThread: () => {},
+ statusSlot: (content: ReactNode) => {content},
+ ...overrides,
+ }).body;
+
+it("keeps the terminal control on a worker's line", async () => {
+ await using screen = await render(
+ {row({ nested: true, terminal: })}
,
+ );
+ const button = screen.getByRole("button", { name: "Open terminal" }).element();
+ expect(button.closest("[data-row-line1]")).not.toBeNull();
+});
+
+it("keeps Settle reachable on a Detailed row showing answers", async () => {
+ await using screen = await render(
+
+ {row({
+ thread: { ...thread, hasPendingApprovals: true },
+ statusSlot: (content: ReactNode) => (
+
+ {content}
+
+
+ ),
+ })}
+
,
+ );
+ const settle = screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement;
+ expect(settle.getClientRects().length).toBeGreaterThan(0);
+ settle.focus();
+ expect(document.activeElement).toBe(settle);
+});
+
+it("hides the time under Settle while Settle has keyboard focus", async () => {
+ await using screen = await render(
+
+ {row({
+ statusSlot: (content: ReactNode) => (
+
+
+ {content}
+
+
+
+ ),
+ })}
+
,
+ );
+ const time = screen.getByTestId("time").element();
+ expect(getComputedStyle(time).opacity).toBe("1");
+ (screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement).focus();
+ expect(getComputedStyle(time).opacity).toBe("0");
+});
+
+it("closes an expanded fold chip with Left, and keeps focus on it", async () => {
+ const onToggle = vi.fn();
+ await using screen = await render(
+
+
+
,
+ );
+ const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement);
+ const chip = screen.getByRole("button", { name: "2 workers · 1 working", exact: true });
+ const element = chip.element() as HTMLElement;
+ expect(element.getAttribute("aria-expanded")).toBe("true");
+ element.focus();
+ element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
+ expect(onToggle).toHaveBeenCalledOnce();
+ expect(document.activeElement).toBe(element);
+ cleanup();
+});
+
+it("returns focus to the worker's parent when a focused worker row goes away", async () => {
+ await using screen = await render(
+ ,
+ );
+ const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement);
+ const parent = screen.getByRole("button", { name: "Parent" }).element();
+ const worker = screen.getByRole("button", { name: "Worker" }).element() as HTMLElement;
+ worker.focus();
+ worker.remove();
+ await vi.waitFor(() => expect(document.activeElement).toBe(parent));
+ cleanup();
+});
+
+it("counts Slim's workers with a static robot and a name", async () => {
+ await using screen = await render(
+
+ {row({ style: "folder-slim", workerCount: 2, workerSummary: "2 workers · 1 working" })}
+
,
+ );
+ const count = screen.getByRole("img", { name: "2 workers · 1 working" }).element();
+ expect(count.textContent).toBe("2");
+ expect(count.querySelector(".lucide-bot")).not.toBeNull();
+ expect(count.closest("button")).toBeNull();
+ expect(getComputedStyle(count).cursor).not.toBe("pointer");
+});
+
+it("keeps a parent's terminal control beside its answers", async () => {
+ await using screen = await render(
+
+ {row({
+ thread: { ...thread, hasPendingApprovals: true },
+ terminal: ,
+ })}
+
,
+ );
+ const button = screen.getByRole("button", { name: "Open terminal" }).element();
+ expect(button.closest("[data-line2-rest]")).toBeNull();
+});
+
+it("shows a jump label on a worker and on a parent showing answers", async () => {
+ const hint = Ctrl 1;
+ await using worker = await render({row({ nested: true, jumpHint: hint })}
);
+ expect(worker.getByText("Ctrl 1").element().closest("[data-row-line1]")).not.toBeNull();
+ await using parent = await render(
+ {row({ thread: { ...thread, hasPendingApprovals: true }, jumpHint: hint })}
,
+ );
+ const label = parent.getByText("Ctrl 1").element();
+ expect(label.closest("[data-row-line1]")).not.toBeNull();
+ expect(label.closest("[data-status-slot]")).toBeNull();
+});
+
+it("takes the fold chip into the sidebar's arrow keys", async () => {
+ const onToggle = vi.fn();
+ await using screen = await render(
+ ,
+ );
+ const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement);
+ const chip = screen
+ .getByRole("button", { name: "2 workers · 1 working", exact: true })
+ .element() as HTMLElement;
+ const parent = chip.parentElement!;
+ parent.focus();
+ parent.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ expect(document.activeElement).toBe(chip);
+ chip.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
+ expect(onToggle).toHaveBeenCalledOnce();
+ chip.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
+ expect(document.activeElement).toBe(parent);
+ cleanup();
+});
diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx
new file mode 100644
index 0000000000000..a27404c03498c
--- /dev/null
+++ b/apps/web/src/components/sidebar/variants/variantRow.tsx
@@ -0,0 +1,314 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
+import { BotIcon, ChevronRightIcon } from "lucide-react";
+import type { MouseEvent, ReactNode } from "react";
+
+import { cn } from "~/lib/utils";
+import { observeVisibleAnimation } from "../../../lib/visibleAnimation";
+import { shouldShowInstanceBadge } from "../../../providerInstances";
+import { ProviderInstanceIcon, providerTextColor } from "../../chat/ProviderInstanceIcon";
+import type { SidebarThreadStatus } from "../../Sidebar.logic";
+import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers";
+import type { SidebarProviderEntry } from "../SidebarThreadRow";
+import { rowAttention, workerTotal, type VariantRowStyle } from "./sidebarVariant.logic";
+
+/** As tall as line 2, with the state colour mixed 14% into the row. */
+export const ROW_PILL_CLASS_NAME =
+ "sidebar-row-pill inline-flex shrink-0 items-center gap-1 rounded-full text-2xs font-semibold whitespace-nowrap";
+
+const stopRowActivation = (event: MouseEvent) => event.stopPropagation();
+
+/** The provider's logo in full colour, with initials when the provider has several instances. */
+export function RowProviderMark(props: {
+ entry: SidebarProviderEntry;
+ entries: ReadonlyMap;
+ className: string;
+}) {
+ const color = providerTextColor(props.entry.driverKind);
+ return (
+
+
+
+ );
+}
+
+function RowLead(props: {
+ repo: ReactNode;
+ mark: ((className: string) => ReactNode) | null;
+}): ReactNode {
+ return (
+
+ {props.repo !== null ? (
+ <>
+ {props.repo}
+ {props.mark ? (
+
+ {props.mark("size-2")}
+
+ ) : null}
+ >
+ ) : props.mark ? (
+ {props.mark("size-3.5")}
+ ) : null}
+
+ );
+}
+
+/** The count on line 2 that folds a row's workers; the sidebar's arrow keys treat it as a row. */
+export function WorkerFoldChip(props: {
+ threadKey: string;
+ open: boolean;
+ summary: string;
+ total: string;
+ onToggle: () => void;
+}) {
+ return (
+
+ );
+}
+
+/** Detailed and Slim rows, where Slim is this row without line 2. */
+export function resolveVariantRow(input: {
+ style: VariantRowStyle;
+ threadKey: string;
+ thread: EnvironmentThreadShell;
+ status: SidebarThreadStatus;
+ statusWord: { label: string; className: string } | null;
+ wokePill: ReactNode;
+ time: ReactNode;
+ isUnread: boolean;
+ isWoke: boolean;
+ isActive: boolean;
+ isSelected: boolean;
+ hasDraftSurface: boolean;
+ nested: boolean;
+ lastNested: boolean;
+ inFolder: boolean;
+ repo: ReactNode;
+ mark: ((className: string) => ReactNode) | null;
+ title: ReactNode;
+ draft: ReactNode;
+ pin: ReactNode;
+ where: ReactNode;
+ lineExtras: ReactNode;
+ remote: ReactNode;
+ terminal: ReactNode;
+ jumpHint: ReactNode;
+ workerCount: number | null;
+ workerSummary: string | null;
+ workersOpen: boolean | null;
+ onToggleWorkers: () => void;
+ answersOpen: boolean;
+ onAnswersOpenChange: (open: boolean) => void;
+ onOpenThread: () => void;
+ statusSlot: (content: ReactNode, keepsLabel: boolean) => ReactNode;
+}) {
+ const { thread, status, statusWord, nested } = input;
+ const working = status === "working" || status === "preparing";
+ const asksYou = thread.hasPendingApprovals || thread.hasPendingUserInput;
+ const detailed = input.style === "folder-detailed";
+ const answersInPopover = !detailed && asksYou;
+ const attention = nested
+ ? undefined
+ : (rowAttention(status, input.isUnread) ?? (input.isWoke ? ("warning" as const) : undefined));
+
+ const pill =
+ input.wokePill ??
+ (statusWord === null ? null : answersInPopover ? (
+
+ ) : (
+
+ {working ? (
+
+ ) : null}
+ {statusWord.label}
+
+ ));
+
+ const pinned = input.pin ? (
+ {input.pin}
+ ) : null;
+ const lead = nested ? (
+
+ {input.mark?.("size-3")}
+
+ ) : (
+
+ );
+ const lineOne = (
+
+ {lead}
+ {input.title}
+ {input.draft}
+ {pinned}
+ {nested ? input.terminal : null}
+ {input.jumpHint}
+ {!detailed ? (
+ <>
+ {input.workerCount !== null ? (
+
+
+ {input.workerCount}
+
+ ) : null}
+ {input.terminal}
+ {input.statusSlot(
+ pill ?? {input.time},
+ answersInPopover,
+ )}
+ >
+ ) : pill !== null ? (
+ {pill}
+ ) : null}
+
+ );
+
+ const chip =
+ input.workerCount === null ? null : input.workersOpen === null ? (
+
+ {input.workerSummary}
+
+ ) : (
+
+ );
+
+ const lineTwo =
+ detailed && !nested ? (
+
+ {input.remote ? (
+
+ {input.remote}
+
+ ) : null}
+ {chip}
+ {asksYou ? : null}
+
+ {input.workerCount !== null ? : input.where}
+
+ {input.terminal}
+
+ {input.lineExtras}
+
+ {input.statusSlot(input.time, false)}
+
+ ) : null;
+
+ return {
+ rowClassName: cn(
+ "sidebar-variant-tokens sidebar-row-look",
+ nested ? "rounded-md px-2 py-0.75" : "rounded-lg",
+ !nested && (detailed ? "px-3 py-2" : "flex h-9 flex-col justify-center px-3"),
+ !nested && (input.isActive || input.isSelected) && "sidebar-row-stroke ring-1 ring-inset",
+ ),
+ rootClassName: cn(
+ nested
+ ? "sidebar-worker-guide relative ms-3 -mt-px ps-6 [contain-intrinsic-size:auto_--spacing(6)]"
+ : detailed
+ ? "[contain-intrinsic-size:auto_--spacing(14)]"
+ : "[contain-intrinsic-size:auto_--spacing(9)]",
+ !nested && input.inFolder && "ms-3",
+ ),
+ rootAttributes: nested && input.lastNested ? { "data-last-worker": "" } : {},
+ surface: nested
+ ? input.isActive
+ ? "worker-active"
+ : input.isSelected
+ ? "worker-selected"
+ : undefined
+ : input.isActive
+ ? "active"
+ : input.isSelected
+ ? "selected"
+ : input.hasDraftSurface
+ ? "draft"
+ : undefined,
+ attention,
+ labelSuffix: input.workerSummary !== null ? `, ${input.workerSummary}` : "",
+ body: (
+ <>
+ {working && !nested ? (
+
+ ) : null}
+ {lineOne}
+ {lineTwo}
+ {nested && asksYou ? (
+
+ ) : null}
+ >
+ ),
+ };
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 0830513e5be45..3142faa65dc5b 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -101,7 +101,7 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
export { Button, buttonVariants, type ButtonSize, type ButtonVariant };
const inlineButtonVariants = cva(
- "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 [text-align:var(--inline-button-text-align,center)] [white-space:var(--inline-button-white-space,nowrap)] hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
+ "relative after:absolute after:-inset-y-1 after:-inset-x-0.5 inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 [text-align:var(--inline-button-text-align,center)] [white-space:var(--inline-button-white-space,nowrap)] hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64",
{
defaultVariants: { tone: "default" },
variants: {
diff --git a/apps/web/src/components/ui/panel-tab-close-button.tsx b/apps/web/src/components/ui/panel-tab-close-button.tsx
index fb6f56a21521c..d05ae29225ee1 100644
--- a/apps/web/src/components/ui/panel-tab-close-button.tsx
+++ b/apps/web/src/components/ui/panel-tab-close-button.tsx
@@ -18,7 +18,7 @@ export function PanelTabCloseButton({
const button = (