diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 595ba32bc290c..a34792269a601 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1212,7 +1212,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/** The tab the hidden-tabs row opens; the group thread still owns ordering and selection. */
displayThread: SidebarThreadSummary;
variant: "card" | "slim";
- // Settled rows un-settle, snoozed rows wake, hidden rows unhide, and cards settle.
+ // The category determines the action independently of the row layout.
variantAction: SidebarRowAction;
// False on environments whose server predates thread.settle/unsettle:
// the lifecycle affordances hide entirely rather than fail on click.
@@ -1604,6 +1604,39 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
},
[onUnhide, rowThreadRef],
);
+ const rowAction =
+ variantAction === "unhide"
+ ? {
+ label: "Unhide thread",
+ text: "Unhide",
+ Icon: EyeIcon,
+ onClick: handleUnhideClick,
+ supported: true,
+ }
+ : variantAction === "unsnooze"
+ ? {
+ label: "Wake thread now",
+ text: "Wake",
+ Icon: AlarmClockOffIcon,
+ onClick: handleUnsnoozeClick,
+ supported: props.snoozeSupported,
+ }
+ : variantAction === "unsettle"
+ ? {
+ label: "Un-settle thread",
+ text: "Un-settle",
+ Icon: Undo2Icon,
+ onClick: handleUnsettleClick,
+ supported: props.settlementSupported,
+ }
+ : {
+ label: "Settle thread",
+ text: "Settle",
+ Icon: CheckIcon,
+ onClick: handleSettleClick,
+ supported: props.settlementSupported,
+ };
+ const RowActionIcon = rowAction.Icon;
const handleUnpinClick = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
@@ -2243,11 +2276,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : null}
)
- ) : unifyTabs ? null : (
+ ) : unifyTabs ? null : variantAction === "unsnooze" &&
+ props.snoozeWakeLabelText !== null ? (
+ props.snoozeWakeLabelText
+ ) : variantAction === "unsettle" ? (
+ settledTimeLabel(thread)
+ ) : (
threadTimeLabel(thread)
)}
- {props.settlementSupported ||
+ {rowAction.supported ||
showSnoozeButton ||
hasUnsentDraft ||
onNewTab ||
@@ -2305,14 +2343,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
timestampFormat={props.timestampFormat}
/>
) : null}
- {props.settlementSupported ? (
+ {rowAction.supported ? (
}
>
-
- Settle
+
+ {rowAction.text}
- Settle thread
+ {rowAction.label}
) : null}
{showUnpinnedPinAction ? (
@@ -3449,6 +3487,7 @@ export default function Sidebar() {
const { hiddenTabThreads: tabThreadGroups, tabEnvironmentIds } = useHiddenTabThreads(threads);
const { createTab, closeTab } = useThreadTabActions();
const splitViewActions = useSplitViewActions();
+ const threadViews = useClientSettings((s) => s.sidebarThreadViews);
const showTabs = useClientSettings((s) => s.sidebarShowTabs);
const tabLimit = useClientSettings((s) => s.sidebarTabLimit);
const tabSortOrder = useClientSettings((s) => s.sidebarTabSortOrder);
@@ -4250,7 +4289,15 @@ export default function Sidebar() {
>();
if (listedTabsByRowKey.size === 0) return layouts;
const cardByKey = new Map(
- [...pinnedThreads, ...activeThreads].map((thread) => [sidebarThreadKey(thread), thread]),
+ [
+ ...(threadViews.pinned === "card" ? pinnedThreads : []),
+ ...(threadViews.active === "card" ? activeThreads : []),
+ ...(threadViews.working === "card" ? workingThreads : []),
+ ...(threadViews.grouped === "card" ? visibleGroups.flatMap((group) => group.threads) : []),
+ ...(threadViews.hidden === "card" ? visibleHiddenThreads : []),
+ ...(threadViews.snoozed === "card" ? visibleSnoozedThreads : []),
+ ...(threadViews.settled === "card" ? renderedSettledThreads : []),
+ ].map((thread) => [sidebarThreadKey(thread), thread]),
);
for (const [rowKey, rowTabs] of listedTabsByRowKey) {
const card = cardByKey.get(rowKey);
@@ -4271,6 +4318,12 @@ export default function Sidebar() {
return layouts;
}, [
activeThreads,
+ workingThreads,
+ visibleGroups,
+ visibleHiddenThreads,
+ visibleSnoozedThreads,
+ renderedSettledThreads,
+ threadViews,
expandedTabRowKey,
heldTabOrder,
listedTabsByRowKey,
@@ -6720,17 +6773,8 @@ 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.
- // Working rows stay cards so their live status shows.
- const isCard =
- section === "active" ||
- section === "pinned" ||
- section === "working" ||
- section === "grouped";
- const rowVariant = isCard ? "card" : "slim";
+ const rowVariant = threadViews[section];
+ const isCard = rowVariant === "card";
const rowTabs = tabsByRowKey.get(threadKey);
const rowTabsOpen = listedTabsByRowKey.has(threadKey);
const rowTabLayout = tabLayoutByRowKey.get(threadKey);
@@ -6746,7 +6790,7 @@ export default function Sidebar() {
thread={thread}
displayThread={displayThread}
variant={rowVariant}
- // Snoozed rows wake, settled rows un-settle, and cards settle.
+ // Each category keeps its action in either view.
variantAction={
section === "hidden"
? "unhide"
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 2440c61a3ca8f..d1fac986d54aa 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -51,6 +51,7 @@ import {
MIN_TERMINAL_LINE_HEIGHT,
type QuitConfirmationMode,
SidebarProjectSortOrder,
+ SidebarThreadView,
} from "@t3tools/contracts/settings";
import {
DEFAULT_SIDEBAR_TAB_LIMIT,
@@ -590,6 +591,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
? ["Project Grouping"]
: []),
+ ...(!Equal.equals(settings.sidebarThreadViews, DEFAULT_UNIFIED_SETTINGS.sidebarThreadViews)
+ ? ["Thread views"]
+ : []),
...(settings.sidebarShowTabs !== DEFAULT_UNIFIED_SETTINGS.sidebarShowTabs ||
settings.sidebarTabLimit !== DEFAULT_UNIFIED_SETTINGS.sidebarTabLimit
? ["Tabs in sidebar"]
@@ -769,6 +773,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.autoResumeLimitedThreads,
settings.snoozeLimitedThreads,
settings.sidebarProjectGroupingMode,
+ settings.sidebarThreadViews,
settings.sidebarShowTabs,
settings.sidebarTabLimit,
settings.sidebarTabSortOrder,
@@ -872,6 +877,7 @@ export function useSettingsRestore(onRestored?: () => void) {
environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
+ sidebarThreadViews: DEFAULT_UNIFIED_SETTINGS.sidebarThreadViews,
sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
sidebarShowTabs: DEFAULT_UNIFIED_SETTINGS.sidebarShowTabs,
@@ -2547,6 +2553,66 @@ export function GeneralSettingsPanel() {
{settings.legacySidebarEnabled ? null : (
<>
+
+ updateSettings({
+ sidebarThreadViews: DEFAULT_UNIFIED_SETTINGS.sidebarThreadViews,
+ })
+ }
+ />
+ ) : undefined
+ }
+ />
+ {(
+ [
+ ["active", "Active"],
+ ["pinned", "Pinned"],
+ ["working", "Working"],
+ ["grouped", "Grouped"],
+ ["hidden", "Hidden"],
+ ["snoozed", "Snoozed"],
+ ["settled", "Settled"],
+ ] as const
+ ).map(([category, label]) => (
+ {
+ if (value === "card" || value === "slim") {
+ updateSettings({
+ sidebarThreadViews: { ...settings.sidebarThreadViews, [category]: value },
+ });
+ }
+ }}
+ >
+
+
+ {settings.sidebarThreadViews[category] === "card" ? "Detailed" : "Compact"}
+
+
+
+ {SidebarThreadView.literals.map((view) => (
+
+ {view === "card" ? "Detailed" : "Compact"}
+
+ ))}
+
+
+ }
+ />
+ ))}
{
).toBe(true);
});
});
+
+describe("ClientSettings sidebar thread views", () => {
+ it("gives hidden threads detailed cards when upgrading existing settings", () => {
+ expect(decodeClientSettings({}).sidebarThreadViews).toEqual({
+ active: "card",
+ pinned: "card",
+ working: "card",
+ grouped: "card",
+ hidden: "card",
+ snoozed: "slim",
+ settled: "slim",
+ });
+ });
+
+ it("round-trips independent category choices and defaults missing categories", () => {
+ const decoded = decodeClientSettings({
+ sidebarThreadViews: { hidden: "slim", settled: "card" },
+ });
+ expect(decoded.sidebarThreadViews.hidden).toBe("slim");
+ expect(decoded.sidebarThreadViews.settled).toBe("card");
+ expect(decoded.sidebarThreadViews.active).toBe("card");
+ expect(
+ decodeClientSettingsPatch({ sidebarThreadViews: decoded.sidebarThreadViews })
+ .sidebarThreadViews,
+ ).toEqual(decoded.sidebarThreadViews);
+ expect(() =>
+ decodeClientSettingsPatch({ sidebarThreadViews: { hidden: "invalid" } }),
+ ).toThrow();
+ });
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index e69bf00f203ea..8ab0bef088dce 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -87,6 +87,21 @@ export const SidebarTabLimit = Schema.Int.check(
);
export type SidebarTabLimit = typeof SidebarTabLimit.Type;
+export const SidebarThreadView = Schema.Literals(["card", "slim"]);
+export type SidebarThreadView = typeof SidebarThreadView.Type;
+
+export const SidebarThreadViews = Schema.Struct({
+ active: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("card" as const))),
+ pinned: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("card" as const))),
+ working: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("card" as const))),
+ grouped: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("card" as const))),
+ hidden: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("card" as const))),
+ snoozed: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("slim" as const))),
+ settled: SidebarThreadView.pipe(Schema.withDecodingDefault(Effect.succeed("slim" as const))),
+});
+export type SidebarThreadViews = typeof SidebarThreadViews.Type;
+const DEFAULT_SIDEBAR_THREAD_VIEWS = Schema.decodeSync(SidebarThreadViews)({});
+
export const SidebarProjectGroupingMode = Schema.Literals([
"repository",
"repository_path",
@@ -536,6 +551,9 @@ export const ClientSettingsSchema = Schema.Struct({
sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)),
),
+ sidebarThreadViews: SidebarThreadViews.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_VIEWS)),
+ ),
// Off: a chat's tabs share one sidebar row. On: each tab is listed under it.
sidebarShowTabs: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
// How many listed tabs show before the rest fold behind a "more" row; null shows all.
@@ -2129,6 +2147,7 @@ export const ClientSettingsPatch = Schema.Struct({
sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder),
sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder),
sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount),
+ sidebarThreadViews: Schema.optionalKey(SidebarThreadViews),
sidebarShowTabs: Schema.optionalKey(Schema.Boolean),
sidebarTabLimit: Schema.optionalKey(Schema.NullOr(SidebarTabLimit)),
sidebarTabSortOrder: Schema.optionalKey(SidebarTabSortOrder),