diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f9..df35a07fe80e2 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,6 +63,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + sidebarThreadRowLines: "two", threadDetailsUsageEnabled: false, threadDetailsItems: {}, sidebarWorkingShelfEnabled: false, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..a7b4765f739f9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -33,6 +33,7 @@ import { resolveSidebarDropVerb, groupSidebarThreadsByProject, resolveSidebarFolderDot, + resolveSidebarRowLines, sidebarFolderExpansionKeys, } from "./Sidebar.logic"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -1641,6 +1642,33 @@ describe("resolveSidebarFolderDot", () => { }); }); +describe("resolveSidebarRowLines", () => { + it.each([ + // Automatic keeps cards outside folders and the retired switch inside them. + { setting: "automatic", section: "active", nested: false, multiline: true, lines: 3 }, + { setting: "automatic", section: "pinned", nested: false, multiline: false, lines: 3 }, + { setting: "automatic", section: "active", nested: true, multiline: false, lines: 1 }, + { setting: "automatic", section: "active", nested: true, multiline: true, lines: 2 }, + // An explicit pick overrides the retired switch, in and out of folders. + { setting: "one", section: "working", nested: false, multiline: true, lines: 1 }, + { setting: "two", section: "pinned", nested: false, multiline: false, lines: 2 }, + { setting: "one", section: "active", nested: true, multiline: true, lines: 1 }, + { setting: "three", section: "active", nested: true, multiline: false, lines: 3 }, + // Snoozed and Settled stay on one line whatever the setting. + { setting: "three", section: "snoozed", nested: false, multiline: false, lines: 1 }, + { setting: "automatic", section: "settled", nested: false, multiline: true, lines: 1 }, + ] as const)("$setting $section nested=$nested multiline=$multiline → $lines", (row) => { + expect( + resolveSidebarRowLines({ + setting: row.setting, + section: row.section, + nested: row.nested, + folderRowsMultiline: row.multiline, + }), + ).toBe(row.lines); + }); +}); + describe("shouldCreateNewThreadInCurrentProject", () => { it("opens the picker when several projects are open, or only archived ones exist", () => { expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5ed1320b59e55..e8c4222903312 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -24,7 +24,11 @@ import { type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { + SidebarProjectSortOrder, + SidebarThreadRowLines, + SidebarThreadSortOrder, +} from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { effectiveSnoozed, @@ -194,6 +198,24 @@ export function resolveSidebarThreadSection(input: { return "active"; } +export type SidebarRowLines = 1 | 2 | 3; + +const SIDEBAR_ROW_LINES = { one: 1, two: 2, three: 3 } as const; + +// Snoozed and Settled rows stay on one line. Automatic keeps the looks rows had +// before the setting: cards outside folders, the retired switch inside them. +export function resolveSidebarRowLines(input: { + readonly setting: SidebarThreadRowLines; + readonly section: SidebarSection; + readonly nested: boolean; + readonly folderRowsMultiline: boolean; +}): SidebarRowLines { + if (input.section === "snoozed" || input.section === "settled") return 1; + if (input.setting !== "automatic") return SIDEBAR_ROW_LINES[input.setting]; + if (!input.nested) return 3; + return input.folderRowsMultiline ? 2 : 1; +} + const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8fa58d8456f75..0d9f3fa3a3676 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -191,6 +191,7 @@ import { resolveSidebarSweepKeys, resolveSidebarDropTarget, resolveSidebarDropVerb, + resolveSidebarRowLines, resolveSidebarThreadSection, resolveSidebarReorderableThreadKeys, searchSidebarThreads, @@ -992,6 +993,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); const boringSidebarEnabled = useClientSettings((settings) => settings.boringSidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); + const rowLinesSetting = useClientSettings((settings) => settings.sidebarThreadRowLines); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -3323,15 +3325,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - const isCard = - section === "active" || section === "pinned" || section === "working"; - const rowVariant = folderRow - ? folderRowsMultiline - ? "folder-multiline" - : "folder" - : isCard - ? "card" - : "slim"; + const nested = folderRow !== undefined; + const rowLines = resolveSidebarRowLines({ + setting: rowLinesSetting, + section, + nested, + folderRowsMultiline, + }); return ( + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -44,32 +48,47 @@ function BoringSidebarRow() { aria-label="Boring sidebar (group by project)" /> } - > - {settings.boringSidebarEnabled ? ( -
- -
- ) : null} - + /> ); } -function BoringSidebarMultilineRow() { +const THREAD_ROW_LINES_LABELS: Record = { + automatic: "Automatic", + one: "1 line", + two: "2 lines", + three: "3 lines", +}; + +const isSidebarThreadRowLines = Schema.is(SidebarThreadRowLines); + +function ThreadRowLinesRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); return ( - updateSettings({ boringSidebarMultilineRows: Boolean(checked) }) - } - aria-label="Two-line folder rows" - /> +
+ +
} /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4f61ba41c0d86..eb25e16738ba6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -468,12 +468,12 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["beta folders project tree classic sidebar"], }, { - id: "boring-sidebar-multiline", - title: "Two-line folder rows", + id: "sidebar-thread-row-lines", + title: "Thread rows", to: "/settings/labs", - // Shown inside the boring sidebar row only while it is on, so search lands on the parent. - targetId: "boring-sidebar", - searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], + searchTerms: [ + "beta sidebar thread rows lines compact dense card one two three multiline branch pull request", + ], }, { id: "tool-call-visibility", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 05e8984200470..d16e81d6465f6 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -85,6 +85,7 @@ import { hasUnseenCompletion, resolveSidebarRowAccessibility, type SidebarDropVerb, + type SidebarRowLines, resolveSidebarThreadStatus, resolveThreadLastVisitedAt, shouldRecedeSidebarThread, @@ -488,8 +489,15 @@ export function SortableThreadRow( : rowProps.thread.runtime !== null ? asLaunchingThread(liveThread) : liveThread; - // Keyed by variant so a card/slim switch remounts the row. - return ; + // Keyed by shape so a card/slim switch remounts the row. + return ( + + ); } export type SidebarSweepAction = "settle" | "unsettle" | "unsnooze"; const dropVerbBadge: Record = { @@ -543,9 +551,10 @@ function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: type SidebarThreadRowProps = { thread: SidebarThreadSummary; - // "folder" is a one-line row inside a boring sidebar project folder; - // "folder-multiline" adds the card's branch and PR line under it. - variant: "card" | "slim" | "folder" | "folder-multiline"; + // 1 is the title, 2 adds the branch and PR line, 3 is the card with the project on top. + lines: SidebarRowLines; + // Inside a boring sidebar project folder, which already shows the project. + nested: boolean; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -659,9 +668,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP openPullRequestsInRightPanel, renamingTitle, thread, - variant, + lines, + nested, variantAction, } = props; + // Snoozed and Settled rows recede and show a time; live rows show their status. + const live = variantAction === "settle"; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); @@ -1036,7 +1048,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "min-w-0 flex-1 text-start text-sm transition-opacity motion-reduce:transition-none", shouldRecede ? "font-normal" : "font-medium", - variant !== "slim" + live ? cn( "truncate", shouldRecede @@ -1208,9 +1220,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; - if (variant !== "card") { - const multiline = variant === "folder-multiline"; - const inFolder = variant === "folder" || multiline; + if (lines !== 3) { + const multiline = lines === 2; return (
  • {accessibleTitle} - {inFolder ? null : ( + {nested ? null : ( @@ -1298,7 +1310,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {props.snoozeWakeLabelText} - ) : (inFolder ? isWokeStatus : isWoke) ? ( + ) : (live ? isWokeStatus : isWoke) ? ( Dismiss Woke notification - ) : inFolder && topStatus ? ( - // Folder rows have no status line, so the status takes the time slot. + ) : live && topStatus ? ( + // These rows have no status line, so the status takes the time slot. - {multiline ? metaLine : null} + {multiline && metaLine !== null ? ( + // Lines the details up with the title, past the project icon. +
    {metaLine}
    + ) : null} {detailsTooltip} @@ -1444,17 +1459,18 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)", metaLineEmpty && "h-auto", + nested && "ps-9", )} >
    {draftIndicator} - {props.project ? ( + {props.project && !nested ? ( ) : null} {/* Left of the status slot, the pin would slide when hover swaps the label for the wider row controls. */} {pinIndicator} - {props.projectDisplayName ? ( + {props.projectDisplayName && !nested ? ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..bbe1efff39829 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -159,6 +159,14 @@ Pinned threads stay above the folders, and Snoozed and Settled stay below them. can drag a thread onto the composer to mention it, but not within the sidebar, and the Working section is off. Each device keeps its own choice. +### Thread rows (beta) + +On web and desktop, **Settings → Labs → Thread rows** sets how many lines each sidebar thread +takes: **1 line** shows the title and status, **2 lines** adds the branch, pull request, and agent, +and **3 lines** adds a line for the project and status above the title. Inside a project folder, +that line shows only the status. **Automatic** uses three lines, and one inside project folders. +Snoozed and Settled threads always take one line. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 346213992e4b2..4324a6be0e83d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -350,6 +350,9 @@ export type ToolCallVisibility = typeof ToolCallVisibility.Type; export const ScreenReaderAnnouncements = Schema.Literals(["status", "messages", "everything"]); export type ScreenReaderAnnouncements = typeof ScreenReaderAnnouncements.Type; +export const SidebarThreadRowLines = Schema.Literals(["automatic", "one", "two", "three"]); +export type SidebarThreadRowLines = typeof SidebarThreadRowLines.Type; + export const ModelFavorite = Schema.Struct({ provider: ProviderInstanceId, model: TrimmedNonEmptyString, @@ -515,9 +518,13 @@ export const ClientSettingsSchema = Schema.Struct({ proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Retired Labs toggle. Automatic's folder rows read it until a Thread rows pick clears it. boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + sidebarThreadRowLines: SidebarThreadRowLines.pipe( + Schema.withDecodingDefault(Effect.succeed("automatic" as const)), + ), toolCallVisibility: ToolCallVisibility.pipe( Schema.withDecodingDefault(Effect.succeed("showAll" as const)), ), @@ -2035,6 +2042,7 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + sidebarThreadRowLines: Schema.optionalKey(SidebarThreadRowLines), toolCallVisibility: Schema.optionalKey(ToolCallVisibility), screenReaderAnnouncements: Schema.optionalKey(ScreenReaderAnnouncements), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean),