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 ? ( +
    { + event.preventDefault(); + const name = rename.trim(); + if (!name) return; + if ( + name === current.name || + change({ type: "rename", id: randomUUID(), name }) + ) { + setRename(null); + } else { + setRenameError("A layout with that name already exists."); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setRename(null); + } + }} + > + event.currentTarget.select()} + maxLength={80} + value={rename} + disabled={!settingsHydrated} + onChange={(event) => { + setRename(event.target.value); + setRenameError(null); + }} + /> + + + {renameError ? ( + + ) : 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),