diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index ea2a80010124..c21a8e69c56d 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -54,6 +54,23 @@ const clientSettings: ClientSettings = {
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
+ sidebarCompactThreadRows: false,
+ sidebarThreadRowLayoutMode: "custom",
+ sidebarActiveThreadLayoutId: "daily",
+ sidebarSavedThreadLayouts: [
+ {
+ id: "daily",
+ name: "Daily",
+ layout: [
+ { component: "title", row: 1, alignment: "left" },
+ { component: "status", row: 2, alignment: "right" },
+ ],
+ },
+ ],
+ sidebarThreadRowLayout: [
+ { component: "title", row: 1, alignment: "left" },
+ { component: "status", row: 2, alignment: "right" },
+ ],
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index 6769586f7fa8..4ff9bca3015d 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -22,6 +22,8 @@ import {
} from "../panelAnimations";
import LegacyThreadSidebar from "./LegacySidebar";
import ThreadSidebar from "./Sidebar";
+import { ThreadListPreviewContext } from "./settings/ThreadListPreviewContext";
+import { Button } from "./ui/button";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
import {
@@ -46,6 +48,26 @@ import {
} from "./ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
+function ThreadListPreviewNavigation({ onClose }: { onClose: () => void }) {
+ const { isMobile, setOpenMobile } = useSidebar();
+ return (
+
+
Your threads · layout preview
+
+
+ );
+}
+
const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px";
function subscribeToViewportWidth(onChange: () => void): () => void {
@@ -153,6 +175,11 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed;
const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/");
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
+ const [showThreadListPreview, setShowThreadListPreview] = useState(false);
+ const previewing = isOnSettings && showThreadListPreview;
+ useEffect(() => {
+ setShowThreadListPreview(false);
+ }, [pathname]);
const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth);
// Subscribed rather than read once: the clamp must track live window size,
// and a clamped drag ends with an unchanged width, which skips the re-render
@@ -219,44 +246,51 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
}, [navigate, pathname]);
return (
-
-
-
-
- nextWidth <= currentWidth ||
- wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
- storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
- onResize: setSidebarWidth,
- }}
+
+
+
- {isOnSettings ? (
- <>
-
-
- >
- ) : legacySidebarEnabled ? (
-
- ) : (
-
- )}
-
-
- {children}
-
-
-
+
+
+ nextWidth <= currentWidth ||
+ wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
+ storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
+ onResize: setSidebarWidth,
+ }}
+ >
+ {previewing ? (
+ <>
+ setShowThreadListPreview(false)} />
+
+ >
+ ) : isOnSettings ? (
+ <>
+
+
+ >
+ ) : legacySidebarEnabled ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+
+
);
}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index c269b73ee761..b1fdb2ec3bb5 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -34,7 +34,13 @@ import {
type ScopedThreadRef,
type ThreadId,
} from "@t3tools/contracts";
-import type { TimestampFormat } from "@t3tools/contracts/settings";
+import {
+ type SidebarThreadRowLayoutMode,
+ type SidebarThreadRowPlacement,
+ type TimestampFormat,
+} from "@t3tools/contracts/settings";
+import { ThreadRowLayout } from "./ThreadRowLayout";
+import { threadRowLayoutForMode } from "./settings/savedThreadLayouts";
import {
AlarmClockIcon,
AlarmClockOffIcon,
@@ -118,6 +124,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { useClientSettings } from "../hooks/useSettings";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { useLocalStorage } from "../hooks/useLocalStorage";
+import { SidebarCompletedTime } from "./sidebar/SidebarCompletedTime";
import { useNowMinute } from "../hooks/useNowMinute";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import {
@@ -945,6 +952,8 @@ const dropVerbBadge: Record = {
const SidebarThreadRow = memo(function SidebarThreadRow(props: {
thread: SidebarThreadSummary;
variant: "card" | "slim";
+ layoutMode: SidebarThreadRowLayoutMode;
+ customLayout: ReadonlyArray;
// Slim rows are either settled (action: un-settle) or merely quiet
// (seen Ready threads — action: settle).
variantAction: "settle" | "unsettle" | "unsnooze";
@@ -1560,6 +1569,225 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
)
) : null;
+ if (props.layoutMode !== "standard") {
+ const rowActivity =
+ status === "working" ? (
+
+
+
+
+
+
+ ) : isWokeStatus ? (
+
+
+ }
+ >
+
+ {topStatus.label}
+
+ Dismiss Woke notification
+
+ ) : variantAction === "unsettle" ? (
+
+ {settledTimeLabel(thread)}
+
+ ) : status === "ready" && thread.latestTurn?.completedAt != null ? (
+
+ ) : topStatus ? (
+
+ {topStatus.label}
+
+ ) : (
+
+ {threadTimeLabel(thread)}
+
+ );
+
+ const statusIndicator = isWokeStatus ? (
+ rowActivity
+ ) : (
+
+ {topStatus?.label ??
+ (variantAction === "unsnooze"
+ ? "Snoozed"
+ : variantAction === "unsettle"
+ ? "Settled"
+ : "Ready")}
+
+ );
+ const snoozeLabel =
+ variantAction === "unsnooze" && props.snoozeWakeLabelText ? (
+
+ {props.snoozeWakeLabelText}
+
+ ) : null;
+ const components = {
+ projectIcon: props.project ? (
+
+ ) : null,
+ title,
+ pin: pinIndicator,
+ activity: snoozeLabel ?? rowActivity,
+ status: statusIndicator,
+ duration:
+ status === "working" ? (
+
+
+
+ ) : null,
+ project: props.projectDisplayName ? (
+ {props.projectDisplayName}
+ ) : null,
+ environment: props.environmentLabel ? (
+ {props.environmentLabel}
+ ) : null,
+ provider: driverKind ? (
+
+ ) : null,
+ model: {modelLabel},
+ branch: thread.branch ? (
+
+
+ {thread.branch}
+
+ ) : null,
+ worktree: thread.worktreePath?.trim() ? : null,
+ pullRequest: prBadge,
+ terminal: terminalStatusIcon,
+ updated: {threadTimeLabel(thread)},
+ created: (
+
+ {compactSidebarTimeLabel(formatRelativeTimeLabel(thread.createdAt))}
+
+ ),
+ completed: thread.latestTurn?.completedAt ? (
+
+ ) : null,
+ snooze: snoozeLabel,
+ };
+ const chosenLayout = threadRowLayoutForMode(props.layoutMode, props.customLayout);
+ // A hidden title must still be editable from the context menu or double-click.
+ const layout =
+ isRenaming && !chosenLayout.some((item) => item.component === "title")
+ ? [
+ { component: "title" as const, row: 1 as const, alignment: "left" as const },
+ ...chosenLayout,
+ ]
+ : chosenLayout;
+ return (
+
+
+
+ }
+ >
+ {draftIndicator}
+
+ {dragDestination}
+ {isRegeneratingTitle ? (
+
+ Regenerating title
+
+ ) : null}
+
+ {hasUnsentDraft ? (
+
+ ) : null}
+ {variantAction === "unsnooze" && props.snoozeSupported ? (
+
+ ) : showSnoozeButton ? (
+
+ ) : null}
+ {props.settlementSupported && variantAction !== "unsnooze" ? (
+
+ ) : null}
+
+ {props.jumpLabel ? : null}
+
+ {detailsTooltip}
+
+
+ );
+ }
+
if (variant === "slim") {
return (
+ s.sidebarThreadRowLayoutMode === "standard" && s.sidebarCompactThreadRows
+ ? "compact"
+ : s.sidebarThreadRowLayoutMode,
+ );
+ const customLayout = useClientSettings((s) => s.sidebarThreadRowLayout);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
@@ -4645,19 +4879,21 @@ export default function Sidebar() {
const threadKey = scopedThreadKey(
scopeThreadRef(thread.environmentId, thread.id),
);
- // Settled and snoozed are the ONLY things that collapse a
- // row: every other thread is a full card. Density comes
- // from users (or the auto rules) actually parking work,
- // not from the sidebar second-guessing what still matters.
+ // Standard active and pinned threads use cards while
+ // settled and snoozed threads use slim rows. Compact and
+ // custom modes take the shared layout-row branch in every
+ // section.
const isCard = section === "active" || section === "pinned";
const rowVariant = isCard ? "card" : "slim";
return (
;
+
+export type ThreadRowLayoutSideProps = {
+ row: SidebarThreadRowPlacement["row"];
+ alignment: SidebarThreadRowPlacement["alignment"];
+ className: string;
+ children: ReactNode;
+ empty: boolean;
+};
+
+export type ThreadRowLayoutRowProps = {
+ row: SidebarThreadRowPlacement["row"];
+ className: string;
+ children: ReactNode;
+};
+
+/** Shared geometry for the real list and settings preview. Missing details take no space. */
+export function ThreadRowLayout({
+ layout,
+ components,
+ renderSide,
+ renderRow,
+ showEmptyRows = false,
+}: {
+ layout: ReadonlyArray;
+ components: Partial>;
+ /** Editor-only wrapper; receives empty sides so details can be placed directly in the sample. */
+ renderSide?: (props: ThreadRowLayoutSideProps) => ReactNode;
+ renderRow?: (props: ThreadRowLayoutRowProps) => ReactNode;
+ showEmptyRows?: boolean;
+}) {
+ return (
+
+ {([1, 2, 3] as const).map((row) => {
+ const items = layout.filter(
+ (item) => item.row === row && components[item.component] != null,
+ );
+ if (items.length === 0 && !showEmptyRows) return null;
+ const className = "flex min-h-5 w-full min-w-0 items-center gap-2";
+ const children = (["left", "right"] as const).map((alignment) => {
+ const group = items.filter((item) => item.alignment === alignment);
+ if (!group.length && !renderSide) return null;
+ const className = cn(
+ "flex min-w-0 basis-auto items-center gap-1.5 overflow-hidden",
+ alignment === "left" ? "flex-1" : "ml-auto justify-end text-right",
+ );
+ const children = group.map((item) => (
+
span]:text-right",
+ )}
+ >
+ {components[item.component]}
+
+ ));
+ return renderSide ? (
+
+ {renderSide({ row, alignment, className, children, empty: !group.length })}
+
+ ) : (
+
+ {children}
+
+ );
+ });
+ return renderRow ? (
+
{renderRow({ row, className, children })}
+ ) : (
+
+ {children}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 143680509544..a89d2a19caa8 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -22,6 +22,7 @@ import {
DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
DEFAULT_UNIFIED_SETTINGS,
type DiffLayout,
+ DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
type EnvironmentIdentificationMode,
MAX_APPEARANCE_CONTRAST,
MAX_CODE_FONT_SIZE,
@@ -159,6 +160,7 @@ import {
useSettingsSearchTargetId,
} from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
+import { ThreadRowLayoutSettings } from "./ThreadRowLayoutSettings";
import { ProjectFavicon } from "../ProjectFavicon";
import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
@@ -533,6 +535,14 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
? ["Auto-settle merged threads"]
: []),
+ ...(settings.sidebarCompactThreadRows ||
+ settings.sidebarThreadRowLayoutMode !== "standard" ||
+ settings.sidebarSavedThreadLayouts.length > 0 ||
+ settings.sidebarActiveThreadLayoutId !== null ||
+ JSON.stringify(settings.sidebarThreadRowLayout) !==
+ JSON.stringify(DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT)
+ ? ["Thread list layout"]
+ : []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...getChangedTypographySettingLabels(settings),
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
@@ -633,6 +643,11 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarProjectGroupingMode,
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
+ settings.sidebarCompactThreadRows,
+ settings.sidebarThreadRowLayoutMode,
+ settings.sidebarThreadRowLayout,
+ settings.sidebarSavedThreadLayouts,
+ settings.sidebarActiveThreadLayoutId,
settings.timestampFormat,
settings.wordWrap,
followSystem,
@@ -708,6 +723,11 @@ export function useSettingsRestore(onRestored?: () => void) {
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
+ sidebarCompactThreadRows: DEFAULT_UNIFIED_SETTINGS.sidebarCompactThreadRows,
+ sidebarThreadRowLayoutMode: DEFAULT_UNIFIED_SETTINGS.sidebarThreadRowLayoutMode,
+ sidebarThreadRowLayout: DEFAULT_UNIFIED_SETTINGS.sidebarThreadRowLayout,
+ sidebarSavedThreadLayouts: DEFAULT_UNIFIED_SETTINGS.sidebarSavedThreadLayouts,
+ sidebarActiveThreadLayoutId: DEFAULT_UNIFIED_SETTINGS.sidebarActiveThreadLayoutId,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
@@ -2163,6 +2183,8 @@ export function GeneralSettingsPanel() {
}
/>
+
+
{supportsAutoSettlement ? (
<>
void;
+} | null>(null);
+
+export function useThreadListPreview() {
+ const context = useContext(ThreadListPreviewContext);
+ if (!context) throw new Error("Thread list preview requires AppSidebarLayout");
+ return context;
+}
diff --git a/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.test.ts b/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.test.ts
new file mode 100644
index 000000000000..b425d2b32ddf
--- /dev/null
+++ b/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from "vite-plus/test";
+import type { SidebarThreadRowPlacement } from "@t3tools/contracts/settings";
+import { dropThreadDetail, threadRowBlankSpace } from "./ThreadRowLayoutEditor.logic";
+
+const layout: ReadonlyArray = [
+ { component: "projectIcon", row: 1, alignment: "left" },
+ { component: "title", row: 1, alignment: "left" },
+ { component: "activity", row: 1, alignment: "right" },
+];
+
+describe("dropping thread details", () => {
+ it("adds a hidden detail to an empty row side", () => {
+ expect(
+ dropThreadDetail(layout, "model", { kind: "place", row: 2, alignment: "right" }),
+ ).toEqual([...layout, { component: "model", row: 2, alignment: "right" }]);
+ });
+
+ it("moves an existing detail between rows without duplicating it", () => {
+ const next = dropThreadDetail(layout, "title", { kind: "place", row: 3, alignment: "right" });
+ expect(next).toEqual([
+ layout[0],
+ layout[2],
+ { component: "title", row: 3, alignment: "right" },
+ ]);
+ expect(layout[1]?.row).toBe(1);
+ });
+
+ it("reorders before and after a peer in the same row", () => {
+ const next = dropThreadDetail(layout, "title", {
+ kind: "place",
+ row: 1,
+ alignment: "left",
+ relativeTo: "projectIcon",
+ edge: "before",
+ });
+ expect(next.map((item) => item.component)).toEqual(["title", "projectIcon", "activity"]);
+ expect(
+ dropThreadDetail(next, "title", {
+ kind: "place",
+ row: 1,
+ alignment: "left",
+ relativeTo: "projectIcon",
+ edge: "after",
+ }),
+ ).toEqual(layout);
+ });
+
+ it("moves across sides next to another detail", () => {
+ expect(
+ dropThreadDetail(layout, "title", {
+ kind: "place",
+ row: 1,
+ alignment: "right",
+ relativeTo: "activity",
+ edge: "after",
+ }),
+ ).toEqual([layout[0], layout[2], { component: "title", row: 1, alignment: "right" }]);
+ });
+
+ it("appends when dropped into the empty space of an occupied side", () => {
+ expect(
+ dropThreadDetail(layout, "projectIcon", { kind: "place", row: 1, alignment: "left" })
+ .filter((item) => item.alignment === "left")
+ .map((item) => item.component),
+ ).toEqual(["title", "projectIcon"]);
+ });
+
+ it("hides a detail but never removes the final visible detail", () => {
+ expect(dropThreadDetail(layout, "title", { kind: "hide" })).toEqual([layout[0], layout[2]]);
+ const onlyTitle = [{ component: "title", row: 1, alignment: "left" }] as const;
+ expect(dropThreadDetail(onlyTitle, "title", { kind: "hide" })).toBe(onlyTitle);
+ expect(dropThreadDetail(layout, "model", { kind: "hide" })).toBe(layout);
+ });
+
+ it("preserves saved settings on cancellation, outside drops and self-drops", () => {
+ expect(dropThreadDetail(layout, "title", null)).toBe(layout);
+ expect(
+ dropThreadDetail(layout, "title", {
+ kind: "place",
+ row: 1,
+ alignment: "left",
+ relativeTo: "title",
+ edge: "before",
+ }),
+ ).toBe(layout);
+ expect(
+ dropThreadDetail(layout, "title", {
+ kind: "place",
+ row: 1,
+ alignment: "left",
+ relativeTo: "projectIcon",
+ edge: "after",
+ }),
+ ).toBe(layout);
+ });
+});
+
+describe("blank-space drop targets", () => {
+ const row = { left: 100, right: 460 };
+
+ it("uses the full width for an empty row or an unoccupied side", () => {
+ expect(threadRowBlankSpace(row, [], [])).toEqual({ left: 0, width: 360 });
+ expect(threadRowBlankSpace(row, [{ right: 180 }], [])).toEqual({ left: 80, width: 280 });
+ expect(threadRowBlankSpace(row, [], [{ left: 420 }])).toEqual({ left: 0, width: 320 });
+ });
+
+ it("splits the actual gap between visible details rather than the flex container", () => {
+ const gap = threadRowBlankSpace(
+ row,
+ [{ right: 130 }, { right: 210 }],
+ [{ left: 430 }, { left: 410 }],
+ );
+ expect(gap).toEqual({ left: 110, width: 200 });
+ // The right target starts halfway through the visible gap, well before the right group.
+ expect(row.left + gap.left + gap.width / 2).toBe(310);
+ });
+
+ it("does not expose a gap when details fill or overflow the row", () => {
+ expect(threadRowBlankSpace(row, [{ right: 420 }], [{ left: 410 }]).width).toBe(0);
+ expect(threadRowBlankSpace(row, [{ right: 480 }], []).width).toBe(0);
+ });
+});
diff --git a/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.ts b/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.ts
new file mode 100644
index 000000000000..111eb6f19e35
--- /dev/null
+++ b/apps/web/src/components/settings/ThreadRowLayoutEditor.logic.ts
@@ -0,0 +1,60 @@
+import type {
+ SidebarThreadRowComponent,
+ SidebarThreadRowPlacement,
+} from "@t3tools/contracts/settings";
+
+export type ThreadDetailDropTarget =
+ | { kind: "hide" }
+ | {
+ kind: "place";
+ row: SidebarThreadRowPlacement["row"];
+ alignment: SidebarThreadRowPlacement["alignment"];
+ relativeTo?: SidebarThreadRowComponent;
+ edge?: "before" | "after";
+ };
+
+/** Apply a drop once, preserving other details and preventing an empty saved layout. */
+export function dropThreadDetail(
+ layout: ReadonlyArray,
+ component: SidebarThreadRowComponent,
+ target: ThreadDetailDropTarget | null,
+) {
+ if (!target) return layout;
+ const remaining = layout.filter((item) => item.component !== component);
+ if (target.kind === "hide")
+ return remaining.length && remaining.length !== layout.length ? remaining : layout;
+ if (target.relativeTo === component) return layout;
+ const placement = { component, row: target.row, alignment: target.alignment };
+ const relativeIndex = remaining.findIndex(
+ (item) =>
+ item.component === target.relativeTo &&
+ item.row === target.row &&
+ item.alignment === target.alignment,
+ );
+ const next = [...remaining];
+ next.splice(
+ relativeIndex < 0 ? remaining.length : relativeIndex + (target.edge === "after" ? 1 : 0),
+ 0,
+ placement,
+ );
+ return next.length === layout.length &&
+ next.every(
+ (item, i) =>
+ item.component === layout[i]?.component &&
+ item.row === layout[i]?.row &&
+ item.alignment === layout[i]?.alignment,
+ )
+ ? layout
+ : next;
+}
+
+/** The visible space between the two groups, including unused width inside a flexed title. */
+export function threadRowBlankSpace(
+ row: { left: number; right: number },
+ leftDetails: ReadonlyArray<{ right: number }>,
+ rightDetails: ReadonlyArray<{ left: number }>,
+) {
+ const start = Math.max(row.left, ...leftDetails.map((detail) => detail.right));
+ const end = Math.min(row.right, ...rightDetails.map((detail) => detail.left));
+ return { left: start - row.left, width: Math.max(0, end - start) };
+}
diff --git a/apps/web/src/components/settings/ThreadRowLayoutEditor.tsx b/apps/web/src/components/settings/ThreadRowLayoutEditor.tsx
new file mode 100644
index 000000000000..2b79ac9f2837
--- /dev/null
+++ b/apps/web/src/components/settings/ThreadRowLayoutEditor.tsx
@@ -0,0 +1,641 @@
+import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import {
+ DndContext,
+ DragOverlay,
+ PointerSensor,
+ pointerWithin,
+ useDraggable,
+ useDroppable,
+ useSensor,
+ useSensors,
+ type CollisionDetection,
+ type Modifier,
+} from "@dnd-kit/core";
+import { getEventCoordinates } from "@dnd-kit/utilities";
+import {
+ CircleDashedIcon,
+ GitBranchIcon,
+ GripVerticalIcon,
+ PinIcon,
+ TerminalIcon,
+} from "lucide-react";
+import {
+ DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ SidebarThreadRowComponent,
+ type SidebarThreadRowPlacement,
+} from "@t3tools/contracts/settings";
+import { cn } from "../../lib/utils";
+import { Button } from "../ui/button";
+import {
+ THREAD_ROW_COMPONENT_LABELS,
+ ThreadRowLayout,
+ type ThreadRowLayoutSideProps,
+ type ThreadRowLayoutRowProps,
+} from "../ThreadRowLayout";
+import {
+ dropThreadDetail,
+ threadRowBlankSpace,
+ type ThreadDetailDropTarget,
+} from "./ThreadRowLayoutEditor.logic";
+
+const SAMPLE_COMPONENTS = {
+ projectIcon: (
+ T3
+ ),
+ title: (
+ Build a custom thread list
+ ),
+ pin: ,
+ activity: (
+
+
+ 6s
+
+ ),
+ status: Working,
+ duration: "6s",
+ project: "T3 Code",
+ environment: "MacBook Pro",
+ provider: "Codex",
+ model: "GPT-5.6",
+ branch: (
+
+
+ feat/thread-layout
+
+ ),
+ worktree: "Worktree",
+ pullRequest: #9835,
+ terminal: ,
+ updated: "now",
+ created: "2h",
+ completed: "5m",
+ snooze: "Tomorrow, 9 AM",
+} satisfies Record;
+
+const ROWS = [1, 2, 3] as const;
+const SIDES = ["left", "right"] as const;
+const rowId = (row: number, side: string) => `row:${row}:${side}`;
+const edgeId = (component: SidebarThreadRowComponent, edge: "before" | "after") =>
+ `edge:${component}:${edge}`;
+
+const detectDrop: CollisionDetection = (args) => {
+ const collisions = pointerWithin(args);
+ const edge = collisions.find((collision) => String(collision.id).startsWith("edge:"));
+ const gap = collisions.find((collision) => String(collision.id).startsWith("gap:"));
+ return edge ? [edge] : gap ? [gap] : collisions;
+};
+
+// Offset only the floating copy; hit testing stays at the pointer.
+const offsetDragPreview: Modifier = ({ activatorEvent, activeNodeRect, transform }) => {
+ const pointer = activatorEvent ? getEventCoordinates(activatorEvent) : null;
+ if (!pointer || !activeNodeRect) return transform;
+ return {
+ ...transform,
+ x: transform.x + pointer.x - activeNodeRect.left + 12,
+ y: transform.y + pointer.y - activeNodeRect.top + 20,
+ };
+};
+
+function InsertionMarker({ side }: { side: "left" | "right" }) {
+ return (
+
+ );
+}
+
+function DetailFace({
+ component,
+ placed,
+}: {
+ component: SidebarThreadRowComponent;
+ placed: boolean;
+}) {
+ return placed ? (
+ SAMPLE_COMPONENTS[component]
+ ) : (
+ <>
+
+
+
+ {SAMPLE_COMPONENTS[component]}
+
+
+ {THREAD_ROW_COMPONENT_LABELS[component]}
+
+
+ >
+ );
+}
+
+function DetailChip({
+ component,
+ placed,
+ selected,
+ onSelect,
+ onRemove,
+ canRemove,
+}: {
+ component: SidebarThreadRowComponent;
+ placed: boolean;
+ selected: boolean;
+ onSelect: () => void;
+ onRemove: () => void;
+ canRemove: boolean;
+}) {
+ const drag = useDraggable({ id: component });
+ const before = useDroppable({
+ id: edgeId(component, "before"),
+ disabled: !placed || drag.isDragging,
+ });
+ const after = useDroppable({
+ id: edgeId(component, "after"),
+ disabled: !placed || drag.isDragging,
+ });
+ return (
+
+
+ {before.isOver && }
+
+
+
+ {after.isOver && }
+
+
+ );
+}
+
+function GapTarget({ row, alignment }: Pick) {
+ const { setNodeRef, isOver } = useDroppable({ id: `gap:${row}:${alignment}` });
+ return (
+
+ {isOver && }
+
+ );
+}
+
+function PreviewRow({
+ row,
+ className,
+ children,
+ editing,
+}: ThreadRowLayoutRowProps & { editing: boolean }) {
+ const root = useRef(null);
+ const [gap, setGap] = useState({ left: 0, width: 0 });
+ useLayoutEffect(() => {
+ const node = root.current;
+ if (!editing || !node) return;
+ const measure = () => {
+ const details = (side: string) =>
+ Array.from(
+ node.querySelectorAll(`[data-layout-drop="${rowId(row, side)}"] [data-layout-detail]`),
+ (detail) => detail.getBoundingClientRect(),
+ );
+ const next = threadRowBlankSpace(
+ node.getBoundingClientRect(),
+ details("left"),
+ details("right"),
+ );
+ setGap((previous) =>
+ previous.left === next.left && previous.width === next.width ? previous : next,
+ );
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [editing, row, children]);
+ return (
+
+ {children}
+ {editing && gap.width > 0 && (
+
+
+
+
+ )}
+
+ );
+}
+
+function PreviewSide({
+ row,
+ alignment,
+ className,
+ children,
+ empty,
+ picking,
+ editing,
+ onPlace,
+}: ThreadRowLayoutSideProps & {
+ picking: boolean;
+ editing: boolean;
+ onPlace: () => void;
+}) {
+ const { setNodeRef, isOver } = useDroppable({ id: rowId(row, alignment) });
+ if (empty && !editing && !picking) return null;
+ return (
+
+ {children}
+ {isOver && }
+ {empty && editing ? (
+
+ ) : picking ? (
+
+ ) : null}
+
+ );
+}
+
+function AvailableDetails({
+ children,
+ picking,
+ onHide,
+}: {
+ children: ReactNode;
+ picking: boolean;
+ onHide: () => void;
+}) {
+ const { setNodeRef, isOver } = useDroppable({ id: "available" });
+ return (
+
+
+
Available details
+ {picking ? (
+
+ ) : null}
+
+
{children}
+
+ );
+}
+
+export function ThreadRowLayoutEditor({
+ layoutId,
+ layout,
+ disabled = false,
+ onChange,
+ footer,
+ renderHeader,
+ showResetLayout = true,
+ resetLayout = DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ showAvailableDetails = true,
+}: {
+ layoutId: string;
+ layout: ReadonlyArray;
+ disabled?: boolean;
+ onChange: (layout: ReadonlyArray) => void;
+ footer?: ReactNode;
+ renderHeader: (preview: ReactNode) => ReactNode;
+ showResetLayout?: boolean;
+ resetLayout?: ReadonlyArray;
+ showAvailableDetails?: boolean;
+}) {
+ const [dragging, setDragging] = useState(null);
+ const [selection, setSelection] = useState<{
+ layoutId: string;
+ component: SidebarThreadRowComponent;
+ } | null>(null);
+ const picked = selection?.layoutId === layoutId ? selection.component : null;
+ const setPicked = (component: SidebarThreadRowComponent | null) =>
+ setSelection(component ? { layoutId, component } : null);
+ const [message, setMessage] = useState("");
+ const root = useRef(null);
+ const focusAfterChange = useRef(null);
+ useLayoutEffect(() => {
+ const component = focusAfterChange.current;
+ if (!component) return;
+ root.current?.querySelector(`[data-layout-detail="${component}"]`)?.focus();
+ focusAfterChange.current = null;
+ }, [layout]);
+ useEffect(() => {
+ if (!dragging) return;
+ // Let the drag sensor cancel before Settings handles Escape as navigation.
+ const keepInEditor = (event: KeyboardEvent) => {
+ if (event.key === "Escape") event.preventDefault();
+ };
+ window.addEventListener("keydown", keepInEditor, true);
+ return () => window.removeEventListener("keydown", keepInEditor, true);
+ }, [dragging]);
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+ const targets = new Map([["available", { kind: "hide" }]]);
+ for (const row of ROWS)
+ for (const alignment of SIDES) {
+ targets.set(rowId(row, alignment), { kind: "place", row, alignment });
+ const first = layout.find(
+ (item) => item.row === row && item.alignment === alignment && item.component !== dragging,
+ );
+ targets.set(`gap:${row}:${alignment}`, {
+ kind: "place",
+ row,
+ alignment,
+ ...(alignment === "right" && first ? { relativeTo: first.component, edge: "before" } : {}),
+ });
+ }
+ for (const item of layout)
+ for (const edge of ["before", "after"] as const)
+ targets.set(edgeId(item.component, edge), {
+ kind: "place",
+ row: item.row,
+ alignment: item.alignment,
+ relativeTo: item.component,
+ edge,
+ });
+
+ const place = (
+ component: SidebarThreadRowComponent,
+ target: ThreadDetailDropTarget | null,
+ restoreFocus = false,
+ ) => {
+ const next = dropThreadDetail(layout, component, target);
+ if (next !== layout) {
+ if (restoreFocus) focusAfterChange.current = component;
+ onChange(next);
+ }
+ setPicked(null);
+ setMessage(
+ target?.kind === "hide"
+ ? layout.some((item) => item.component === component)
+ ? next === layout
+ ? "Keep at least one detail visible."
+ : `${THREAD_ROW_COMPONENT_LABELS[component]} hidden.`
+ : "Move cancelled."
+ : target
+ ? `${THREAD_ROW_COMPONENT_LABELS[component]} placed in row ${target.row}, ${target.alignment}.`
+ : "Move cancelled.",
+ );
+ if (restoreFocus && next === layout)
+ root.current
+ ?.querySelector(`[data-layout-detail="${component}"]`)
+ ?.focus();
+ };
+ const chip = (component: SidebarThreadRowComponent, placed: boolean) => (
+ 1}
+ onSelect={() => {
+ if (picked && picked !== component && placed) {
+ place(picked, targets.get(edgeId(component, "before")) ?? null, true);
+ } else {
+ setPicked(picked === component ? null : component);
+ setMessage(
+ picked === component
+ ? "Move cancelled."
+ : `${THREAD_ROW_COMPONENT_LABELS[component]} selected. Choose a row side or another detail to place it.`,
+ );
+ }
+ }}
+ onRemove={() => place(component, { kind: "hide" }, true)}
+ />
+ );
+
+ const preview = (
+
+
+
[component, chip(component, true)]),
+ )}
+ renderRow={(props) => }
+ renderSide={(props) => (
+ {
+ if (picked)
+ place(
+ picked,
+ { kind: "place", row: props.row, alignment: props.alignment },
+ true,
+ );
+ }}
+ />
+ )}
+ />
+
+
+ );
+
+ return (
+ {
+ setPicked(null);
+ setDragging(SidebarThreadRowComponent.literals.find((id) => id === active.id) ?? null);
+ }}
+ onDragCancel={() => {
+ setDragging(null);
+ setMessage("Move cancelled.");
+ }}
+ onDragEnd={({ active, over }) => {
+ setDragging(null);
+ const component = SidebarThreadRowComponent.literals.find((id) => id === active.id);
+ if (component) place(component, over ? (targets.get(String(over.id)) ?? null) : null);
+ }}
+ >
+ {
+ if (
+ picked &&
+ event.target instanceof Element &&
+ event.target.closest("[data-layout-detail]") &&
+ ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const current = layout.find((item) => item.component === picked);
+ const row = current?.row ?? 1;
+ const alignment = current?.alignment ?? "left";
+ place(
+ picked,
+ {
+ kind: "place",
+ row:
+ event.key === "ArrowUp"
+ ? row === 3
+ ? 2
+ : 1
+ : event.key === "ArrowDown"
+ ? row === 1
+ ? 2
+ : 3
+ : row,
+ alignment:
+ event.key === "ArrowLeft"
+ ? "left"
+ : event.key === "ArrowRight"
+ ? "right"
+ : alignment,
+ },
+ true,
+ );
+ }
+ if (event.key === "Escape" && dragging) event.preventDefault();
+ if (event.key === "Escape" && picked) {
+ event.preventDefault();
+ event.stopPropagation();
+ setPicked(null);
+ setMessage("Move cancelled.");
+ }
+ }}
+ >
+
+ {renderHeader(preview)}
+ {showAvailableDetails && (
+
1 && layout.some((item) => item.component === picked)}
+ onHide={() => {
+ if (picked) place(picked, { kind: "hide" }, true);
+ }}
+ >
+ {SidebarThreadRowComponent.literals
+ .filter((component) => !layout.some((item) => item.component === component))
+ .map((component) => chip(component, false))}
+
+ )}
+
+ {(showResetLayout || footer) && (
+
+ {showResetLayout && (
+
+ )}
+ {footer}
+
+ )}
+
+ {message}
+
+
+
+ {dragging ? (
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/ThreadRowLayoutSettings.test.tsx b/apps/web/src/components/settings/ThreadRowLayoutSettings.test.tsx
new file mode 100644
index 000000000000..891b11f8eb97
--- /dev/null
+++ b/apps/web/src/components/settings/ThreadRowLayoutSettings.test.tsx
@@ -0,0 +1,110 @@
+import { DEFAULT_CLIENT_SETTINGS, type SavedThreadRowLayout } from "@t3tools/contracts/settings";
+import { act, type ReactNode } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const setup = vi.hoisted(() => ({ hydrated: false, setShowing: vi.fn() }));
+
+vi.mock("../../hooks/useSettings", () => ({
+ useClientSettingsHydrated: () => setup.hydrated,
+ usePrimarySettingsAvailable: () => true,
+}));
+
+vi.mock("../ui/sidebar", () => ({
+ useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn(), setOpen: vi.fn() }),
+}));
+
+vi.mock("./ThreadListPreviewContext", () => ({
+ useThreadListPreview: () => ({ showing: false, setShowing: setup.setShowing }),
+}));
+
+vi.mock("./ThreadRowLayoutEditor", () => ({
+ ThreadRowLayoutEditor: ({
+ renderHeader,
+ footer,
+ }: {
+ renderHeader: (preview: ReactNode) => ReactNode;
+ footer: ReactNode;
+ }) => (
+ <>
+ {renderHeader(Preview
)}
+ {footer}
+ >
+ ),
+}));
+
+import { ThreadRowLayoutSettings } from "./ThreadRowLayoutSettings";
+
+let renderer: ReactTestRenderer | undefined;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ setup.hydrated = false;
+ setup.setShowing.mockClear();
+});
+
+afterEach(async () => {
+ await act(() => renderer?.unmount());
+ renderer = undefined;
+ vi.unstubAllGlobals();
+});
+
+function duplicateButton() {
+ return renderer!.root.find(
+ (node) => node.type === "button" && node.children.includes("Duplicate"),
+ );
+}
+
+describe("thread row layout settings hydration", () => {
+ it("does not build a saved-layout patch from the pre-hydration defaults", async () => {
+ const onChange = vi.fn();
+ await act(() => {
+ renderer = create(
+ ,
+ );
+ });
+
+ const preview = () =>
+ renderer!.root.find(
+ (node) => node.type === "button" && node.children.includes("Preview my threads"),
+ );
+ expect(preview().props.disabled).toBe(true);
+ await act(() => preview().props.onClick());
+ expect(setup.setShowing).not.toHaveBeenCalled();
+
+ const duplicateBeforeHydration = duplicateButton();
+ await act(() => duplicateBeforeHydration.props.onClick());
+ expect(onChange).not.toHaveBeenCalled();
+ expect(duplicateBeforeHydration.props.disabled).toBe(true);
+
+ const existing: SavedThreadRowLayout = {
+ id: "saved-review",
+ name: "Review",
+ layout: DEFAULT_CLIENT_SETTINGS.sidebarThreadRowLayout,
+ };
+ setup.hydrated = true;
+ await act(() => {
+ renderer!.update(
+ ,
+ );
+ });
+
+ expect(preview().props.disabled).toBe(false);
+ await act(() => preview().props.onClick());
+ expect(setup.setShowing).toHaveBeenCalledWith(true);
+
+ expect(duplicateButton().props.disabled).toBe(false);
+ await act(() => duplicateButton().props.onClick());
+ expect(onChange).toHaveBeenCalledOnce();
+ expect(onChange.mock.calls[0]?.[0].sidebarSavedThreadLayouts).toEqual([
+ existing,
+ expect.objectContaining({ name: "Standard copy" }),
+ ]);
+ });
+});
diff --git a/apps/web/src/components/settings/ThreadRowLayoutSettings.tsx b/apps/web/src/components/settings/ThreadRowLayoutSettings.tsx
new file mode 100644
index 000000000000..d43b787a1f0c
--- /dev/null
+++ b/apps/web/src/components/settings/ThreadRowLayoutSettings.tsx
@@ -0,0 +1,248 @@
+import { useEffect, useId, useRef, useState } from "react";
+import { type ClientSettings, type ClientSettingsPatch } from "@t3tools/contracts/settings";
+import { CheckIcon, XIcon } from "lucide-react";
+import { randomUUID } from "../../lib/utils";
+import { Button } from "../ui/button";
+import { Input } from "../ui/input";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectPopup,
+ SelectItem,
+ SelectSeparator,
+} from "../ui/select";
+import { SettingsRow, SettingResetButton } from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+import { useSidebar } from "../ui/sidebar";
+import { ThreadRowLayoutEditor } from "./ThreadRowLayoutEditor";
+import {
+ changeSavedThreadLayout,
+ resolveSavedThreadLayouts,
+ COMPACT_THREAD_LAYOUT,
+} from "./savedThreadLayouts";
+import { useThreadListPreview } from "./ThreadListPreviewContext";
+import { useClientSettingsHydrated } from "../../hooks/useSettings";
+
+export function ThreadRowLayoutSettings({
+ settings,
+ onChange,
+}: {
+ settings: ClientSettings;
+ onChange: (patch: ClientSettingsPatch) => void;
+}) {
+ const { layouts, current, preset } = resolveSavedThreadLayouts(settings);
+ const settingsHydrated = useClientSettingsHydrated();
+ const [rename, setRename] = useState(null);
+ const [renameError, setRenameError] = useState(null);
+ const renameErrorId = useId();
+ const pickerRef = useRef(null);
+ const wasRenaming = useRef(false);
+ useEffect(() => {
+ if (rename === null && wasRenaming.current) pickerRef.current?.focus();
+ wasRenaming.current = rename !== null;
+ }, [rename]);
+ const { showing, setShowing } = useThreadListPreview();
+ const { isMobile, setOpenMobile, setOpen } = useSidebar();
+ useEffect(() => () => setShowing(false), [setShowing]);
+ const change = (action: Parameters[1]) => {
+ if (!settingsHydrated) return false;
+ const patch = changeSavedThreadLayout(settings, action);
+ if (patch) onChange(patch);
+ return patch !== null;
+ };
+ return (
+ (
+ {
+ setRename(null);
+ change({ type: "select", id: "preset:standard" });
+ }}
+ />
+ ) : null
+ }
+ className="px-0 pt-0 pb-0 sm:px-0"
+ >
+
+
{preview}
+
+ {rename !== null ? (
+
+ ) : (
+ <>
+
+ >
+ )}
+
+ {rename === null && preset === null && (
+
+ )}
+
+
+
+
+
+ )}
+ layoutId={current.id}
+ layout={current.layout}
+ disabled={!settingsHydrated}
+ showAvailableDetails={preset === null}
+ showResetLayout={preset === null}
+ resetLayout={preset ? current.layout : COMPACT_THREAD_LAYOUT.layout}
+ onChange={(layout) => change({ type: "edit", id: randomUUID(), layout })}
+ footer={
+ <>
+
+ {preset === null ? (
+
+ ) : null}
+ >
+ }
+ />
+ );
+}
diff --git a/apps/web/src/components/settings/savedThreadLayouts.test.ts b/apps/web/src/components/settings/savedThreadLayouts.test.ts
new file mode 100644
index 000000000000..a2a60febd11b
--- /dev/null
+++ b/apps/web/src/components/settings/savedThreadLayouts.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ DEFAULT_CLIENT_SETTINGS,
+ DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+} from "@t3tools/contracts/settings";
+import {
+ changeSavedThreadLayout,
+ resolveSavedThreadLayouts,
+ STANDARD_THREAD_LAYOUT,
+ COMPACT_THREAD_LAYOUT,
+ threadRowLayoutForMode,
+} from "./savedThreadLayouts";
+
+const original = {
+ ...DEFAULT_CLIENT_SETTINGS,
+ sidebarThreadRowLayoutMode: "custom" as const,
+ sidebarThreadRowLayout: [{ component: "title", row: 2, alignment: "right" }] as const,
+};
+
+describe("saved thread layouts", () => {
+ it("rejects renaming to another saved or built-in layout name", () => {
+ const first = changeSavedThreadLayout(original, {
+ type: "create",
+ id: "one",
+ duplicate: false,
+ })!;
+ const second = changeSavedThreadLayout(first, { type: "create", id: "two", duplicate: false })!;
+ for (const name of ["Layout", "Standard", "Compact"]) {
+ expect(changeSavedThreadLayout(second, { type: "rename", id: "unused", name })).toBeNull();
+ }
+ });
+ it("preserves the pre-existing arrangement when creating a fresh layout", () => {
+ const next = changeSavedThreadLayout(original, {
+ type: "create",
+ id: "new",
+ duplicate: false,
+ })!;
+ expect(next.sidebarThreadRowLayout).toEqual(DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT);
+ expect(next.sidebarSavedThreadLayouts[0]?.layout).toEqual(original.sidebarThreadRowLayout);
+ const restored = changeSavedThreadLayout(next, { type: "select", id: "current" });
+ expect(restored?.sidebarThreadRowLayout).toEqual(original.sidebarThreadRowLayout);
+ });
+
+ it("edits a duplicate independently and restores both arrangements when switching", () => {
+ const copy = changeSavedThreadLayout(original, {
+ type: "create",
+ id: "copy",
+ duplicate: true,
+ })!;
+ const edited = changeSavedThreadLayout(copy, {
+ type: "edit",
+ id: "unused",
+ layout: DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ })!;
+ const first = changeSavedThreadLayout(edited, { type: "select", id: "current" })!;
+ expect(first.sidebarThreadRowLayout).toEqual(original.sidebarThreadRowLayout);
+ expect(
+ changeSavedThreadLayout(first, { type: "select", id: "copy" })?.sidebarThreadRowLayout,
+ ).toEqual(DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT);
+ expect(original.sidebarThreadRowLayout[0]?.row).toBe(2);
+ });
+
+ it("renames, deletes and selects a surviving layout, and falls back to Standard after deleting the last custom layout", () => {
+ const copy = changeSavedThreadLayout(original, {
+ type: "create",
+ id: "copy",
+ duplicate: true,
+ })!;
+ const renamed = changeSavedThreadLayout(copy, {
+ type: "rename",
+ id: "renamed",
+ name: " Focus ",
+ })!;
+ expect(resolveSavedThreadLayouts(renamed).current.name).toBe("Focus");
+ const remaining = changeSavedThreadLayout(renamed, { type: "delete" })!;
+ expect(remaining.sidebarActiveThreadLayoutId).toBe("current");
+ expect(remaining.sidebarThreadRowLayout).toEqual(original.sidebarThreadRowLayout);
+ const empty = changeSavedThreadLayout(remaining, { type: "delete" })!;
+ expect(empty.sidebarSavedThreadLayouts).toEqual([]);
+ expect(resolveSavedThreadLayouts(empty).current.name).toBe("Standard");
+ expect(changeSavedThreadLayout(empty, { type: "delete" })).toBeNull();
+ expect(
+ changeSavedThreadLayout(remaining, { type: "rename", id: "renamed", name: " " }),
+ ).toBeNull();
+ expect(changeSavedThreadLayout(remaining, { type: "select", id: "missing" })).toBeNull();
+ });
+
+ it("gives new layouts distinct names and retains saved layouts when selection is missing", () => {
+ const first = changeSavedThreadLayout(original, {
+ type: "create",
+ id: "one",
+ duplicate: false,
+ })!;
+ const second = changeSavedThreadLayout(first, { type: "create", id: "two", duplicate: false })!;
+ expect(resolveSavedThreadLayouts(second).current.name).toBe("Layout 2");
+ const orphaned = resolveSavedThreadLayouts({ ...second, sidebarActiveThreadLayoutId: null });
+ expect(orphaned.layouts.map((item) => item.id)).toEqual([
+ "preset:standard",
+ "preset:compact",
+ "current",
+ "one",
+ "two",
+ ]);
+ expect(
+ changeSavedThreadLayout(second, { type: "create", id: "one", duplicate: false }),
+ ).toBeNull();
+ });
+});
+
+describe("built-in thread layouts", () => {
+ it("maps each persisted mode to the layout rendered by the sidebar", () => {
+ expect(threadRowLayoutForMode("standard", original.sidebarThreadRowLayout)).toBe(
+ STANDARD_THREAD_LAYOUT.layout,
+ );
+ expect(threadRowLayoutForMode("compact", original.sidebarThreadRowLayout)).toBe(
+ COMPACT_THREAD_LAYOUT.layout,
+ );
+ expect(threadRowLayoutForMode("custom", original.sidebarThreadRowLayout)).toBe(
+ original.sidebarThreadRowLayout,
+ );
+ });
+
+ it("offers both presets without creating a custom layout, including legacy compact settings", () => {
+ const initial = resolveSavedThreadLayouts(DEFAULT_CLIENT_SETTINGS);
+ expect(initial.layouts).toEqual([STANDARD_THREAD_LAYOUT, COMPACT_THREAD_LAYOUT]);
+ expect(initial.current).toBe(STANDARD_THREAD_LAYOUT);
+ expect(
+ resolveSavedThreadLayouts({ ...DEFAULT_CLIENT_SETTINGS, sidebarCompactThreadRows: true })
+ .current,
+ ).toBe(COMPACT_THREAD_LAYOUT);
+ });
+
+ it.each([STANDARD_THREAD_LAYOUT, COMPACT_THREAD_LAYOUT])(
+ "forks $name on its first edit and keeps subsequent edits in that copy",
+ (preset) => {
+ const selected = changeSavedThreadLayout(original, { type: "select", id: preset.id })!;
+ const layout = [
+ ...preset.layout,
+ { component: "model", row: 2, alignment: "right" } as const,
+ ];
+ const edited = changeSavedThreadLayout(selected, { type: "edit", id: "fork", layout })!;
+ expect(edited.sidebarThreadRowLayoutMode).toBe("custom");
+ expect(edited.sidebarActiveThreadLayoutId).toBe("fork");
+ expect(resolveSavedThreadLayouts(edited).current.name).toBe(`${preset.name} copy`);
+ expect(edited.sidebarThreadRowLayout).toEqual(layout);
+ expect(edited.sidebarSavedThreadLayouts.map((item) => item.id)).toEqual(["current", "fork"]);
+ const again = changeSavedThreadLayout(edited, {
+ type: "edit",
+ id: "unused",
+ layout: [{ component: "title", row: 1, alignment: "left" }],
+ })!;
+ expect(again.sidebarActiveThreadLayoutId).toBe("fork");
+ expect(again.sidebarSavedThreadLayouts).toHaveLength(2);
+ const restored = changeSavedThreadLayout(again, { type: "select", id: preset.id })!;
+ expect(resolveSavedThreadLayouts(restored).current).toEqual(preset);
+ expect(restored.sidebarThreadRowLayoutMode).toBe(
+ preset === STANDARD_THREAD_LAYOUT ? "standard" : "compact",
+ );
+ expect(
+ changeSavedThreadLayout(restored, { type: "select", id: "current" })
+ ?.sidebarThreadRowLayout,
+ ).toEqual(original.sidebarThreadRowLayout);
+ },
+ );
+
+ it("does not fork a preset for a no-op edit or cancelled drag", () => {
+ expect(
+ changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, {
+ type: "edit",
+ id: "fork",
+ layout: STANDARD_THREAD_LAYOUT.layout.map((item) => ({ ...item })),
+ }),
+ ).toBeNull();
+ });
+
+ it("creates distinct names when editing the same preset again", () => {
+ const layout = [{ component: "title", row: 1, alignment: "left" }] as const;
+ const first = changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, {
+ type: "edit",
+ id: "one",
+ layout,
+ })!;
+ const selected = changeSavedThreadLayout(first, {
+ type: "select",
+ id: STANDARD_THREAD_LAYOUT.id,
+ })!;
+ const second = changeSavedThreadLayout(selected, { type: "edit", id: "two", layout })!;
+ expect(resolveSavedThreadLayouts(second).current.name).toBe("Standard copy 2");
+ expect(second.sidebarSavedThreadLayouts).toHaveLength(2);
+ });
+
+ it("copies a preset when renaming or duplicating it and never deletes a built-in", () => {
+ const renamed = changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, {
+ type: "rename",
+ id: "named",
+ name: "Focus",
+ })!;
+ expect(resolveSavedThreadLayouts(renamed).current).toEqual({
+ id: "named",
+ name: "Focus",
+ layout: STANDARD_THREAD_LAYOUT.layout,
+ });
+ const copy = changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, {
+ type: "create",
+ id: "copy",
+ duplicate: true,
+ })!;
+ expect(copy.sidebarThreadRowLayout).toEqual(STANDARD_THREAD_LAYOUT.layout);
+ expect(changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, { type: "delete" })).toBeNull();
+ expect(
+ changeSavedThreadLayout(DEFAULT_CLIENT_SETTINGS, {
+ type: "edit",
+ id: STANDARD_THREAD_LAYOUT.id,
+ layout: COMPACT_THREAD_LAYOUT.layout,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/settings/savedThreadLayouts.ts b/apps/web/src/components/settings/savedThreadLayouts.ts
new file mode 100644
index 000000000000..af23cb249d26
--- /dev/null
+++ b/apps/web/src/components/settings/savedThreadLayouts.ts
@@ -0,0 +1,199 @@
+import {
+ DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ type ClientSettings,
+ type SavedThreadRowLayout,
+ type SidebarThreadRowLayoutMode,
+ type SidebarThreadRowPlacement,
+} from "@t3tools/contracts/settings";
+
+type LayoutSettings = Pick<
+ ClientSettings,
+ | "sidebarThreadRowLayout"
+ | "sidebarSavedThreadLayouts"
+ | "sidebarActiveThreadLayoutId"
+ | "sidebarThreadRowLayoutMode"
+ | "sidebarCompactThreadRows"
+>;
+
+export const STANDARD_THREAD_LAYOUT: SavedThreadRowLayout = {
+ id: "preset:standard",
+ name: "Standard",
+ layout: [
+ { component: "projectIcon", row: 1, alignment: "left" },
+ { component: "project", row: 1, alignment: "left" },
+ { component: "pin", row: 1, alignment: "right" },
+ { component: "status", row: 1, alignment: "right" },
+ { component: "duration", row: 1, alignment: "right" },
+ { component: "title", row: 2, alignment: "left" },
+ { component: "worktree", row: 3, alignment: "left" },
+ { component: "branch", row: 3, alignment: "left" },
+ { component: "terminal", row: 3, alignment: "right" },
+ { component: "pullRequest", row: 3, alignment: "right" },
+ { component: "provider", row: 3, alignment: "right" },
+ ],
+};
+export const COMPACT_THREAD_LAYOUT: SavedThreadRowLayout = {
+ id: "preset:compact",
+ name: "Compact",
+ layout: DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+};
+const BUILT_IN_THREAD_LAYOUTS = [STANDARD_THREAD_LAYOUT, COMPACT_THREAD_LAYOUT];
+
+export function threadRowLayoutForMode(
+ mode: SidebarThreadRowLayoutMode,
+ customLayout: ReadonlyArray,
+): ReadonlyArray {
+ switch (mode) {
+ case "standard":
+ return STANDARD_THREAD_LAYOUT.layout;
+ case "compact":
+ return COMPACT_THREAD_LAYOUT.layout;
+ case "custom":
+ return customLayout;
+ }
+}
+
+const isPreset = (id: string) => BUILT_IN_THREAD_LAYOUTS.some((item) => item.id === id);
+const sameLayout = (
+ left: ReadonlyArray,
+ right: ReadonlyArray,
+) =>
+ left.length === right.length &&
+ left.every(
+ (item, i) =>
+ item.component === right[i]?.component &&
+ item.row === right[i]?.row &&
+ item.alignment === right[i]?.alignment,
+ );
+
+/** Keep built-ins out of saved state and preserve arrangements from before the layout library. */
+export function resolveSavedThreadLayouts(settings: LayoutSettings) {
+ const mode =
+ settings.sidebarThreadRowLayoutMode === "standard" && settings.sidebarCompactThreadRows
+ ? "compact"
+ : settings.sidebarThreadRowLayoutMode;
+ const saved = settings.sidebarSavedThreadLayouts.filter((item) => !isPreset(item.id));
+ const active = saved.find((item) => item.id === settings.sidebarActiveThreadLayoutId);
+ const custom = {
+ id: "current",
+ name: "My layout",
+ ...active,
+ layout: settings.sidebarThreadRowLayout,
+ };
+ const hasCustom =
+ !!active || mode === "custom" || !sameLayout(custom.layout, DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT);
+ const customLayouts = !hasCustom
+ ? saved
+ : active
+ ? saved.map((item) => (item.id === active.id ? custom : item))
+ : [custom, ...saved.filter((item) => item.id !== custom.id)];
+ const preset = mode === "custom" ? null : mode;
+ const current =
+ preset === "standard"
+ ? STANDARD_THREAD_LAYOUT
+ : preset === "compact"
+ ? COMPACT_THREAD_LAYOUT
+ : custom;
+ return { layouts: [...BUILT_IN_THREAD_LAYOUTS, ...customLayouts], current, preset };
+}
+
+type LayoutAction =
+ | { type: "edit"; id: string; layout: ReadonlyArray }
+ | { type: "select"; id: string }
+ | { type: "create"; id: string; duplicate: boolean }
+ | { type: "rename"; id: string; name: string }
+ | { type: "delete" };
+
+export function changeSavedThreadLayout(
+ settings: LayoutSettings,
+ action: LayoutAction,
+): LayoutSettings | null {
+ const resolved = resolveSavedThreadLayouts(settings);
+ let { current } = resolved;
+ let saved = resolved.layouts.filter((item) => !isPreset(item.id));
+ const create = (id: string, base: string, layout: ReadonlyArray) => {
+ if (resolved.layouts.some((item) => item.id === id)) return null;
+ let name = base;
+ for (let n = 2; resolved.layouts.some((item) => item.name === name); n++) name = `${base} ${n}`;
+ return { id, name, layout };
+ };
+ switch (action.type) {
+ case "edit": {
+ if (sameLayout(current.layout, action.layout)) return null;
+ const edited = resolved.preset
+ ? create(action.id, `${current.name} copy`, action.layout)
+ : { ...current, layout: action.layout };
+ if (!edited) return null;
+ current = edited;
+ break;
+ }
+ case "select": {
+ const selected = resolved.layouts.find((item) => item.id === action.id);
+ if (!selected) return null;
+ if (isPreset(selected.id)) {
+ return {
+ sidebarThreadRowLayout: settings.sidebarThreadRowLayout,
+ sidebarThreadRowLayoutMode:
+ selected.id === STANDARD_THREAD_LAYOUT.id ? "standard" : "compact",
+ sidebarCompactThreadRows: false,
+ sidebarActiveThreadLayoutId: resolved.preset
+ ? settings.sidebarActiveThreadLayoutId
+ : current.id,
+ sidebarSavedThreadLayouts: saved,
+ };
+ }
+ current = selected;
+ break;
+ }
+ case "create": {
+ const created = create(
+ action.id,
+ action.duplicate ? `${current.name.slice(0, 65)} copy` : "Layout",
+ action.duplicate ? current.layout : DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ );
+ if (!created) return null;
+ current = created;
+ break;
+ }
+ case "rename": {
+ const name = action.name.trim();
+ if (
+ !name ||
+ name.length > 80 ||
+ name === current.name ||
+ resolved.layouts.some((item) => item.id !== current.id && item.name === name)
+ )
+ return null;
+ const renamed = resolved.preset
+ ? create(action.id, name, current.layout)
+ : { ...current, name };
+ if (!renamed) return null;
+ current = renamed;
+ break;
+ }
+ case "delete": {
+ if (resolved.preset) return null;
+ saved = saved.filter((item) => item.id !== current.id);
+ const next = saved[0];
+ if (!next)
+ return {
+ sidebarThreadRowLayoutMode: "standard",
+ sidebarCompactThreadRows: false,
+ sidebarThreadRowLayout: DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
+ sidebarActiveThreadLayoutId: null,
+ sidebarSavedThreadLayouts: [],
+ };
+ current = next;
+ break;
+ }
+ }
+ return {
+ sidebarThreadRowLayoutMode: "custom",
+ sidebarCompactThreadRows: false,
+ sidebarThreadRowLayout: current.layout,
+ sidebarActiveThreadLayoutId: current.id,
+ sidebarSavedThreadLayouts: saved.some((item) => item.id === current.id)
+ ? saved.map((item) => (item.id === current.id ? current : item))
+ : [...saved, current],
+ };
+}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index f56579990714..e92089f60fc2 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -168,6 +168,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["combine matching repositories environments sidebar"],
},
+ {
+ id: "compact-thread-list",
+ title: "Thread list layout",
+ searchTerms: ["standard compact custom rows saved layouts preview my threads"],
+ to: "/settings/general",
+ },
{
id: "auto-settle-inactive-threads",
title: "Auto-settle inactive threads",
diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx
new file mode 100644
index 000000000000..7327af79e794
--- /dev/null
+++ b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx
@@ -0,0 +1,56 @@
+import { act, memo } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
+
+import { SidebarCompletedTime } from "./SidebarCompletedTime";
+
+let renderer: ReactTestRenderer | undefined;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-07T01:01:00Z"));
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("window", {
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ });
+});
+
+afterEach(async () => {
+ await act(() => renderer?.unmount());
+ renderer = undefined;
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+});
+
+it("advances visible and accessible completion times without rerendering its memoized row", async () => {
+ const rowRender = vi.fn();
+ const Row = memo(function Row() {
+ rowRender();
+ return ;
+ });
+ await act(() => {
+ renderer = create(
);
+ });
+ expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z");
+ expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]);
+ expect(
+ renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]),
+ ).toHaveLength(0);
+ expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([
+ "1m",
+ ]);
+
+ await act(() => vi.advanceTimersByTime(60_000));
+
+ expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]);
+ expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([
+ "2m",
+ ]);
+ expect(rowRender).toHaveBeenCalledTimes(1);
+ await act(() => renderer!.unmount());
+ renderer = undefined;
+ expect(vi.getTimerCount()).toBe(0);
+});
diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx
new file mode 100644
index 000000000000..6776c69081d7
--- /dev/null
+++ b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx
@@ -0,0 +1,22 @@
+import { CircleCheckIcon } from "lucide-react";
+
+import { useNowMinute } from "../../hooks/useNowMinute";
+import { formatRelativeTimeLabel } from "../../timestampFormat";
+
+export function SidebarCompletedTime({ completedAt }: { completedAt: string }) {
+ // Subscribe inside the label so time advances even when the row is memoized.
+ const nowMinute = useNowMinute();
+ const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`));
+ const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, "");
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts
index 8c6287010d8f..5169e438bb2d 100644
--- a/apps/web/src/timestampFormat.test.ts
+++ b/apps/web/src/timestampFormat.test.ts
@@ -228,3 +228,15 @@ describe("formatElapsedDurationLabel", () => {
expect(formatElapsedDurationLabel("2026-04-03T12:00:00.000Z")).toBe("4d");
});
});
+
+describe("explicit relative-time clock", () => {
+ it("uses the supplied minute instead of the wall clock", () => {
+ const completedAt = "2026-09-07T01:00:00Z";
+ expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:01:00Z"))).toBe("1m ago");
+ expect(formatRelativeTimeLabel(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toBe("2m ago");
+ expect(formatRelativeTime(completedAt, Date.parse("2026-09-07T01:02:00Z"))).toEqual({
+ value: "2m",
+ suffix: "ago",
+ });
+ });
+});
diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts
index 9dd463bb50fa..983a9bb8b232 100644
--- a/apps/web/src/timestampFormat.ts
+++ b/apps/web/src/timestampFormat.ts
@@ -196,10 +196,10 @@ export type RelativeTimeState =
| { status: "invalid" }
| { status: "relative"; value: string; suffix: string | null };
-export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
+export function formatRelativeTime(isoDate: string, nowMs = Date.now()): RelativeTimeParts | null {
const date = parseTimestampDate(isoDate);
if (!date) return null;
- const diffMs = Date.now() - date.getTime();
+ const diffMs = nowMs - date.getTime();
if (diffMs < 0) return { value: "just now", suffix: null };
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return { value: "just now", suffix: null };
@@ -211,8 +211,8 @@ export function formatRelativeTime(isoDate: string): RelativeTimeParts | null {
return { value: `${days}d`, suffix: "ago" };
}
-export function formatRelativeTimeLabel(isoDate: string) {
- const relative = formatRelativeTime(isoDate);
+export function formatRelativeTimeLabel(isoDate: string, nowMs = Date.now()) {
+ const relative = formatRelativeTime(isoDate, nowMs);
if (!relative) return "";
return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value;
}
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 360057dd56a1..43fd03b6c546 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -1,5 +1,16 @@
# Working with threads
+## Custom thread layouts
+
+In **Settings → General → Thread list layout**, choose Standard, Compact, or
+**New layout…**. Drag sample details into the preview to choose their order, row,
+and alignment. Drag a placed detail back to Available details to hide it.
+
+Edits save automatically. Editing Standard or Compact creates a separate custom
+layout; the built-ins stay available. Duplicate a layout to try a variation, and
+use **Preview my threads** to see it across your actual sidebar before returning
+to Settings. Named layouts and the selection are saved on this client.
+
Use a new thread for a separate task. Choose **New worktree** when its code changes
need a separate branch and working directory.
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 7322307c1f89..0e4704b709b6 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -7,6 +7,7 @@ import {
ClientSettingsPatch,
ClaudeSettings,
DEFAULT_SERVER_SETTINGS,
+ DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT,
resolveProviderInstanceEnabled,
ServerSettings,
ServerSettingsPatch,
@@ -390,7 +391,18 @@ describe("ClientSettings environment identification", () => {
describe("ClientSettings sidebar", () => {
it("defaults to the current sidebar", () => {
- expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false);
+ const settings = decodeClientSettings({});
+ expect(settings.legacySidebarEnabled).toBe(false);
+ expect(settings.sidebarCompactThreadRows).toBe(false);
+ });
+
+ it("preserves an explicit compact thread row preference", () => {
+ expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe(
+ true,
+ );
+ expect(
+ decodeClientSettingsPatch({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows,
+ ).toBe(true);
});
it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
@@ -772,3 +784,82 @@ it("validates remote device hosts and rejects ambiguous host ids", () => {
).toThrow();
expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
});
+
+describe("ClientSettings thread row layout", () => {
+ it("keeps existing installs standard and preserves the earlier compact preference", () => {
+ const defaults = decodeClientSettings({});
+ expect(defaults.sidebarThreadRowLayoutMode).toBe("standard");
+ expect(defaults.sidebarThreadRowLayout).toEqual(DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT);
+ expect(defaults.sidebarSavedThreadLayouts).toEqual([]);
+ expect(defaults.sidebarActiveThreadLayoutId).toBeNull();
+ expect(decodeClientSettings({ sidebarCompactThreadRows: true }).sidebarCompactThreadRows).toBe(
+ true,
+ );
+ });
+
+ it("round-trips custom order, rows and alignment through persisted settings", () => {
+ const patch = decodeClientSettingsPatch({
+ sidebarThreadRowLayoutMode: "custom",
+ sidebarThreadRowLayout: [
+ { component: "model", row: 2, alignment: "left" },
+ { component: "title", row: 1, alignment: "right" },
+ { component: "project", row: 2, alignment: "left" },
+ ],
+ });
+ const settings = decodeClientSettings(patch);
+ const reloaded = decodeClientSettings(
+ JSON.parse(JSON.stringify(encodeClientSettings(settings))),
+ );
+ expect(reloaded.sidebarThreadRowLayoutMode).toBe("custom");
+ expect(reloaded.sidebarThreadRowLayout).toEqual(patch.sidebarThreadRowLayout);
+ });
+
+ it.each(
+ [
+ [],
+ [{ component: "title", row: 0, alignment: "left" }],
+ [{ component: "title", row: 1, alignment: "center" }],
+ [{ component: "unknown", row: 1, alignment: "left" }],
+ [
+ { component: "title", row: 1, alignment: "left" },
+ { component: "title", row: 2, alignment: "right" },
+ ],
+ ].map((layout) => ({ layout })),
+ )("rejects malformed or ambiguous layouts: $layout", ({ layout }) => {
+ expect(() => decodeClientSettings({ sidebarThreadRowLayout: layout })).toThrow();
+ expect(() => decodeClientSettingsPatch({ sidebarThreadRowLayout: layout })).toThrow();
+ });
+
+ it("allows hiding the title and keeps the custom arrangement when changing modes", () => {
+ const settings = decodeClientSettings({
+ sidebarThreadRowLayoutMode: "custom",
+ sidebarThreadRowLayout: [{ component: "projectIcon", row: 1, alignment: "right" }],
+ });
+ const standard = decodeClientSettings({
+ ...settings,
+ ...decodeClientSettingsPatch({ sidebarThreadRowLayoutMode: "standard" }),
+ });
+ expect(standard.sidebarThreadRowLayout).toEqual(settings.sidebarThreadRowLayout);
+ expect(() => decodeClientSettingsPatch({ sidebarThreadRowLayoutMode: "invalid" })).toThrow();
+ });
+});
+
+describe("saved thread layout settings", () => {
+ const saved = { id: "daily", name: "Daily", layout: DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT };
+ it("round-trips the library and active selection through persistence and patches", () => {
+ const patch = decodeClientSettingsPatch({
+ sidebarSavedThreadLayouts: [saved],
+ sidebarActiveThreadLayoutId: "daily",
+ });
+ const reloaded = decodeClientSettings(
+ JSON.parse(JSON.stringify(encodeClientSettings(decodeClientSettings(patch)))),
+ );
+ expect(reloaded.sidebarSavedThreadLayouts).toEqual([saved]);
+ expect(reloaded.sidebarActiveThreadLayoutId).toBe("daily");
+ });
+ it("rejects duplicate IDs, blank names and invalid nested arrangements", () => {
+ for (const layouts of [[saved, saved], [{ ...saved, name: " " }], [{ ...saved, layout: [] }]]) {
+ expect(() => decodeClientSettingsPatch({ sidebarSavedThreadLayouts: layouts })).toThrow();
+ }
+ });
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index dd6136461fc1..7bfa62d54455 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -53,6 +53,63 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at
export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
+export const SidebarThreadRowLayoutMode = Schema.Literals(["standard", "compact", "custom"]);
+export type SidebarThreadRowLayoutMode = typeof SidebarThreadRowLayoutMode.Type;
+export const SidebarThreadRowComponent = Schema.Literals([
+ "projectIcon",
+ "title",
+ "pin",
+ "activity",
+ "status",
+ "duration",
+ "project",
+ "environment",
+ "provider",
+ "model",
+ "branch",
+ "worktree",
+ "pullRequest",
+ "terminal",
+ "updated",
+ "created",
+ "completed",
+ "snooze",
+]);
+export type SidebarThreadRowComponent = typeof SidebarThreadRowComponent.Type;
+export const SidebarThreadRowPlacement = Schema.Struct({
+ component: SidebarThreadRowComponent,
+ row: Schema.Literals([1, 2, 3]),
+ alignment: Schema.Literals(["left", "right"]),
+});
+export type SidebarThreadRowPlacement = typeof SidebarThreadRowPlacement.Type;
+export const SidebarThreadRowLayout = Schema.Array(SidebarThreadRowPlacement).check(
+ Schema.isMinLength(1),
+ Schema.makeFilter(
+ (items) =>
+ new Set(items.map((item) => item.component)).size === items.length ||
+ "Each thread detail can appear only once",
+ ),
+);
+export const SavedThreadRowLayout = Schema.Struct({
+ id: TrimmedNonEmptyString,
+ name: TrimmedNonEmptyString.check(Schema.isMaxLength(80)),
+ layout: SidebarThreadRowLayout,
+});
+export type SavedThreadRowLayout = typeof SavedThreadRowLayout.Type;
+export const SavedThreadRowLayouts = Schema.Array(SavedThreadRowLayout).check(
+ Schema.makeFilter(
+ (items) =>
+ new Set(items.map((item) => item.id)).size === items.length || "Layout IDs must be unique",
+ ),
+);
+export const DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT: ReadonlyArray = [
+ { component: "projectIcon", row: 1, alignment: "left" },
+ { component: "title", row: 1, alignment: "left" },
+ { component: "pin", row: 1, alignment: "right" },
+ { component: "pullRequest", row: 1, alignment: "right" },
+ { component: "activity", row: 1, alignment: "right" },
+];
+
export const SidebarProjectGroupingMode = Schema.Literals([
"repository",
"repository_path",
@@ -432,9 +489,22 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarThreadSortOrder: SidebarThreadSortOrder.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)),
),
+ sidebarCompactThreadRows: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
),
+ sidebarThreadRowLayoutMode: SidebarThreadRowLayoutMode.pipe(
+ Schema.withDecodingDefault(Effect.succeed("standard")),
+ ),
+ sidebarThreadRowLayout: SidebarThreadRowLayout.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_ROW_LAYOUT)),
+ ),
+ sidebarSavedThreadLayouts: SavedThreadRowLayouts.pipe(
+ Schema.withDecodingDefault(Effect.succeed([])),
+ ),
+ sidebarActiveThreadLayoutId: Schema.NullOr(TrimmedNonEmptyString).pipe(
+ Schema.withDecodingDefault(Effect.succeed(null)),
+ ),
timestampFormat: TimestampFormat.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)),
),
@@ -1389,7 +1459,12 @@ export const ClientSettingsPatch = Schema.Struct({
),
sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
+ sidebarCompactThreadRows: Schema.optionalKey(Schema.Boolean),
sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
+ sidebarThreadRowLayoutMode: Schema.optionalKey(SidebarThreadRowLayoutMode),
+ sidebarThreadRowLayout: Schema.optionalKey(SidebarThreadRowLayout),
+ sidebarSavedThreadLayouts: Schema.optionalKey(SavedThreadRowLayouts),
+ sidebarActiveThreadLayoutId: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
timestampFormat: Schema.optionalKey(TimestampFormat),
snapShotEnabled: Schema.optionalKey(Schema.Boolean),
snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean),