Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 66 additions & 22 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2243,11 +2276,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : null}
</span>
)
) : unifyTabs ? null : (
) : unifyTabs ? null : variantAction === "unsnooze" &&
props.snoozeWakeLabelText !== null ? (
props.snoozeWakeLabelText
) : variantAction === "unsettle" ? (
settledTimeLabel(thread)
) : (
threadTimeLabel(thread)
)}
</span>
{props.settlementSupported ||
{rowAction.supported ||
showSnoozeButton ||
hasUnsentDraft ||
onNewTab ||
Expand Down Expand Up @@ -2305,14 +2343,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
timestampFormat={props.timestampFormat}
/>
) : null}
{props.settlementSupported ? (
{rowAction.supported ? (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Settle thread"
onClick={handleSettleClick}
aria-label={rowAction.label}
onClick={rowAction.onClick}
onPointerDown={handleActionPointerDown}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground",
Expand All @@ -2322,10 +2360,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
}
>
<CheckIcon className="size-3.5" />
Settle
<RowActionIcon className="size-3.5" />
{rowAction.text}
</TooltipTrigger>
<TooltipPopup>Settle thread</TooltipPopup>
<TooltipPopup>{rowAction.label}</TooltipPopup>
</Tooltip>
) : null}
{showUnpinnedPinAction ? (
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -4271,6 +4318,12 @@ export default function Sidebar() {
return layouts;
}, [
activeThreads,
workingThreads,
visibleGroups,
visibleHiddenThreads,
visibleSnoozedThreads,
renderedSettledThreads,
threadViews,
expandedTabRowKey,
heldTabOrder,
listedTabsByRowKey,
Expand Down Expand Up @@ -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);
Expand All @@ -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"
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
MIN_TERMINAL_LINE_HEIGHT,
type QuitConfirmationMode,
SidebarProjectSortOrder,
SidebarThreadView,
} from "@t3tools/contracts/settings";
import {
DEFAULT_SIDEBAR_TAB_LIMIT,
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -769,6 +773,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.autoResumeLimitedThreads,
settings.snoozeLimitedThreads,
settings.sidebarProjectGroupingMode,
settings.sidebarThreadViews,
settings.sidebarShowTabs,
settings.sidebarTabLimit,
settings.sidebarTabSortOrder,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2547,6 +2553,66 @@ export function GeneralSettingsPanel() {

{settings.legacySidebarEnabled ? null : (
<>
<SettingsRow
{...searchableSetting("sidebar-thread-views")}
description="Choose detailed cards or compact rows independently for each thread category."
resetAction={
!Equal.equals(
settings.sidebarThreadViews,
DEFAULT_UNIFIED_SETTINGS.sidebarThreadViews,
) ? (
<SettingResetButton
label="thread views"
onClick={() =>
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]) => (
<SettingsRow
key={category}
title={`${label} threads`}
control={
<Select
value={settings.sidebarThreadViews[category]}
onValueChange={(value) => {
if (value === "card" || value === "slim") {
updateSettings({
sidebarThreadViews: { ...settings.sidebarThreadViews, [category]: value },
});
}
}}
>
<SelectTrigger aria-label={`${label} thread view`} size="sm">
<SelectValue>
{settings.sidebarThreadViews[category] === "card" ? "Detailed" : "Compact"}
</SelectValue>
</SelectTrigger>
<SelectPopup>
{SidebarThreadView.literals.map((view) => (
<SelectItem key={view} value={view}>
{view === "card" ? "Detailed" : "Compact"}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
))}
<SettingsRow
{...searchableSetting("sidebar-tabs")}
description={`List a chat's tabs under it in the sidebar. Past the limit, the rest fold behind a "more" row.`}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,14 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["combine matching repositories environments sidebar"],
},
{
id: "sidebar-thread-views",
title: "Thread views",
to: "/settings/general",
searchTerms: [
"sidebar detailed compact cards rows active pinned working grouped hidden snoozed settled",
],
},
{
id: "sidebar-tabs",
title: "Tabs in sidebar",
Expand Down
13 changes: 13 additions & 0 deletions docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,19 @@ app's navigation history, the same as the `navigation.back` and `navigation.forw

Code: `SidebarHistoryNavigation` in `apps/web/src/components/sidebar/SidebarChrome.tsx`.

## Sidebar thread views

On web and desktop, **Settings → General → Thread views** chooses detailed cards or compact
rows independently for active, pinned, working, grouped, hidden, snoozed, and settled threads.
Hidden threads default to detailed cards; snoozed and settled threads default to compact rows.
These client preferences also apply when several categories share the sidebar and when chat
tabs are expanded.

Code: [`Sidebar.tsx`](../apps/web/src/components/Sidebar.tsx),
[`SettingsPanels.tsx`](../apps/web/src/components/settings/SettingsPanels.tsx), and
[`settings.ts`](../packages/contracts/src/settings.ts). User guide:
[thread-sidebar.md](./user/thread-sidebar.md#hide-a-thread).

## Sidebar filter menu

On web and desktop, one filter button in the sidebar header replaces upstream's project picker.
Expand Down
4 changes: 4 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,10 @@ appears in the inbox. Pick **Hidden** under **Show** in the filter menu, describ
thread** from a thread's menu, or the eye on its row on web and desktop, to put it back. Hidden threads are saved on the server, so they stay
hidden on your other devices.

On web and desktop, **Settings → General → Thread views** lets you choose detailed cards or
compact rows separately for active, pinned, working, grouped, hidden, snoozed, and settled
threads. Hidden threads start with detailed cards so their project and branch remain visible.

## Group threads

Make your own groups, such as "Research" or "Later", to file threads away from your live list.
Expand Down
30 changes: 30 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,3 +1177,33 @@ describe("ServerSettings.removeAgentCreditsOnMerge", () => {
).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();
});
});
19 changes: 19 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down