From 792ef17ee330bc53704260158b9759d6a0b0e730 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 15:05:24 +0100 Subject: [PATCH 01/42] feat(web): choose a sidebar layout in Settings Add a sidebarVariant client setting with three values, in Settings, General, Sidebar. Default is the sidebar users have today and changes nothing. Detailed and Slim both keep threads in place when their state changes. Detailed keeps every thread in its project group, nests workers under their parent with a state bar, lists threads asking for something under Waiting on you, and answers on the row. Slim shows one line per thread in project folders, folds workers to a count, and opens answers from the status word so the rows below never move. Both layouts lock the Boring sidebar, two-line rows and Working section settings without rewriting them, so going back to Default restores them. --- apps/web/src/components/Sidebar.tsx | 114 +++++-- .../components/settings/LabsSettingsPanel.tsx | 21 +- .../components/settings/SettingsPanels.tsx | 17 +- .../settings/SidebarVariantSetting.tsx | 142 +++++++++ .../src/components/settings/settingsSearch.ts | 6 + .../components/sidebar/SidebarThreadRow.tsx | 124 ++++++-- .../variants/DetailedSidebarGroups.tsx | 281 ++++++++++++++++++ .../variants/RowAnswers.browser.test.tsx | 131 ++++++++ .../sidebar/variants/RowAnswers.tsx | 233 +++++++++++++++ .../sidebar/variants/sidebarLayout.ts | 24 ++ .../variants/sidebarVariant.logic.test.ts | 231 ++++++++++++++ .../sidebar/variants/sidebarVariant.logic.ts | 186 ++++++++++++ .../sidebar/variants/sidebarVariants.css | 254 ++++++++++++++++ .../sidebar/variants/useDetailedFolders.ts | 94 ++++++ .../sidebar/variants/variantRow.tsx | 176 +++++++++++ apps/web/src/hooks/useSettings.ts | 5 +- apps/web/src/index.css | 1 + docs/user/thread-sidebar.md | 17 ++ packages/contracts/src/settings.test.ts | 19 ++ packages/contracts/src/settings.ts | 4 + 20 files changed, 2031 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/settings/SidebarVariantSetting.tsx create mode 100644 apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx create mode 100644 apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx create mode 100644 apps/web/src/components/sidebar/variants/RowAnswers.tsx create mode 100644 apps/web/src/components/sidebar/variants/sidebarLayout.ts create mode 100644 apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts create mode 100644 apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts create mode 100644 apps/web/src/components/sidebar/variants/sidebarVariants.css create mode 100644 apps/web/src/components/sidebar/variants/useDetailedFolders.ts create mode 100644 apps/web/src/components/sidebar/variants/variantRow.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a82da0bf5f4de..8397b6c4f2400 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -132,6 +132,7 @@ import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import type { SidebarVariant } from "@t3tools/contracts/settings"; import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; @@ -261,6 +262,15 @@ import { type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { DetailedSidebarGroups } from "./sidebar/variants/DetailedSidebarGroups"; +import { + threadsWaitingOnYou, + variantRowStyle, + workerBucket, + workersByParent, + workersOf, +} from "./sidebar/variants/sidebarVariant.logic"; +import { useDetailedFolders } from "./sidebar/variants/useDetailedFolders"; import { SidebarProjectFolderHeader, sidebarFolderRowId, @@ -422,6 +432,8 @@ function useSidebarThreads(input: { readonly folderGroups: readonly SidebarProjectSnapshot[] | null; // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; + readonly sidebarVariant: SidebarVariant; + readonly routeThreadKey: string | null; }) { const { projectScopeKey, @@ -434,6 +446,8 @@ function useSidebarThreads(input: { routeRowThreadKey, folderGroups, keepEmptyFolders: keepEmpty, + sidebarVariant, + routeThreadKey, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -490,7 +504,7 @@ function useSidebarThreads(input: { // Older settled threads load in pages; the shelf lists them only as far as they are loaded. const olderSettledShelf = useOlderSettledShelf({ scopes: olderSettledScopes([...serverConfigs.keys()], scopedProjectKeys), - expanded: settledShelfExpanded, + expanded: settledShelfExpanded || sidebarVariant === "detailed", visibleCount: settledVisibleCount, }); const listedSettledThreads = settledThreads.filter(olderSettledShelf.isListed); @@ -539,7 +553,22 @@ function useSidebarThreads(input: { : EMPTY_THREADS, }; }); - const orderedThreads = [ + const detailedFolders = useDetailedFolders( + sidebarVariant === "detailed" && folderGroups !== null + ? { + groups: folderGroups, + threads, + pinned: pinnedThreads, + active: activeThreads, + snoozed: snoozedThreads, + settled: visibleSettledThreads, + projectExpandedById, + routeRowKey: routeRowThreadKey, + routeThreadKey, + } + : null, + ); + const orderedThreads = detailedFolders?.orderedThreads ?? [ ...pinnedThreads, ...(projectFolders === null ? activeThreads @@ -585,6 +614,7 @@ function useSidebarThreads(input: { workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), projectFolders, + detailedFolders, toggleProjectFolder: (group: SidebarProjectSnapshot) => { const keys = sidebarFolderExpansionKeys(group); setProjectExpanded(keys, !resolveProjectExpanded(projectExpandedById, keys)); @@ -988,6 +1018,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const boringSidebarEnabled = useBoringSidebarEnabled(); const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); const folderRowsMultiline = useClientSettings((settings) => settings.boringSidebarMultilineRows); + const sidebarVariant = useClientSettings((settings) => settings.sidebarVariant); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -1309,6 +1340,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve workingShelfExpanded, toggleWorkingShelf, projectFolders, + detailedFolders, toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, @@ -1339,6 +1371,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : null, keepEmptyFolders: (group) => scopedProjectGroups.length > 0 || (group.pinned && !group.archived), + sidebarVariant, + routeThreadKey, }); // Ranked across environments as the palette ranks them, so one @@ -3300,24 +3334,35 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} > {(() => { + const workerMap = sidebarVariant === "slim" ? workersByParent(threads) : null; + const activeRowKey = + detailedFolders !== null && + routeThreadKey !== null && + threadByKey.has(routeThreadKey) + ? routeThreadKey + : routeRowThreadKey; const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, // The tree row of the project folder this row sits in. folderRow?: string, + nested = false, ) => { 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 rowVariant = + variantRowStyle(sidebarVariant, section, folderRow !== undefined) ?? + (folderRow + ? folderRowsMultiline + ? "folder-multiline" + : "folder" + : isCard + ? "card" + : "slim"); + const workers = workerMap ? workersOf(workerMap, thread) : []; return ( workerBucket(worker) === "need", + )} navigationParent={ folderRow ?? (section === "working" || @@ -3346,8 +3396,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? "unsettle" : "settle" } - settlementSupported={serverConfigs.has(thread.environmentId)} - snoozeSupported={serverConfigs.has(thread.environmentId)} + settlementSupported={!nested && serverConfigs.has(thread.environmentId)} + snoozeSupported={!nested && serverConfigs.has(thread.environmentId)} pinningSupported={serverConfigs.has(thread.environmentId)} isPinned={thread.pinnedAt != null} dropVerb={ @@ -3369,10 +3419,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : null } wokeAt={threadWokeAt(thread, { now: snoozeNow })} - isActive={routeRowThreadKey === threadKey} - revealRef={ - routeRowThreadKey === threadKey ? revealActiveRow : undefined - } + isActive={activeRowKey === threadKey} + revealRef={activeRowKey === threadKey ? revealActiveRow : undefined} isRouteThread={routeThreadKey === threadKey} openPullRequestsInRightPanel={routeThreadRef !== null} jumpLabel={ @@ -3432,6 +3480,37 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onDraftContextMenu={handleDraftContextMenu} />, ]; + if (detailedFolders !== null) { + items.push( + + projectDisplayNameByKey.get( + `${thread.environmentId}:${thread.projectId}`, + ) ?? null + } + onNavigate={navigateToThread} + onToggleWorkers={detailedFolders.toggleWorkers} + onToggleParked={detailedFolders.toggleParked} + renderHeader={(folder) => ( + + )} + renderRow={renderThreadRow} + />, + ); + return items; + } for (const item of sidebarListItems) { if (item.kind === "thread") { // Folders draw active rows in their own place below. @@ -3576,7 +3655,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return items; })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( + {(settledShelfExpanded || detailedFolders !== null) && + hiddenSettledCount > 0 ? (
  • ) : null} diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 36c289b8335bc..0e09673acb610 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -10,6 +10,7 @@ import { ScopedSwitch } from "./ScopedSwitch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { LegacyFeaturesSection } from "./SettingsPanels"; +import { resolveSidebarLayout } from "../sidebar/variants/sidebarLayout"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { useOptionalSettingsScope } from "./SettingsScopeContext"; import { firstManagedDenial, usePrimaryManagedPolicy } from "~/state/managedPolicy"; @@ -34,13 +35,19 @@ export function LabsSettingsPanel() { function BoringSidebarRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const layout = resolveSidebarLayout(settings); return ( updateSettings({ boringSidebarEnabled: Boolean(checked), legacySidebarEnabled: false }) @@ -55,13 +62,19 @@ function BoringSidebarRow() { function BoringSidebarMultilineRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const layout = resolveSidebarLayout(settings); return ( updateSettings({ boringSidebarMultilineRows: Boolean(checked) }) } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d408d6c8d5d94..ea600789cca78 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,8 @@ import { useScopedSettingsMixed, useUpdateScopedSettings, } from "./useScopedSettings"; +import { resolveSidebarLayout } from "../sidebar/variants/sidebarLayout"; +import { SidebarVariantRow } from "./SidebarVariantSetting"; import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { scopedIdleCompaction } from "./scopedSettings"; @@ -480,6 +482,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? ["Project order"] : []), + ...(settings.sidebarVariant !== DEFAULT_UNIFIED_SETTINGS.sidebarVariant + ? ["Sidebar layout"] + : []), ...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? ["Working section"] : []), @@ -651,6 +656,7 @@ export function useSettingsRestore(onRestored?: () => void) { panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, + sidebarVariant: DEFAULT_UNIFIED_SETTINGS.sidebarVariant, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests, @@ -785,6 +791,7 @@ export function LegacyFeaturesSection() { export function GeneralSettingsPanel() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); + const sidebarLayout = resolveSidebarLayout(settings); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); @@ -807,6 +814,7 @@ export function GeneralSettingsPanel() { + updateSettings({ sidebarWorkingShelfEnabled: Boolean(checked) }) } diff --git a/apps/web/src/components/settings/SidebarVariantSetting.tsx b/apps/web/src/components/settings/SidebarVariantSetting.tsx new file mode 100644 index 0000000000000..160c3647bab3e --- /dev/null +++ b/apps/web/src/components/settings/SidebarVariantSetting.tsx @@ -0,0 +1,142 @@ +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { DEFAULT_UNIFIED_SETTINGS, SidebarVariant } from "@t3tools/contracts/settings"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { RadioGroup } from "../ui/radio-group"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; + +const OPTIONS: Record = { + default: { + label: "Default", + description: "T3 Code's sidebar. The settings below shape it.", + }, + detailed: { + label: "Detailed", + description: "Threads stay in their project. Workers nest under them. Answer on the row.", + }, + slim: { + label: "Slim", + description: "One line per thread in project folders. Workers fold to a count.", + }, +}; + +function PreviewLine(props: { className?: string }) { + return ; +} + +function PreviewRows({ variant }: { variant: SidebarVariant }) { + switch (variant) { + case "default": + return ( + <> + {["w-4/5", "w-3/5"].map((width) => ( + + + + + + ))} + + + ); + case "detailed": + return ( + <> + + {["w-4/5", "w-3/5"].map((width, index) => ( + + + + + + ))} + + + + + + + ); + case "slim": + return ( + <> + + {["w-4/5", "w-2/3", "w-3/4", "w-3/5"].map((width, index) => ( + + + + + ))} + + ); + } +} + +/** The sidebar variant picker; it applies at once, and the stored Boring settings stay for Default. */ +export function SidebarVariantRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ sidebarVariant: DEFAULT_UNIFIED_SETTINGS.sidebarVariant }) + } + /> + ) : null + } + > + updateSettings({ sidebarVariant: value as SidebarVariant })} + className="mt-3 mb-2 grid grid-cols-1 @min-[32rem]/settings-row:grid-cols-3" + > + {SidebarVariant.literals.map((variant) => ( + + + + + + + {OPTIONS[variant].label} + + + + + {OPTIONS[variant].description} + + + ))} + + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 23d32bb35e9c2..89150bb19bcb4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -447,6 +447,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/threads", searchTerms: ["usage quota rate limit reset recover continue"], }, + { + id: "sidebar-variant", + title: "Sidebar layout", + to: "/settings/general", + searchTerms: ["sidebar variant detailed slim default rows workers answers"], + }, { id: "working-shelf", title: "Working section", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 7c9cc93f2b3b3..78c89df7d5d52 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -114,6 +114,8 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } f import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { MiddleTruncate } from "../ui/middle-truncate"; import { useThreadHasUnsentDraft } from "../../composerDraftStore"; +import type { VariantRowStyle } from "./variants/sidebarVariant.logic"; +import { resolveVariantRow } from "./variants/variantRow"; // The fields thread rows show. Provider status pushes refresh `checkedAt`, // status and auth on every provider; leaving those out keeps each @@ -182,11 +184,13 @@ function terminalProcessLabel(count: number): string { function SidebarProviderStack(props: { thread: SidebarThreadSummary; providerEntryByInstanceId: ReadonlyMap; + hideLone?: boolean; }) { const stack = resolveThreadProviderStack(props.thread); const currentInstanceId = stack[stack.length - 1]!; const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; if (currentEntry === null) return null; + if (props.hideLone && stack.length === 1 && props.providerEntryByInstanceId.size < 2) return null; const showInstanceBadge = shouldShowInstanceBadge( currentEntry, props.providerEntryByInstanceId.values(), @@ -536,7 +540,10 @@ 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"; + variant: "card" | "slim" | "folder" | "folder-multiline" | VariantRowStyle; + nested?: boolean; + workerCount?: number; + workerNeedsYou?: boolean; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -714,13 +721,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && thread.settledOverride !== "settled"; - const shouldRecede = shouldRecedeSidebarThread({ - status, - isUnread, - isWoke, - isActive: props.isActive, - isSelected, - }); + const variantStyled = variant === "folder-detailed" || variant === "folder-slim"; + const [answersOpen, setAnswersOpen] = useState(false); + const shouldRecede = + !(variantStyled && status === "approval") && + shouldRecedeSidebarThread({ + status, + isUnread, + isWoke, + isActive: props.isActive, + isSelected, + }); const topStatus = status === "preparing" || status === "working" ? { @@ -963,6 +974,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", shouldRecede && + !variantStyled && (status === "preparing" || status === "working") && "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", isFileDragOver && "ring-1 ring-inset ring-primary/70", @@ -1144,7 +1156,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP diff === null && runningTerminalIds.length === 0; const metaLine = metaLineEmpty ? null : ( -
    +
    {thread.branch ? ( <> @@ -1180,6 +1195,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
    @@ -1190,13 +1206,53 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; if (variant !== "card") { - const multiline = variant === "folder-multiline"; - const inFolder = variant === "folder" || multiline; + const detailed = variant === "folder-detailed"; + const multiline = variant === "folder-multiline" || detailed; + const inFolder = variant !== "slim"; + const variantRow = + variant === "folder-detailed" || variant === "folder-slim" + ? resolveVariantRow({ + style: variant, + thread, + status, + statusWord: topStatus && !isWokeStatus ? topStatus : null, + statusIcon: + topStatus && !isWokeStatus ? ( + + ) : null, + duration: , + time: ( + + ), + isUnread, + isActive: props.isActive, + nested: props.nested ?? false, + project: props.project, + showProjectMark: props.navigationParent === undefined, + title, + titleDirection: textDirection(thread.title), + workerCount: props.workerCount ?? 0, + workerNeedsYou: props.workerNeedsYou ?? false, + answersOpen, + onAnswersOpenChange: setAnswersOpen, + onOpenThread: () => onThreadActivate(threadRef), + }) + : null; return (
  • - + {/* `contents` keeps one-line rows a single flex row. */} -
    +
    {accessibleTitle} + {variantRow?.decor} {inFolder ? null : ( )} - {draftIndicator} - {title} + {detailed ? null : draftIndicator} + {detailed ? ( + + {draftIndicator} + {title} + {pinIndicator} + + ) : ( + (variantRow?.title ?? title) + )} {multiline ? titleLineIcons : null} - {pinIndicator} + {detailed ? null : pinIndicator} {multiline ? null : terminalStatusIcon} + {variantRow?.beforeSlot} {isRegeneratingTitle ? ( Regenerating title @@ -1266,6 +1339,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP dragDestination ) : ( {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( @@ -1301,6 +1375,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP /> Dismiss Woke notification + ) : variantRow ? ( + variantRow.slot ) : inFolder && topStatus ? ( // Folder rows have no status line, so the status takes the time slot. ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( + ) : !props.settlementSupported || variantRow?.keepsSlot ? null : variantAction === + "unsettle" ? ( {multiline ? metaLine : null} + {variantRow?.answers} {detailsTooltip} diff --git a/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx new file mode 100644 index 0000000000000..10c88c064ddf2 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx @@ -0,0 +1,281 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + CheckIcon, + ChevronDownIcon, + CircleAlertIcon, + CircleDashedIcon, + MessageCircleQuestionIcon, + PowerIcon, + ShieldQuestionIcon, + UserRoundIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { resolveSidebarThreadStatus, type SidebarSection } from "../../Sidebar.logic"; +import { sidebarFolderRowId } from "../SidebarProjectFolderHeader"; +import { workerBucket, type DetailedFolderItem, type WorkerBucket } from "./sidebarVariant.logic"; + +const WORKER_BUCKETS: readonly WorkerBucket[] = ["need", "failed", "working", "done", "idle"]; + +const BUCKET_BAR: Record = { + need: "bg-warning-foreground", + failed: "bg-error-foreground", + working: "bg-info-foreground", + done: "bg-success-foreground", + idle: "bg-secondary-label", +}; + +const BUCKET_TEXT: Record = { + need: "text-warning-foreground", + failed: "text-error-foreground", + working: "text-info-foreground", + done: "text-success-foreground", + idle: "text-secondary-label", +}; + +const BUCKET_LABEL: Record = { + need: "need you", + failed: "failed", + working: "working", + done: "done", + idle: "idle", +}; + +const BUCKET_ICON = { + need: ShieldQuestionIcon, + failed: CircleAlertIcon, + working: CircleDashedIcon, + done: CheckIcon, + idle: PowerIcon, +} as const; + +const SECONDARY_ROW_CLASS_NAME = + "sidebar-variant-tokens w-full cursor-pointer rounded-md text-left text-xs outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; + +function WorkerStrip(props: { + rowId: string; + parentRow: string; + workers: readonly EnvironmentThreadShell[]; + open: boolean; + onToggle: () => void; +}) { + const buckets = props.workers.map(workerBucket); + const counts = WORKER_BUCKETS.flatMap((bucket) => { + const count = buckets.filter((candidate) => candidate === bucket).length; + return count > 0 ? [{ bucket, count }] : []; + }); + const summary = counts.map(({ bucket, count }) => `${count} ${BUCKET_LABEL[bucket]}`).join(", "); + return ( +
  • + +
  • + ); +} + +function ParkedToggle(props: { + rowId: string; + parentRow: string; + label: string; + open: boolean; + onToggle: () => void; +}) { + return ( +
  • + +
  • + ); +} + +function waitingIcon(thread: EnvironmentThreadShell): ReactNode { + const status = resolveSidebarThreadStatus(thread); + if (status === "input") { + return ( + + ); + } + if (status === "request") { + return ; + } + return ; +} + +/** Sticks to the list's bottom edge, so a new ask moves no row above it. */ +function WaitingOnYou(props: { + threads: readonly EnvironmentThreadShell[]; + routeThreadKey: string | null; + projectNameOf: (thread: EnvironmentThreadShell) => string | null; + onNavigate: (ref: ScopedThreadRef) => void; +}) { + if (props.threads.length === 0) return null; + return ( +
  • +

    + Waiting on you + {props.threads.length} +

    +
      + {props.threads.map((thread) => { + const ref = scopeThreadRef(thread.environmentId, thread.id); + const key = scopedThreadKey(ref); + return ( +
    • + +
    • + ); + })} +
    +
  • + ); +} + +/** Project groups that hold their threads whatever their state, plus the Waiting on you list. */ +export function DetailedSidebarGroups< + TFolder extends { + readonly group: { readonly projectKey: string }; + readonly items: readonly DetailedFolderItem[]; + }, +>(props: { + folders: readonly TFolder[]; + waiting: readonly EnvironmentThreadShell[]; + routeThreadKey: string | null; + projectNameOf: (thread: EnvironmentThreadShell) => string | null; + onNavigate: (ref: ScopedThreadRef) => void; + onToggleWorkers: (parentKey: string, open: boolean) => void; + onToggleParked: (projectKey: string) => void; + renderHeader: (folder: TFolder) => ReactNode; + renderRow: ( + thread: EnvironmentThreadShell, + section: SidebarSection, + folderRow: string, + nested: boolean, + ) => ReactNode; +}) { + return ( + <> + {props.folders.flatMap((folder) => { + const projectKey = folder.group.projectKey; + const folderRow = sidebarFolderRowId(projectKey); + return [ + props.renderHeader(folder), + ...folder.items.map((item) => { + switch (item.kind) { + case "thread": + return props.renderRow(item.thread, item.section, folderRow, item.nested); + case "workers": + return ( + props.onToggleWorkers(item.parentKey, item.open)} + /> + ); + case "parked": + return ( + props.onToggleParked(projectKey)} + /> + ); + } + }), + ]; + })} + + + ); +} diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx new file mode 100644 index 0000000000000..7d12df7ab93ce --- /dev/null +++ b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx @@ -0,0 +1,131 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../../test/render"; + +const state = vi.hoisted(() => ({ + respondApproval: vi.fn(), + respondInput: vi.fn(), + pendingAtom: null as unknown, +})); + +vi.mock("../../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === "approval" ? state.respondApproval : state.respondInput, +})); +vi.mock("../../../state/threads", () => ({ + environmentThreadDetails: { pendingRequestsAtom: () => state.pendingAtom }, + threadEnvironment: { respondToApproval: "approval", respondToUserInput: "input" }, +})); + +import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers"; + +const environmentId = EnvironmentId.make("environment-a"); +const threadId = ThreadId.make("thread-a"); +const requestId = RuntimeRequestId.make("request-a"); +const thread = { environmentId, id: threadId } as unknown as EnvironmentThreadShell; + +function setup(pending: Partial) { + state.respondApproval.mockReset(); + state.respondInput.mockReset(); + state.respondApproval.mockResolvedValue(AsyncResult.success(undefined)); + state.respondInput.mockResolvedValue(AsyncResult.success(undefined)); + state.pendingAtom = Atom.make({ + approvals: [], + userInputs: [], + ...pending, + } as PendingThreadRequests); +} + +const approval = { + requestId, + requestKind: "command", + detail: "psql -f rotate_keys.sql", + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", +} as PendingThreadRequests["approvals"][number]; + +const question = (extra: Record = {}) => + ({ + requestId, + createdAt: "2026-11-01T00:00:00.000Z", + responseCapability: "live", + questions: [ + { + id: "rounding", + header: "Rounding", + question: "Round per line or per total?", + options: [{ label: "Per line", description: "Totals can drift" }, { label: "Per total" }], + multiSelect: false, + ...extra, + }, + ], + }) as unknown as PendingThreadRequests["userInputs"][number]; + +it("sends an approval from the row without activating the row", async () => { + setup({ approvals: [approval] }); + const onRowClick = vi.fn(); + await using screen = await render( +
    + +
    , + ); + await expect.element(screen.getByText("psql -f rotate_keys.sql")).toBeVisible(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + expect(state.respondApproval).toHaveBeenCalledWith({ + environmentId, + input: { threadId, requestId, decision: "accept" }, + }); + expect(onRowClick).not.toHaveBeenCalled(); +}); + +it("shows a failed answer on the row and clears it on retry", async () => { + setup({ approvals: [approval] }); + state.respondApproval.mockReset(); + state.respondApproval + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Environment offline")))) + .mockResolvedValueOnce(AsyncResult.success(undefined)); + await using screen = await render(); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Environment offline"); + await screen.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.element(screen.getByRole("alert")).not.toBeInTheDocument(); +}); + +it("answers a one-question request with the option's value", async () => { + setup({ userInputs: [question()] }); + await using screen = await render(); + await screen.getByRole("button", { name: "Per total", exact: true }).click(); + expect(state.respondInput).toHaveBeenCalledWith({ + environmentId, + input: { threadId, requestId, answers: { rounding: "Per total" } }, + }); +}); + +it("draws nothing on the row for a question that needs the full thread", async () => { + setup({ userInputs: [question({ multiSelect: true })] }); + await using screen = await render(); + expect(screen.container.querySelector("[data-row-answers]")?.childElementCount).toBe(0); +}); + +it("offers the thread from the popover when the row cannot answer", async () => { + setup({ userInputs: [question({ multiSelect: true })] }); + const onOpenThread = vi.fn(); + await using screen = await render( + {}} + onOpenThread={onOpenThread} + />, + ); + await screen.getByRole("button", { name: "Open thread" }).click(); + expect(onOpenThread).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx new file mode 100644 index 0000000000000..eb887f0a948a7 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx @@ -0,0 +1,233 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import { useState, type MouseEvent, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { useEnvironmentScope } from "../../../state/session"; +import { environmentThreadDetails, threadEnvironment } from "../../../state/threads"; +import { useAtomCommand } from "../../../state/use-atom-command"; +import { ComposerPendingApprovalActions } from "../../chat/ComposerPendingApprovalActions"; +import { Button } from "../../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../../ui/tooltip"; + +const stopRowActivation = (event: MouseEvent) => event.stopPropagation(); + +function AnswerBody(props: { + detail: string; + mono?: boolean; + note?: string | null; + error: string | null; + children: ReactNode; +}) { + return ( + <> +

    + {props.detail} +

    + {props.note ?

    {props.note}

    : null} +
    {props.children}
    + {props.error !== null ? ( +

    + {props.error} +

    + ) : null} + + ); +} + +/** The thread's first open request with its answer buttons, sent through `runtime-request.respond`. */ +function RowAnswerContent(props: { + thread: EnvironmentThreadShell; + onOpenThread: (() => void) | null; +}) { + const { thread, onOpenThread } = props; + const pending = useAtomValue( + environmentThreadDetails.pendingRequestsAtom(scopeThreadRef(thread.environmentId, thread.id)), + ); + const canOperate = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); + const respondApproval = useAtomCommand(threadEnvironment.respondToApproval, { + reportFailure: false, + }); + const respondInput = useAtomCommand(threadEnvironment.respondToUserInput, { + reportFailure: false, + }); + const [responding, setResponding] = useState(false); + const [error, setError] = useState(null); + + const finish = (result: Awaited>) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "Could not send the answer."); + } + setResponding(false); + return result; + }; + + const approval = pending?.approvals[0] ?? null; + if (approval !== null) { + return ( + + { + setResponding(true); + setError(null); + return finish( + await respondApproval({ + environmentId: thread.environmentId, + input: { threadId: thread.id, requestId, decision }, + }), + ); + }} + /> + + ); + } + + const input = pending?.userInputs[0] ?? null; + const question = input?.questions.length === 1 ? (input.questions[0] ?? null) : null; + if ( + input === null || + question === null || + question.multiSelect || + question.options.length === 0 || + input.responseCapability !== "live" + ) { + return pending !== null && onOpenThread !== null ? ( + + + + ) : null; + } + return ( + + {question.options.map((option) => { + const button = ( + + ); + return option.description ? ( + + + {option.description} + + ) : ( + button + ); + })} + + ); +} + +/** Detailed: the answers sit under the row while it asks. */ +export function InlineRowAnswers(props: { thread: EnvironmentThreadShell }) { + return ( +
    + +
    + ); +} + +/** Slim: the status word opens the answers, so the rows below never move. */ +export function RowAnswersPopover(props: { + thread: EnvironmentThreadShell; + label: string; + className: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onOpenThread: () => void; +}) { + return ( + + + } + > + {props.label} + + + {/* A row click navigates, and portals still bubble React events to the row. */} + +
    + +
    +
    +
    + ); +} diff --git a/apps/web/src/components/sidebar/variants/sidebarLayout.ts b/apps/web/src/components/sidebar/variants/sidebarLayout.ts new file mode 100644 index 0000000000000..f549aed6253ad --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarLayout.ts @@ -0,0 +1,24 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; + +/** What the sidebar settings add up to; Detailed and Slim lock the rest without rewriting them. */ +export function resolveSidebarLayout( + settings: Pick< + ClientSettings, + | "sidebarVariant" + | "boringSidebarEnabled" + | "boringSidebarMultilineRows" + | "legacySidebarEnabled" + | "sidebarWorkingShelfEnabled" + >, +) { + const locked = settings.sidebarVariant !== "default"; + const folders = locked || settings.boringSidebarEnabled || settings.legacySidebarEnabled; + return { + locked, + folders, + workingSection: !folders && settings.sidebarWorkingShelfEnabled, + twoLineRows: locked + ? settings.sidebarVariant === "detailed" + : settings.boringSidebarMultilineRows, + }; +} diff --git a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts new file mode 100644 index 0000000000000..463c6d78c0348 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts @@ -0,0 +1,231 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { DEFAULT_CLIENT_SETTINGS, type SidebarVariant } from "@t3tools/contracts/settings"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSidebarLayout } from "./sidebarLayout"; +import { + arrangeDetailedFolder, + arrangeStableRows, + rowAttention, + threadsWaitingOnYou, + variantRowStyle, + workersByParent, +} from "./sidebarVariant.logic"; + +const shell = ( + id: string, + createdAt: string, + overrides: Record = {}, +): EnvironmentThreadShell => + ({ + id, + environmentId: "environment-a", + projectId: "project-a", + createdAt, + archivedAt: null, + pinnedAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + pendingGuestMessageCount: 0, + runtime: null, + lineage: { parentThreadId: null, relationshipToParent: null }, + ...overrides, + }) as unknown as EnvironmentThreadShell; + +const worker = (id: string, parent: string, overrides: Record = {}) => + shell(id, "2026-01-01T00:00:00.000Z", { + lineage: { parentThreadId: parent, relationshipToParent: "subagent" }, + ...overrides, + }); + +const arrange = (input: Partial[0]>) => + arrangeDetailedFolder({ + collapsed: false, + pinned: [], + active: [], + snoozed: [], + settled: [], + workers: new Map(), + workerFold: new Map(), + parkedOpen: false, + routeRowKey: null, + routeThreadKey: null, + ...input, + }); + +const ids = (result: ReturnType) => + result.items.flatMap((item): string[] => + item.kind === "thread" ? [item.thread.id] : [item.kind], + ); + +describe("resolveSidebarLayout", () => { + const variants: readonly SidebarVariant[] = ["default", "detailed", "slim"]; + const flags = [false, true]; + + it("gives every combination of the sidebar settings one consistent layout", () => { + for (const sidebarVariant of variants) { + for (const boringSidebarEnabled of flags) { + for (const boringSidebarMultilineRows of flags) { + for (const legacySidebarEnabled of flags) { + for (const sidebarWorkingShelfEnabled of flags) { + const layout = resolveSidebarLayout({ + sidebarVariant, + boringSidebarEnabled, + boringSidebarMultilineRows, + legacySidebarEnabled, + sidebarWorkingShelfEnabled, + }); + const boring = boringSidebarEnabled || legacySidebarEnabled; + expect(layout.locked).toBe(sidebarVariant !== "default"); + expect(layout.folders).toBe(sidebarVariant !== "default" || boring); + expect(layout.workingSection).toBe( + sidebarVariant === "default" && !boring && sidebarWorkingShelfEnabled, + ); + expect(layout.twoLineRows).toBe( + sidebarVariant === "default" + ? boringSidebarMultilineRows + : sidebarVariant === "detailed", + ); + expect(layout.folders && layout.workingSection).toBe(false); + } + } + } + } + } + }); + + it("matches what users have today when nothing is set", () => { + expect(resolveSidebarLayout(DEFAULT_CLIENT_SETTINGS)).toEqual({ + locked: false, + folders: false, + workingSection: true, + twoLineRows: false, + }); + }); +}); + +describe("variantRowStyle", () => { + it("leaves every row to T3 under Default", () => { + for (const section of ["pinned", "active", "working", "snoozed", "settled"] as const) { + expect(variantRowStyle("default", section, true)).toBeNull(); + expect(variantRowStyle("default", section, false)).toBeNull(); + } + }); + + it("styles pinned rows in Slim and leaves its shelves to T3", () => { + expect(variantRowStyle("slim", "active", true)).toBe("folder-slim"); + expect(variantRowStyle("slim", "pinned", false)).toBe("folder-slim"); + expect(variantRowStyle("slim", "settled", false)).toBeNull(); + expect(variantRowStyle("detailed", "settled", true)).toBe("folder-detailed"); + }); +}); + +describe("rowAttention", () => { + it("marks asks, failures and unseen finishes only", () => { + expect(rowAttention("approval", false)).toBe("need"); + expect(rowAttention("limited", false)).toBe("need"); + expect(rowAttention("failed", true)).toBe("failed"); + expect(rowAttention("ready", true)).toBe("done"); + expect(rowAttention("ready", false)).toBeUndefined(); + expect(rowAttention("working", true)).toBeUndefined(); + }); +}); + +describe("placement", () => { + it("keeps every row in its slot when only its state changes", () => { + const before = [ + shell("a", "2026-01-01T00:00:00.000Z"), + shell("b", "2026-01-03T00:00:00.000Z", { hasPendingApprovals: true }), + shell("c", "2026-01-02T00:00:00.000Z"), + ]; + const after = before.map((thread) => + shell(thread.id, thread.createdAt, { + hasPendingUserInput: true, + runtime: { status: "idle" }, + }), + ); + expect(arrangeStableRows(after).map((thread) => thread.id)).toEqual( + arrangeStableRows(before).map((thread) => thread.id), + ); + expect(arrangeStableRows(before).map((thread) => thread.id)).toEqual(["b", "c", "a"]); + }); + + it("puts pinned threads first, newest first inside each part", () => { + const threads = [ + shell("old", "2026-01-01T00:00:00.000Z"), + shell("new", "2026-01-05T00:00:00.000Z"), + shell("pin", "2026-01-02T00:00:00.000Z", { pinnedAt: "2026-01-09T00:00:00.000Z" }), + ]; + expect(arrangeStableRows(threads).map((thread) => thread.id)).toEqual(["pin", "new", "old"]); + }); + + it("parks snoozed and settled threads behind one row in their folder", () => { + const settled = [shell("s1", "2026-01-01T00:00:00.000Z")]; + const snoozed = [shell("z1", "2026-01-01T00:00:00.000Z")]; + const closed = arrange({ snoozed, settled }); + expect(ids(closed)).toEqual(["parked"]); + expect(closed.items[0]).toMatchObject({ label: "1 snoozed · 1 settled", open: false }); + expect(ids(arrange({ snoozed, settled, parkedOpen: true }))).toEqual(["parked", "z1", "s1"]); + }); + + it("still shows the open thread in a closed folder or parked group", () => { + const active = [shell("a", "2026-01-01T00:00:00.000Z"), shell("b", "2026-01-02T00:00:00.000Z")]; + const key = "environment-a:a"; + const closed = arrange({ active, collapsed: true, routeRowKey: key }); + expect(ids(closed)).toEqual(["a"]); + expect(closed.hiddenThreads.map((thread) => thread.id)).toEqual(["b"]); + const settled = [shell("s", "2026-01-01T00:00:00.000Z")]; + expect(ids(arrange({ settled, routeRowKey: "environment-a:s" }))).toEqual(["parked", "s"]); + }); +}); + +describe("workers", () => { + const parent = shell("p", "2026-01-01T00:00:00.000Z"); + const quiet = worker("w1", "p"); + const asking = worker("w2", "p", { hasPendingApprovals: true }); + + it("folds workers until one needs the user, and honors a manual choice", () => { + const folded = workersByParent([parent, quiet]); + expect(ids(arrange({ active: [parent], workers: folded }))).toEqual(["p", "workers"]); + const needs = workersByParent([parent, quiet, asking]); + expect(ids(arrange({ active: [parent], workers: needs }))).toEqual([ + "p", + "workers", + "w1", + "w2", + ]); + expect( + ids( + arrange({ + active: [parent], + workers: needs, + workerFold: new Map([["environment-a:p", false]]), + }), + ), + ).toEqual(["p", "workers"]); + }); + + it("shows an open worker under its parent while the strip is folded", () => { + const workers = workersByParent([parent, quiet]); + expect(ids(arrange({ active: [parent], workers, routeThreadKey: "environment-a:w1" }))).toEqual( + ["p", "workers", "w1"], + ); + }); + + it("counts a thread as waiting only when it asks itself", () => { + const raised = shell("r", "2026-01-01T00:00:00.000Z", { pendingSubagentRequest: "approval" }); + const archived = shell("x", "2026-01-01T00:00:00.000Z", { + hasPendingApprovals: true, + archivedAt: "2026-01-02T00:00:00.000Z", + }); + const other = shell("o", "2026-01-01T00:00:00.000Z", { + hasPendingUserInput: true, + projectId: "project-b", + }); + const threads = [raised, archived, asking, other]; + expect(threadsWaitingOnYou(threads, null).map((thread) => thread.id)).toEqual(["w2", "o"]); + expect( + threadsWaitingOnYou(threads, new Set(["environment-a:project-a"])).map((thread) => thread.id), + ).toEqual(["w2"]); + }); +}); diff --git a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts new file mode 100644 index 0000000000000..baa0f1ef3432d --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts @@ -0,0 +1,186 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { SidebarVariant } from "@t3tools/contracts/settings"; + +import { + hasUnseenCompletion, + resolveSidebarThreadStatus, + type SidebarSection, + type SidebarThreadStatus, +} from "../../Sidebar.logic"; + +export type VariantRowStyle = "folder-detailed" | "folder-slim"; +export type RowAttention = "need" | "failed" | "done"; +export type WorkerBucket = "need" | "failed" | "working" | "done" | "idle"; + +/** The row a variant draws, or null for T3's own row in that place. */ +export function variantRowStyle( + variant: SidebarVariant, + section: SidebarSection, + inFolder: boolean, +): VariantRowStyle | null { + if (variant === "detailed" && inFolder) return "folder-detailed"; + if (variant === "slim" && (inFolder || section === "pinned")) return "folder-slim"; + return null; +} + +export function rowAttention( + status: SidebarThreadStatus, + isUnread: boolean, +): RowAttention | undefined { + switch (status) { + case "approval": + case "input": + case "request": + case "limited": + return "need"; + case "failed": + return "failed"; + case "ready": + return isUnread ? "done" : undefined; + default: + return undefined; + } +} + +export function workerBucket(thread: EnvironmentThreadShell): WorkerBucket { + const status = resolveSidebarThreadStatus(thread); + if (status === "approval" || status === "input" || status === "request") return "need"; + if (status === "failed" || status === "limited") return "failed"; + if (status === "working" || status === "preparing" || status === "waiting") return "working"; + return thread.latestRun?.completedAt || hasUnseenCompletion(thread) ? "done" : "idle"; +} + +export type WorkerMap = ReadonlyMap; + +export function workersByParent(threads: readonly EnvironmentThreadShell[]): WorkerMap { + const byParent = new Map(); + for (const thread of threads) { + const parent = thread.lineage.parentThreadId; + if (parent === null || thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.archivedAt !== null) continue; + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, parent)); + byParent.set(key, [...(byParent.get(key) ?? []), thread]); + } + return byParent; +} + +const threadKeyOf = (thread: Pick) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + +export const workersOf = (workers: WorkerMap, thread: EnvironmentThreadShell) => + workers.get(threadKeyOf(thread)) ?? []; + +/** Creation order never changes, so nothing a thread does can move its row. */ +export function arrangeStableRows< + T extends { readonly id: string; readonly createdAt: string; readonly pinnedAt?: string | null }, +>(threads: readonly T[]): T[] { + const newestFirst = (left: T, right: T) => + right.createdAt.localeCompare(left.createdAt) || left.id.localeCompare(right.id); + return [ + ...threads.filter((thread) => thread.pinnedAt != null).toSorted(newestFirst), + ...threads.filter((thread) => thread.pinnedAt == null).toSorted(newestFirst), + ]; +} + +function parkedLabel(snoozed: number, settled: number): string { + return [snoozed > 0 ? `${snoozed} snoozed` : null, settled > 0 ? `${settled} settled` : null] + .filter((part) => part !== null) + .join(" · "); +} + +export type DetailedFolderItem = + | { + readonly kind: "thread"; + readonly thread: EnvironmentThreadShell; + readonly section: SidebarSection; + readonly nested: boolean; + } + | { + readonly kind: "workers"; + readonly parentKey: string; + readonly workers: readonly EnvironmentThreadShell[]; + readonly open: boolean; + } + | { readonly kind: "parked"; readonly label: string; readonly open: boolean }; + +export function arrangeDetailedFolder(input: { + readonly collapsed: boolean; + readonly pinned: readonly EnvironmentThreadShell[]; + readonly active: readonly EnvironmentThreadShell[]; + readonly snoozed: readonly EnvironmentThreadShell[]; + readonly settled: readonly EnvironmentThreadShell[]; + readonly workers: WorkerMap; + readonly workerFold: ReadonlyMap; + readonly parkedOpen: boolean; + /** The row standing for the open thread; a closed group still shows it. */ + readonly routeRowKey: string | null; + /** The open thread itself, which may be a worker. */ + readonly routeThreadKey: string | null; +}): { + readonly items: readonly DetailedFolderItem[]; + readonly hiddenThreads: readonly EnvironmentThreadShell[]; +} { + const { collapsed, routeRowKey } = input; + const isRouteRow = (thread: EnvironmentThreadShell) => threadKeyOf(thread) === routeRowKey; + const main = arrangeStableRows([...input.pinned, ...input.active]); + const items: DetailedFolderItem[] = []; + for (const thread of main) { + if (collapsed && !isRouteRow(thread)) continue; + items.push({ + kind: "thread", + thread, + section: thread.pinnedAt != null ? "pinned" : "active", + nested: false, + }); + const workers = workersOf(input.workers, thread); + if (workers.length === 0) continue; + const parentKey = threadKeyOf(thread); + const open = + !collapsed && + (input.workerFold.get(parentKey) ?? + workers.some((worker) => workerBucket(worker) === "need")); + if (!collapsed) items.push({ kind: "workers", parentKey, workers, open }); + for (const worker of arrangeStableRows(workers)) { + if (open || threadKeyOf(worker) === input.routeThreadKey) { + items.push({ kind: "thread", thread: worker, section: "active", nested: true }); + } + } + } + const parkedCount = input.snoozed.length + input.settled.length; + const parkedOpen = !collapsed && input.parkedOpen; + if (!collapsed && parkedCount > 0) { + items.push({ + kind: "parked", + label: parkedLabel(input.snoozed.length, input.settled.length), + open: parkedOpen, + }); + } + for (const [list, section] of [ + [input.snoozed, "snoozed"], + [input.settled, "settled"], + ] as const) { + for (const thread of list) { + if (parkedOpen || isRouteRow(thread)) { + items.push({ kind: "thread", thread, section, nested: false }); + } + } + } + return { items, hiddenThreads: collapsed ? main.filter((thread) => !isRouteRow(thread)) : [] }; +} + +/** Threads asking the user something themselves; a parent raising a worker's ask is not one. */ +export function threadsWaitingOnYou( + threads: readonly EnvironmentThreadShell[], + scopedProjectKeys: ReadonlySet | null, +): EnvironmentThreadShell[] { + return threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + (thread.hasPendingApprovals || + thread.hasPendingUserInput || + (thread.pendingGuestMessageCount ?? 0) > 0), + ); +} diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css new file mode 100644 index 0000000000000..d1bb812dd5ec0 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -0,0 +1,254 @@ +@keyframes sidebar-row-sweep { + 0% { + transform: translateX(-100%); + animation-timing-function: steps(9); + } + 20% { + transform: translateX(-84.4%); + animation-timing-function: steps(9); + } + 40% { + transform: translateX(-47.2%); + animation-timing-function: steps(9); + } + 60% { + transform: translateX(-2.8%); + animation-timing-function: steps(9); + } + 80% { + transform: translateX(34.4%); + animation-timing-function: steps(9); + } + 100% { + transform: translateX(50%); + } +} + +@keyframes sidebar-status-pulse { + 0%, + 40%, + 100% { + opacity: 1; + animation-timing-function: steps(6); + } + 50%, + 90% { + opacity: 0.8; + animation-timing-function: steps(6); + } +} + +/* Some built-in themes put these tokens under 4.5:1 at 12px; mixing in foreground lifts them. */ +@utility sidebar-variant-tokens { + --contrast-secondary-label: color-mix(in srgb, var(--foreground) 72%, transparent); + --variant-warning: color-mix(in srgb, var(--warning-foreground) 65%, var(--foreground)); + --variant-success: color-mix(in srgb, var(--success-foreground) 65%, var(--foreground)); + --variant-info: color-mix(in srgb, var(--info-foreground) 65%, var(--foreground)); + --variant-error: color-mix(in srgb, var(--error-foreground) 65%, var(--foreground)); + + &[aria-current="page"] { + --variant-warning: color-mix(in srgb, var(--warning-foreground) 50%, var(--foreground)); + --variant-success: color-mix(in srgb, var(--success-foreground) 50%, var(--foreground)); + --variant-info: color-mix(in srgb, var(--info-foreground) 50%, var(--foreground)); + --variant-error: color-mix(in srgb, var(--error-foreground) 50%, var(--foreground)); + } + + & .text-warning-foreground { + color: var(--variant-warning); + } + & .text-success-foreground { + color: var(--variant-success); + } + & .text-info-foreground { + color: var(--variant-info); + } + & .text-error-foreground { + color: var(--variant-error); + } + & .text-muted-foreground { + color: var(--contrast-secondary-label); + } + & .text-guest-foreground { + color: color-mix(in srgb, var(--guest-foreground) 65%, var(--foreground)); + } + & .text-indigo-600, + & .sidebar-input-mark { + color: color-mix(in srgb, var(--color-indigo-600) 65%, var(--foreground)); + } + .dark & .text-indigo-600, + .dark & .sidebar-input-mark { + color: color-mix(in srgb, var(--color-indigo-300) 65%, var(--foreground)); + } + & .bg-warning-foreground { + background-color: var(--variant-warning); + } + & .bg-success-foreground { + background-color: var(--variant-success); + } + & .bg-info-foreground { + background-color: var(--variant-info); + } + & .bg-error-foreground { + background-color: var(--variant-error); + } +} + +/* The sheet paints the root sidebar token, while the docked sidebar rescopes it locally. */ +@utility sidebar-waiting-surface { + background-color: var(--sidebar); + + [data-mobile="true"] & { + background-color: var(--color-sidebar); + } +} + +@utility sidebar-input-mark { + color: var(--color-indigo-600); +} + +@utility sidebar-row-sweep { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + border-radius: inherit; + contain: strict; + + &::before { + content: ""; + position: absolute; + inset-block: 0; + left: 0; + width: 200%; + background: linear-gradient( + to right, + transparent 0%, + color-mix(in srgb, var(--foreground) 2.5%, transparent) 40%, + color-mix(in srgb, var(--foreground) 6%, transparent) 50%, + color-mix(in srgb, var(--foreground) 2.5%, transparent) 60%, + transparent 100% + ); + animation: sidebar-row-sweep 3s infinite; + animation-play-state: var(--visible-animation-state, paused); + will-change: var(--visible-animation-will-change, auto); + } + + @media (prefers-reduced-motion: reduce), (forced-colors: active) { + display: none; + } +} + +@utility sidebar-status-dot { + animation: sidebar-status-pulse 2s infinite; + animation-play-state: var(--visible-animation-state, paused); + will-change: var(--visible-animation-will-change, auto); + + @media (prefers-reduced-motion: reduce), (forced-colors: active) { + animation: none; + will-change: auto; + } +} + +@utility sidebar-status-spin { + animation: spin 2.2s steps(30) infinite; + animation-play-state: var(--visible-animation-state, paused); + will-change: var(--visible-animation-will-change, auto); + + @media (prefers-reduced-motion: reduce), (forced-colors: active) { + animation: none; + will-change: auto; + } +} + +@utility sidebar-row-detailed { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.125rem 0.5rem; + isolation: isolate; + + & [data-title-line] { + grid-area: 1 / 1 / 2 / 2; + } + + & > [data-meta-line] { + grid-area: 2 / 1 / 3 / 3; + margin-top: 0; + } + + & [data-status-slot], + & [role="status"] { + height: 1.25rem; + margin-block: -0.125rem; + } + + /* The status joins the branch line, so the title gets the row's full width. */ + &:has(> [data-meta-line]) [data-title-line] { + grid-column-end: -1; + } + + &:has(> [data-meta-line]) [data-status-slot], + &:has(> [data-meta-line]) [role="status"] { + grid-area: 2 / 3 / 3 / 4; + } + + & > [data-row-answers] { + grid-column: 1 / -1; + } + + &::before, + &::after { + content: ""; + position: absolute; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + &::before { + inset: 0; + z-index: -1; + background: var(--attention-tint, transparent); + } + + &::after { + left: 2px; + inset-block: 0.625rem; + width: 3px; + border-radius: 1.5px; + background: var(--attention-bar, transparent); + } + + &[data-attention]::before, + &[data-attention]::after { + opacity: 1; + } + + &[data-attention="need"] { + --attention-tint: color-mix(in srgb, var(--warning) 8%, transparent); + --attention-bar: var(--variant-warning); + } + + &[data-attention="failed"] { + --attention-tint: color-mix(in srgb, var(--error) 10%, transparent); + --attention-bar: var(--variant-error); + } + + &[data-attention="done"] { + --attention-tint: color-mix(in srgb, var(--success) 10%, transparent); + --attention-bar: var(--variant-success); + } + + &[aria-current="page"]::before { + opacity: 0; + } +} + +/* Keeps a focused or revealed row clear of the sticky Waiting on you list. */ +ul:has(> [data-testid="sidebar-waiting-on-you"]) > li > [data-sidebar-row] { + scroll-margin-bottom: 14rem; +} diff --git a/apps/web/src/components/sidebar/variants/useDetailedFolders.ts b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts new file mode 100644 index 0000000000000..7015bc773339c --- /dev/null +++ b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts @@ -0,0 +1,94 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import * as Schema from "effect/Schema"; +import { useState } from "react"; + +import { useLocalStorage } from "../../../hooks/useLocalStorage"; +import { resolveProjectExpanded } from "../../../uiStateStore"; +import type { SidebarProjectSnapshot } from "../../../sidebarProjectGrouping"; +import { groupSidebarThreadsByProject, sidebarFolderExpansionKeys } from "../../Sidebar.logic"; +import { arrangeDetailedFolder, workersByParent } from "./sidebarVariant.logic"; + +const PARKED_OPEN_KEY = "t3code:sidebar:detailed-parked-open"; +const ParkedOpenKeys = Schema.Array(Schema.String); +const keepEveryGroup = () => true; + +/** Detailed keeps each project's threads in its folder, so it arranges pinned and parked rows too. */ +export function useDetailedFolders( + input: { + readonly groups: readonly SidebarProjectSnapshot[]; + readonly threads: readonly EnvironmentThreadShell[]; + readonly pinned: readonly EnvironmentThreadShell[]; + readonly active: readonly EnvironmentThreadShell[]; + readonly snoozed: readonly EnvironmentThreadShell[]; + readonly settled: readonly EnvironmentThreadShell[]; + readonly projectExpandedById: Readonly>; + readonly routeRowKey: string | null; + readonly routeThreadKey: string | null; + } | null, +) { + const [workerFold, setWorkerFold] = useState>(new Map()); + const [parkedOpenKeys, setParkedOpenKeys] = useLocalStorage( + PARKED_OPEN_KEY, + [] as readonly string[], + ParkedOpenKeys, + ); + if (input === null) return null; + + const workers = workersByParent(input.threads); + const inGroups = (list: readonly EnvironmentThreadShell[]) => + new Map( + groupSidebarThreadsByProject(input.groups, list, keepEveryGroup).map((entry) => [ + entry.group.projectKey, + entry.threads, + ]), + ); + const pinned = inGroups(input.pinned); + const active = inGroups(input.active); + const snoozed = inGroups(input.snoozed); + const settled = inGroups(input.settled); + + const folders = input.groups.flatMap((group) => { + const key = group.projectKey; + const lists = { + pinned: pinned.get(key) ?? [], + active: active.get(key) ?? [], + snoozed: snoozed.get(key) ?? [], + settled: settled.get(key) ?? [], + }; + if (Object.values(lists).every((list) => list.length === 0) && !group.pinned) return []; + const collapsed = !resolveProjectExpanded( + input.projectExpandedById, + sidebarFolderExpansionKeys(group), + ); + return [ + { + group, + collapsed, + ...arrangeDetailedFolder({ + ...lists, + collapsed, + workers, + workerFold, + parkedOpen: parkedOpenKeys.includes(key), + routeRowKey: input.routeRowKey, + routeThreadKey: input.routeThreadKey, + }), + }, + ]; + }); + + return { + folders, + orderedThreads: folders.flatMap((folder) => + folder.items.flatMap((item) => (item.kind === "thread" ? [item.thread] : [])), + ), + toggleWorkers: (parentKey: string, open: boolean) => + setWorkerFold((current) => new Map(current).set(parentKey, !open)), + toggleParked: (projectKey: string) => + setParkedOpenKeys((current) => + current.includes(projectKey) + ? current.filter((key) => key !== projectKey) + : [...current, projectKey], + ), + }; +} diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx new file mode 100644 index 0000000000000..01ea421262214 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -0,0 +1,176 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { BotIcon, CircleDashedIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { textDirection } from "~/textDirection"; +import { observeVisibleAnimation } from "../../../lib/visibleAnimation"; +import { ActivityShimmerOverlay } from "../../chat/TimelineRowParts"; +import { ProjectFavicon } from "../../ProjectFavicon"; +import type { SidebarThreadStatus } from "../../Sidebar.logic"; +import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers"; +import { rowAttention, type VariantRowStyle } from "./sidebarVariant.logic"; + +function PulsingDot() { + return ( + + ); +} + +function SpinningMark() { + return ( + + ); +} + +function workerLabel(count: number, needsYou: boolean): string { + return `${count} ${count === 1 ? "worker" : "workers"}${needsYou ? ", one needs you" : ""}`; +} + +/** The parts a variant adds to T3's folder row; everything else stays T3's row. */ +export function resolveVariantRow(input: { + style: VariantRowStyle; + thread: EnvironmentThreadShell; + status: SidebarThreadStatus; + statusWord: { label: string; className: string } | null; + statusIcon: ReactNode; + duration: ReactNode; + time: ReactNode; + isUnread: boolean; + isActive: boolean; + nested: boolean; + project: Parameters[0]["project"] | null; + showProjectMark: boolean; + title: ReactNode; + titleDirection: ReturnType; + workerCount: number; + workerNeedsYou: boolean; + answersOpen: boolean; + onAnswersOpenChange: (open: boolean) => void; + onOpenThread: () => void; +}) { + const { style, thread, status, statusWord } = input; + const working = status === "working" || status === "preparing"; + const asksYou = thread.hasPendingApprovals || thread.hasPendingUserInput; + const detailed = style === "folder-detailed"; + const answersInPopover = !detailed && asksYou; + + const mark = working ? ( + detailed ? ( + + ) : ( + + ) + ) : ( + (input.statusIcon ?? + (input.showProjectMark && input.project ? ( + + ) : null)) + ); + + const speaksUp = + status === "approval" || + status === "input" || + status === "request" || + status === "failed" || + status === "limited"; + + const slot = + answersInPopover && statusWord ? ( + + ) : (detailed || speaksUp) && statusWord ? ( + + {statusWord.label} + {detailed && working ? ( + + {input.duration} + + ) : null} + + ) : working ? ( + {input.duration} + ) : ( + {input.time} + ); + + return { + rowClassName: cn( + "sidebar-variant-tokens ps-9", + detailed ? "sidebar-row-detailed py-1.5 pe-2.5" : "flex h-9 items-center gap-2.5 pe-2.5", + input.nested && "ps-13", + ), + rootClassName: input.nested + ? "relative before:absolute before:inset-y-0 before:left-[1.0625rem] before:w-px before:bg-border" + : undefined, + attention: detailed ? rowAttention(status, input.isUnread) : undefined, + labelSuffix: + input.workerCount > 0 ? `, ${workerLabel(input.workerCount, input.workerNeedsYou)}` : "", + decor: ( + <> + {detailed && working ? ( + + ) : null} + {mark !== null ? ( + + {mark} + + ) : null} + + ), + title: + !detailed && working && !input.isActive ? ( + + {input.title} + + + {thread.title} + + + + ) : ( + input.title + ), + beforeSlot: + input.workerCount > 0 ? ( + + + {input.workerCount} + + ) : null, + slot, + keepsSlot: answersInPopover, + answers: detailed && asksYou ? : null, + }; +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 43dfe3e045bb7..d56650984ac43 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -61,6 +61,7 @@ import { } from "~/state/session"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { resolveSidebarLayout } from "~/components/sidebar/variants/sidebarLayout"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -417,9 +418,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod /** Legacy sidebar opt-ins still count, so those users land in the boring sidebar. */ export function useBoringSidebarEnabled(): boolean { - return useClientSettings( - (settings) => settings.boringSidebarEnabled || settings.legacySidebarEnabled, - ); + return useClientSettings((settings) => resolveSidebarLayout(settings).folders); } export async function prepareModelPreferencesPatch( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3f3e1c742f4cf..8186adbb49f6a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,7 @@ /* Clerk injects its component styles into the clerk layer (clerkAppearance.cssLayerName). */ @layer theme, base, clerk, components, utilities; @import "tailwindcss"; +@import "./components/sidebar/variants/sidebarVariants.css"; @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 6a3145d17512f..1f48e32dea5b4 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -159,6 +159,23 @@ needs you. Use the pen button on a folder, or right-click it, to start a thread Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section is off. Each device keeps its own choice. +### Choose a sidebar layout (beta) + +**Settings → General → Sidebar layout** on web and desktop picks how the sidebar lists threads. It +applies at once, and each device keeps its own choice. + +- **Default** is the sidebar described above, shaped by the Working section and Boring sidebar + settings. +- **Detailed** keeps each thread in its project whatever its state. Pinned threads stay at the top + of their folder, and Snoozed and Settled sit behind a row at the end of it. Workers nest under + the thread that started them. **Waiting on you** lists every thread that needs an answer, and you + can answer on the row without opening the thread. +- **Slim** shows one line per thread in project folders, with workers folded to a count. Click the + status of a thread that asks for something to answer it without opening the thread. + +Detailed and Slim always group by project and have no Working section, so those settings are locked +while either is on. Your values for them return when you go back to Default. + ## 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.test.ts b/packages/contracts/src/settings.test.ts index 4ff7d2c9618f2..4ec996e150ad0 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -40,6 +40,25 @@ const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("sidebar variant setting", () => { + it("defaults old settings files to the sidebar users have today", () => { + expect(decodeClientSettings({}).sidebarVariant).toBe("default"); + expect(decodeClientSettings({ boringSidebarEnabled: true }).sidebarVariant).toBe("default"); + expect(decodeClientSettingsPatch({})).not.toHaveProperty("sidebarVariant"); + }); + + it("round-trips each variant and refuses an unknown one", () => { + for (const sidebarVariant of ["default", "detailed", "slim"] as const) { + const decoded = decodeClientSettings({ sidebarVariant }); + expect(decodeClientSettings(encodeClientSettings(decoded)).sidebarVariant).toBe( + sidebarVariant, + ); + expect(decodeClientSettingsPatch({ sidebarVariant }).sidebarVariant).toBe(sidebarVariant); + } + expect(() => decodeClientSettings({ sidebarVariant: "wide" })).toThrow(); + }); +}); + describe("storage cleanup settings", () => { it("keeps cleanup disabled for existing installations", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 798e076495a95..6199ded6d9b22 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -63,6 +63,8 @@ const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; +export const SidebarVariant = Schema.Literals(["default", "detailed", "slim"]); +export type SidebarVariant = typeof SidebarVariant.Type; export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; @@ -480,6 +482,7 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + sidebarVariant: SidebarVariant.pipe(Schema.withDecodingDefault(Effect.succeed("default"))), // Retired Labs toggle. Still read as the default for the Usage item in threadDetailsItems. threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Sparse show/hide overrides for thread details card items. Missing ids use the client's @@ -1820,6 +1823,7 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + sidebarVariant: Schema.optionalKey(SidebarVariant), threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), From d336c2eed44702d7bc02f891bcaa8e136ab3ad42 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 18:52:04 +0100 Subject: [PATCH 02/42] fix(web): Allow and Deny on a timeline approval are real buttons The timeline and child-thread approvals drew Allow and Deny as inline text links, so the action a blocked agent waits for was the quietest control on screen. Both now use the composer's buttons. That leaves inline presentation with no callers, so this removes it. --- .../components/chat/ChildThreadApprovals.tsx | 1 - .../chat/ComposerPendingApprovalActions.tsx | 47 ++++++------------- .../chat/TimelinePendingApprovalRow.tsx | 22 ++++----- .../sidebar/variants/RowAnswers.tsx | 1 - 4 files changed, 22 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/chat/ChildThreadApprovals.tsx b/apps/web/src/components/chat/ChildThreadApprovals.tsx index 5a7cb3074b4a6..3c9452ca485dd 100644 --- a/apps/web/src/components/chat/ChildThreadApprovals.tsx +++ b/apps/web/src/components/chat/ChildThreadApprovals.tsx @@ -41,7 +41,6 @@ function ChildApproval({ entry }: { entry: ChildThreadApproval }) { ) : null}
    ; export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ - presentation = "composer", requestId, isResponding, canRespond, @@ -61,30 +59,19 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro const content = ( <> {option.warning ? : null} - - {option.label} - + {option.label} ); - const button = - presentation === "inline" ? ( - - {content} - - ) : ( - - ); + const button = ( + + ); return option.warning ? ( @@ -98,15 +85,9 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro - ) : ( -
    )} -
    +
    Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 12/42] feat(web): a setting for a shimmer on the chat's Working row The row sat still while an agent worked, and only worktree setup and compaction shimmered. Off by default because it adds continuous motion, in Settings > General > Display. It stops while the thread waits on you. --- .../components/chat/TimelineWorkGroupRows.tsx | 4 ++- .../components/settings/SettingsPanels.tsx | 29 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++++ packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 2 ++ 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 6beece883057e..55cbe509b7698 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -4,6 +4,7 @@ import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { type ToolActivityIcon } from "@t3tools/contracts"; import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { useClientSettings } from "~/hooks/useSettings"; import { observeResize } from "~/lib/observeResize"; import { tickTextContent } from "~/lib/tickTextContent"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; @@ -279,7 +280,8 @@ export function WorkingTimelineRow({ row }: { row: Extract settings.chatWorkingShimmer); + const shimmer = isPreparingWorktree || compacting || (workingShimmer && !awaitingUser); const label = isPreparingWorktree ? ( "Setting up worktree…" ) : compacting ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8c78b12e82ae9..49cb5ef990362 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -530,6 +530,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.composerQuietStopWhileWaiting ? ["Quiet stop button"] : []), + ...(settings.chatWorkingShimmer !== DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer + ? ["Working shimmer"] + : []), ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ["Rich text composer"] : []), @@ -652,6 +655,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, + chatWorkingShimmer: DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer, composerQuietStopWhileWaiting: DEFAULT_UNIFIED_SETTINGS.composerQuietStopWhileWaiting, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, @@ -1089,6 +1093,31 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + chatWorkingShimmer: DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer, + }) + } + /> + ) : null + } + control={ + + updateSettings({ chatWorkingShimmer: Boolean(checked) }) + } + aria-label="Working shimmer" + /> + } + /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7a0634797e36a..84004f8da0b55 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -545,6 +545,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["notification toast popup completion input approval failure"], }, + { + id: "chat-working-shimmer", + title: "Working shimmer", + to: "/settings/general", + searchTerms: ["shimmer animation working row motion chat timeline"], + }, { id: "time-format", title: "Time format", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 33012683ce2b8..e10dd65551e1a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -62,6 +62,7 @@ describe("sidebar variant setting", () => { describe("opt-in display settings", () => { it("default off, so existing users see no change", () => { const decoded = decodeClientSettings({}); + expect(decoded.chatWorkingShimmer).toBe(false); expect(decoded.composerQuietStopWhileWaiting).toBe(false); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5283422eda6d0..b2b6f53829628 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -470,6 +470,7 @@ export const ClientSettingsSchema = Schema.Struct({ planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + chatWorkingShimmer: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerQuietStopWhileWaiting: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1820,6 +1821,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), + chatWorkingShimmer: Schema.optionalKey(Schema.Boolean), composerQuietStopWhileWaiting: Schema.optionalKey(Schema.Boolean), sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), From 3fb344d5a2ff0e049bdadb9e1cc49966174eb368 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 13/42] feat(web): a setting to hide the provider logo when only one exists With one provider the logo repeats on every row and tells nothing apart. Off by default, in Settings > General > Sidebar. --- .../components/settings/SettingsPanels.tsx | 30 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++++ .../components/sidebar/SidebarThreadRow.tsx | 6 ++-- packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 2 ++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 49cb5ef990362..d29d0fa791377 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -533,6 +533,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.chatWorkingShimmer !== DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer ? ["Working shimmer"] : []), + ...(settings.sidebarHideLoneProvider !== DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider + ? ["Hide lone provider logo"] + : []), ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ["Rich text composer"] : []), @@ -655,6 +658,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, + sidebarHideLoneProvider: DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider, chatWorkingShimmer: DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer, composerQuietStopWhileWaiting: DEFAULT_UNIFIED_SETTINGS.composerQuietStopWhileWaiting, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, @@ -930,6 +934,32 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + sidebarHideLoneProvider: DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider, + }) + } + /> + ) : null + } + control={ + + updateSettings({ sidebarHideLoneProvider: Boolean(checked) }) + } + aria-label="Hide lone provider logo" + /> + } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 84004f8da0b55..ef84bb72f6def 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -453,6 +453,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["sidebar variant detailed slim default rows workers answers"], }, + { + id: "sidebar-hide-lone-provider", + title: "Hide lone provider logo", + to: "/settings/general", + searchTerms: ["provider logo icon sidebar row single one hide"], + }, { id: "working-shelf", title: "Working section", diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 14dc9e2576dd1..82e203757d70b 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -4,6 +4,7 @@ import { discardComposerDraft } from "../../lib/discardComposerDraft"; import { requestCustomSnooze } from "../CustomSnoozeDialog"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { useDeadlinePassed } from "~/hooks/useDeadline"; +import { useClientSettings } from "~/hooks/useSettings"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -637,6 +638,7 @@ function ThreadRenameInput(props: { } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const hideLoneProvider = useClientSettings((settings) => settings.sidebarHideLoneProvider); const { isRenaming, onCancelRename, @@ -1191,7 +1193,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
    @@ -1202,7 +1204,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e10dd65551e1a..1333748e527c2 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -62,6 +62,7 @@ describe("sidebar variant setting", () => { describe("opt-in display settings", () => { it("default off, so existing users see no change", () => { const decoded = decodeClientSettings({}); + expect(decoded.sidebarHideLoneProvider).toBe(false); expect(decoded.chatWorkingShimmer).toBe(false); expect(decoded.composerQuietStopWhileWaiting).toBe(false); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b2b6f53829628..7acc39a7396e2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -470,6 +470,7 @@ export const ClientSettingsSchema = Schema.Struct({ planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarHideLoneProvider: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), chatWorkingShimmer: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerQuietStopWhileWaiting: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), @@ -1821,6 +1822,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), + sidebarHideLoneProvider: Schema.optionalKey(Schema.Boolean), chatWorkingShimmer: Schema.optionalKey(Schema.Boolean), composerQuietStopWhileWaiting: Schema.optionalKey(Schema.Boolean), sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), From b008ce09b22009ad9f6111344e4e16fda029132d Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 14/42] feat(web): a setting for the waiting count in the window title With notifications off, their default, nothing outside the window says a thread is blocked on you. The title shows (2) T3 Code when two threads wait. Off by default, in Settings > General > Notifications, and the count is not computed while off. --- apps/web/src/appInit.tsx | 54 ++++++++++++++++++- .../components/settings/SettingsPanels.tsx | 30 +++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 2 + 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 9d77c475155cb..3ce30c19de19b 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -20,6 +20,11 @@ import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocati import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoordinator"; import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { resolveSidebarThreadStatus } from "./components/Sidebar.logic"; +import { environmentSummaries } from "./state/presentation"; +import { presentThreadShell } from "@t3tools/client-runtime/state/models"; +import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; +import { Atom } from "effect/reactivity"; import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; @@ -116,19 +121,66 @@ function startClientAppearance(): void { apply(); } +const waitingThreadCountAtom = Atom.make((get) => { + const delegated = get(environmentThreadShells.delegatedUnderParentAtom); + let waiting = 0; + for (const environmentId of get(environmentSummaries.environmentIdsAtom)) { + const subagentRequests = get( + environmentThreadShells.pendingSubagentRequestsAtom(environmentId), + ); + for (const thread of get(environmentThreadShells.environmentThreadsAtom(environmentId))) { + if (isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, delegated)) { + continue; + } + const status = resolveSidebarThreadStatus( + presentThreadShell(environmentId, thread, subagentRequests.get(thread.id) ?? null), + ); + if (status === "approval" || status === "input" || status === "request") waiting += 1; + } + } + return waiting; +}); + function startDocumentTitle(): void { + let name = document.title; + let waiting = 0; + let stopCounting: (() => void) | null = null; + const apply = () => { + document.title = waiting > 0 ? `(${waiting}) ${name}` : name; + }; + const applySetting = () => { + const enabled = getClientSettings().windowTitleWaitingCount; + if (enabled && stopCounting === null) { + stopCounting = appAtomRegistry.subscribe( + waitingThreadCountAtom, + (count) => { + waiting = count; + apply(); + }, + { immediate: true }, + ); + } else if (!enabled && stopCounting !== null) { + stopCounting(); + stopCounting = null; + waiting = 0; + apply(); + } + }; + subscribeClientSettings(applySetting); appAtomRegistry.subscribe( primaryServerConfigAtom, (config) => { - document.title = resolveServerBackedAppDisplayName({ + name = resolveServerBackedAppDisplayName({ baseName: APP_BASE_NAME, fallbackDisplayName: APP_DISPLAY_NAME, fallbackStageLabel: APP_STAGE_LABEL, primaryServerVersion: config?.environment.serverVersion ?? null, }); + apply(); }, { immediate: true }, ); + applySetting(); } /** diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d29d0fa791377..2299c30c9ee04 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -536,6 +536,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarHideLoneProvider !== DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider ? ["Hide lone provider logo"] : []), + ...(settings.windowTitleWaitingCount !== DEFAULT_UNIFIED_SETTINGS.windowTitleWaitingCount + ? ["Waiting count in window title"] + : []), ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? ["Rich text composer"] : []), @@ -658,6 +661,7 @@ export function useSettingsRestore(onRestored?: () => void) { proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, + windowTitleWaitingCount: DEFAULT_UNIFIED_SETTINGS.windowTitleWaitingCount, sidebarHideLoneProvider: DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider, chatWorkingShimmer: DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer, composerQuietStopWhileWaiting: DEFAULT_UNIFIED_SETTINGS.composerQuietStopWhileWaiting, @@ -824,6 +828,32 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + windowTitleWaitingCount: DEFAULT_UNIFIED_SETTINGS.windowTitleWaitingCount, + }) + } + /> + ) : null + } + control={ + + updateSettings({ windowTitleWaitingCount: Boolean(checked) }) + } + aria-label="Waiting count in window title" + /> + } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index ef84bb72f6def..de8137c457440 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -545,6 +545,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["notification sound alert completion input approval desktop"], }, + { + id: "window-title-waiting-count", + title: "Waiting count in window title", + to: "/settings/general", + searchTerms: ["window title tab count waiting approval needs you badge"], + }, { id: "in-app-notifications", title: "In-app notifications", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1333748e527c2..7ed9a90753f2c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -62,6 +62,7 @@ describe("sidebar variant setting", () => { describe("opt-in display settings", () => { it("default off, so existing users see no change", () => { const decoded = decodeClientSettings({}); + expect(decoded.windowTitleWaitingCount).toBe(false); expect(decoded.sidebarHideLoneProvider).toBe(false); expect(decoded.chatWorkingShimmer).toBe(false); expect(decoded.composerQuietStopWhileWaiting).toBe(false); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7acc39a7396e2..2a926ff2d6a89 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -470,6 +470,7 @@ export const ClientSettingsSchema = Schema.Struct({ planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerRichTextEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + windowTitleWaitingCount: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarHideLoneProvider: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), chatWorkingShimmer: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerQuietStopWhileWaiting: Schema.Boolean.pipe( @@ -1822,6 +1823,7 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerRichTextEnabled: Schema.optionalKey(Schema.Boolean), + windowTitleWaitingCount: Schema.optionalKey(Schema.Boolean), sidebarHideLoneProvider: Schema.optionalKey(Schema.Boolean), chatWorkingShimmer: Schema.optionalKey(Schema.Boolean), composerQuietStopWhileWaiting: Schema.optionalKey(Schema.Boolean), From 8404818d3e70f71e5a9b3d3e5d53f409cae2dc67 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 15/42] fix(web): the worker guide stops at a tinted row in the Detailed sidebar The guide ran through a tinted worker row, between its bar and status mark, and stuck out below the tint. Only Detailed and Slim rows reach this code. --- .../src/components/sidebar/variants/variantRow.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index 01ea421262214..0849eb18a8a4c 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -110,16 +110,20 @@ export function resolveVariantRow(input: { {input.time} ); + const attention = detailed ? rowAttention(status, input.isUnread) : undefined; + return { rowClassName: cn( "sidebar-variant-tokens ps-9", detailed ? "sidebar-row-detailed py-1.5 pe-2.5" : "flex h-9 items-center gap-2.5 pe-2.5", input.nested && "ps-13", ), - rootClassName: input.nested - ? "relative before:absolute before:inset-y-0 before:left-[1.0625rem] before:w-px before:bg-border" - : undefined, - attention: detailed ? rowAttention(status, input.isUnread) : undefined, + // A tinted row carries its own bar, so the guide stops at its edge and resumes after it. + rootClassName: + input.nested && attention === undefined + ? "relative before:absolute before:inset-y-0 before:left-[1.0625rem] before:w-px before:bg-border" + : undefined, + attention, labelSuffix: input.workerCount > 0 ? `, ${workerLabel(input.workerCount, input.workerNeedsYou)}` : "", decor: ( From 28984bee340a121e310c8094bd25e9759222ee34 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 16/42] feat(web): a provider badge on each Detailed and Slim row A small full-colour provider icon sits on the lower right corner of the row's status mark or project favicon, so it costs no title width. A row with no mark shows the icon alone in that slot. The mark is notched, so the row's own surface forms the badge's ring in every state. Both layouts follow the Hide lone provider logo setting alone, and the right-hand stack stays hidden in them. Default rows render as before. --- .../components/settings/SettingsPanels.tsx | 2 +- .../components/sidebar/SidebarThreadRow.tsx | 56 +++++++++++++++---- .../sidebar/variants/sidebarVariants.css | 4 ++ .../sidebar/variants/variantRow.tsx | 28 +++++++++- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2299c30c9ee04..767d7bca80cf2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -966,7 +966,7 @@ export function GeneralSettingsPanel() { /> , + hideLone: boolean | undefined, +): boolean { + const stack = resolveThreadProviderStack(thread); + return hideLone === true && stack.length === 1 && providerEntryByInstanceId.size < 2; +} + function SidebarProviderStack(props: { thread: SidebarThreadSummary; providerEntryByInstanceId: ReadonlyMap; @@ -191,7 +200,7 @@ function SidebarProviderStack(props: { const currentInstanceId = stack[stack.length - 1]!; const currentEntry = props.providerEntryByInstanceId.get(currentInstanceId) ?? null; if (currentEntry === null) return null; - if (props.hideLone && stack.length === 1 && props.providerEntryByInstanceId.size < 2) return null; + if (hidesLoneProvider(props.thread, props.providerEntryByInstanceId, props.hideLone)) return null; const showInstanceBadge = shouldShowInstanceBadge( currentEntry, props.providerEntryByInstanceId.values(), @@ -1190,22 +1199,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className="pointer-events-none ml-auto inline-flex shrink-0 items-center gap-1" > {remoteMachineIcon} - + {variantStyled ? null : ( + + )}
    ); const titleLineIcons = metaLineEmpty ? ( {remoteMachineIcon} - + {variantStyled ? null : ( + + )} ) : null; @@ -1213,6 +1226,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const detailed = variant === "folder-detailed"; const multiline = variant === "folder-multiline" || detailed; const inFolder = variant !== "slim"; + const providerStack = resolveThreadProviderStack(thread); + const rowProviderEntry = hidesLoneProvider( + thread, + props.providerEntryByInstanceId, + hideLoneProvider, + ) + ? null + : (props.providerEntryByInstanceId.get(providerStack[providerStack.length - 1]!) ?? null); const variantRow = variant === "folder-detailed" || variant === "folder-slim" ? resolveVariantRow({ @@ -1249,6 +1270,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP answersOpen, onAnswersOpenChange: setAnswersOpen, onOpenThread: () => onThreadActivate(threadRef), + providerIcon: rowProviderEntry + ? (size) => ( + + ) + : null, }) : null; return ( diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index d1bb812dd5ec0..ea3c6157c83e5 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -160,6 +160,10 @@ } } +@utility sidebar-row-mark-notch { + mask-image: radial-gradient(circle 6px at 17px 17px, transparent 95%, #000 100%); +} + @utility sidebar-row-detailed { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index 0849eb18a8a4c..b8b1d7821cc1c 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -56,6 +56,7 @@ export function resolveVariantRow(input: { answersOpen: boolean; onAnswersOpenChange: (open: boolean) => void; onOpenThread: () => void; + providerIcon: ((size: "badge" | "alone") => ReactNode) | null; }) { const { style, thread, status, statusWord } = input; const working = status === "working" || status === "preparing"; @@ -131,15 +132,36 @@ export function resolveVariantRow(input: { {detailed && working ? ( ) : null} - {mark !== null ? ( + {mark !== null || input.providerIcon !== null ? ( - {mark} + + {mark ?? ( + {input.providerIcon?.("alone")} + )} + + {mark !== null && input.providerIcon !== null ? ( + + {input.providerIcon("badge")} + + ) : null} ) : null} From d66fbf8c2ec71256fe94e4611b6ca7249d4046b1 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:07:29 +0100 Subject: [PATCH 17/42] fix(web): keep Advanced last in the Composer settings The Quiet stop button row landed below the collapsed Advanced row, where it read as part of that disclosure. --- .../components/settings/SettingsPanels.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 767d7bca80cf2..e5ab2a4d359d6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1268,33 +1268,6 @@ export function ChatSettingsPanel() { } /> - - - updateSettings({ - showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, - }) - } - /> - ) : null - } - control={ - - updateSettings({ showSkillsInSlashMenu: Boolean(checked) }) - } - aria-label="Show skills in slash menu" - /> - } - /> - } /> + + + updateSettings({ + showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, + }) + } + /> + ) : null + } + control={ + + updateSettings({ showSkillsInSlashMenu: Boolean(checked) }) + } + aria-label="Show skills in slash menu" + /> + } + /> + From fab4cd5eb014c8163bef6cc86114431fefe82bf8 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Thu, 5 Nov 2026 20:45:42 +0100 Subject: [PATCH 18/42] feat(web): Detailed and Slim rows copy pitwall's sidebar rows Both layouts share one row: the repo icon with the provider mark on an 11px disc, a semibold title, a state pill, the branch and time on line 2 in Detailed, and fold, tint, bar and stroke rules taken from pitwall. Workers fold from a chip on their parent's line 2 and sit behind an elbow guide. Hide lone provider logo now applies to the Default layout only. Two instances of one provider keep their initials on the mark, and a scoped empty project keeps its folder and its New thread control. The fold chip joins the sidebar's arrow keys, and worker rows and rows showing answers keep their terminal control and jump label. --- apps/web/src/components/Sidebar.tsx | 23 +- .../components/settings/SettingsPanels.tsx | 2 +- .../components/sidebar/SidebarThreadRow.tsx | 515 ++++++++++-------- .../variants/DetailedSidebarGroups.tsx | 119 +--- .../sidebar/variants/RowAnswers.tsx | 69 ++- .../variants/sidebarVariant.logic.test.ts | 48 +- .../sidebar/variants/sidebarVariant.logic.ts | 122 +++-- .../sidebar/variants/sidebarVariants.css | 276 ++++++---- .../sidebar/variants/useDetailedFolders.ts | 9 +- .../variants/variantRow.browser.test.tsx | 45 ++ .../variantRow.regressions.browser.test.tsx | 120 ++++ .../sidebar/variants/variantRow.tsx | 400 +++++++++----- 12 files changed, 1081 insertions(+), 667 deletions(-) create mode 100644 apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx create mode 100644 apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8397b6c4f2400..31e1756e3544d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -266,7 +266,7 @@ import { DetailedSidebarGroups } from "./sidebar/variants/DetailedSidebarGroups" import { threadsWaitingOnYou, variantRowStyle, - workerBucket, + workerSummary, workersByParent, workersOf, } from "./sidebar/variants/sidebarVariant.logic"; @@ -565,6 +565,7 @@ function useSidebarThreads(input: { projectExpandedById, routeRowKey: routeRowThreadKey, routeThreadKey, + keepEmpty, } : null, ); @@ -3334,7 +3335,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} > {(() => { - const workerMap = sidebarVariant === "slim" ? workersByParent(threads) : null; + const workerMap = + sidebarVariant !== "default" ? workersByParent(threads) : null; const activeRowKey = detailedFolders !== null && routeThreadKey !== null && @@ -3346,7 +3348,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve section: SidebarSection, // The tree row of the project folder this row sits in. folderRow?: string, - nested = false, + detailedItem?: { + nested: boolean; + lastNested: boolean; + workersOpen: boolean | null; + }, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -3363,6 +3369,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? "card" : "slim"); const workers = workerMap ? workersOf(workerMap, thread) : []; + const nested = detailedItem?.nested ?? false; return ( workerBucket(worker) === "need", - )} + lastNested={detailedItem?.lastNested ?? false} + workerCount={workers.length > 0 ? workers.length : null} + workerSummary={workers.length > 0 ? workerSummary(workers) : null} + workersOpen={detailedItem?.workersOpen ?? null} + onToggleWorkers={detailedFolders?.toggleWorkers} navigationParent={ folderRow ?? (section === "working" || @@ -3493,7 +3501,6 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) ?? null } onNavigate={navigateToThread} - onToggleWorkers={detailedFolders.toggleWorkers} onToggleParked={detailedFolders.toggleParked} renderHeader={(folder) => ( void) | undefined; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -735,7 +738,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const variantStyled = variant === "folder-detailed" || variant === "folder-slim"; const [answersOpen, setAnswersOpen] = useState(false); const shouldRecede = - !(variantStyled && status === "approval") && + !variantStyled && shouldRecedeSidebarThread({ status, isUnread, @@ -975,15 +978,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP variantAction === "unsettle" && !props.isActive && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label", - props.isActive - ? "bg-sidebar-row-active text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70" - : isSelected || props.sweepAction !== null - ? "bg-sidebar-row-selected text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70" - : hasUnsentDraft - ? cn(draftSurfaceClassName, "text-sidebar-foreground") - : shouldRecede - ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" - : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + variantStyled + ? "text-sidebar-foreground" + : props.isActive + ? "bg-sidebar-row-active text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70" + : isSelected || props.sweepAction !== null + ? "bg-sidebar-row-selected text-sidebar-foreground light:ring-1 light:ring-inset light:ring-sidebar-border/70" + : hasUnsentDraft + ? cn(draftSurfaceClassName, "text-sidebar-foreground") + : shouldRecede + ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" + : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", isFileDragOver && "ring-1 ring-inset ring-primary/70", isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", props.sortable?.isDragging && @@ -1028,6 +1033,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, }); + const lookWantsUser = + status === "approval" || + status === "input" || + status === "request" || + status === "failed" || + status === "limited" || + (!props.nested && (isUnread || isWoke)); const title = isRenaming ? ( ) : null; const diff = latestRunDiff(thread); + const diffCount = diff ? ( + + +{formatDiffCount(diff.insertions)}{" "} + −{formatDiffCount(diff.deletions)} + + ) : null; // Only the trailing icons would remain, so the row moves them to the title line instead. const metaLineEmpty = !thread.branch && @@ -1188,101 +1217,220 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {terminalStatusIcon} {prBadge} - {diff ? ( - - +{formatDiffCount(diff.insertions)}{" "} - −{formatDiffCount(diff.deletions)} - - ) : null} + {diffCount} {remoteMachineIcon} - {variantStyled ? null : ( - - )} +
    ); const titleLineIcons = metaLineEmpty ? ( {remoteMachineIcon} - {variantStyled ? null : ( - - )} + ) : null; if (variant !== "card") { - const detailed = variant === "folder-detailed"; - const multiline = variant === "folder-multiline" || detailed; + const multiline = variant === "folder-multiline"; const inFolder = variant !== "slim"; + const statusSlot = (content: ReactNode, keepsLabel: boolean) => ( + <> + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + + {/* The clock tells a wake time from an age at a glance. */} + + {props.snoozeWakeLabelText} + + ) : ( + content + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported || keepsLabel ? null : variantAction === "unsettle" ? ( + + + } + > + + + Un-settle thread + + ) : ( + + + } + > + + + Settle thread + + )} + {jumpHintLabel && !variantStyled ? : null} + + )} + {props.sweepAction !== null ? dragDestination : null} + + ); + const relativeTime = ( + + ); const providerStack = resolveThreadProviderStack(thread); - const rowProviderEntry = hidesLoneProvider( - thread, - props.providerEntryByInstanceId, - hideLoneProvider, - ) - ? null - : (props.providerEntryByInstanceId.get(providerStack[providerStack.length - 1]!) ?? null); + const rowProviderEntry = + props.providerEntryByInstanceId.get(providerStack[providerStack.length - 1]!) ?? null; const variantRow = variant === "folder-detailed" || variant === "folder-slim" ? resolveVariantRow({ style: variant, + threadKey, thread, status, statusWord: topStatus && !isWokeStatus ? topStatus : null, - statusIcon: - topStatus && !isWokeStatus ? ( - + + + Woke + + + } /> - ) : null, - duration: , - time: ( - - ), + Dismiss Woke notification + + ) : null, + time: relativeTime, isUnread, + isWoke, isActive: props.isActive, + isSelected: isSelected || props.sweepAction !== null, + hasDraftSurface: hasUnsentDraft, nested: props.nested ?? false, - project: props.project, - showProjectMark: props.navigationParent === undefined, + lastNested: props.lastNested ?? false, + inFolder: + props.navigationParent !== undefined && !props.navigationParent.startsWith("shelf:"), + repo: props.project ? ( + + ) : null, + mark: rowProviderEntry + ? (className) => ( + + ) + : null, title, - titleDirection: textDirection(thread.title), - workerCount: props.workerCount ?? 0, - workerNeedsYou: props.workerNeedsYou ?? false, + draft: draftIndicator, + pin: pinIndicator, + where: thread.branch ? ( + + {thread.branch} + + ) : showsScratchMachine ? ( + + {props.scratchMachineLabel} + + ) : ( + + ), + lineExtras: ( + <> + {prBadge} + {diffCount} + + ), + remote: remoteMachineIcon, + terminal: terminalStatusIcon, + jumpHint: jumpHintLabel ? : null, + workerCount: props.workerCount ?? null, + workerSummary: props.workerSummary ?? null, + workersOpen: props.workersOpen ?? null, + onToggleWorkers: () => props.onToggleWorkers?.(threadKey, props.workersOpen ?? false), answersOpen, onAnswersOpenChange: setAnswersOpen, onOpenThread: () => onThreadActivate(threadRef), - providerIcon: rowProviderEntry - ? (size) => ( - - ) - : null, + statusSlot, }) : null; return ( @@ -1296,6 +1444,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP sortable?.isDragging && "relative z-20", variantRow?.rootClassName, )} + {...variantRow?.rootAttributes} > } > - {/* `contents` keeps one-line rows a single flex row. */} -
    - {accessibleTitle} - {variantRow?.decor} - {inFolder ? null : ( - + {accessibleTitle} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {variantRow.body} + + ) : ( + <> + {/* `contents` keeps one-line rows a single flex row. */} +
    + {accessibleTitle} + {inFolder ? null : ( + + {props.project ? ( + + ) : null} + )} - > - {props.project ? ( - - ) : null} - - )} - {detailed ? null : draftIndicator} - {detailed ? ( - {draftIndicator} {title} + {multiline ? titleLineIcons : null} {pinIndicator} - - ) : ( - (variantRow?.title ?? title) - )} - {multiline ? titleLineIcons : null} - {detailed ? null : pinIndicator} - {multiline ? null : terminalStatusIcon} - {variantRow?.beforeSlot} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} - {sortable?.isDragging ? ( - dragDestination - ) : ( - - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - - {/* The clock tells a wake time from an age at a glance. */} - - {props.snoozeWakeLabelText} - - ) : (inFolder ? isWokeStatus : isWoke) ? ( + {multiline ? null : terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {statusSlot( + (inFolder ? isWokeStatus : isWoke) ? ( Dismiss Woke notification - ) : variantRow ? ( - variantRow.slot ) : inFolder && topStatus ? ( // Folder rows have no status line, so the status takes the time slot. ) : ( - - - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported || variantRow?.keepsSlot ? null : variantAction === - "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - - } - > - - - Settle thread - + {relativeTime} + ), + false, )} - {jumpHintLabel ? : null} - - )} - {props.sweepAction !== null ? dragDestination : null} -
    - {multiline ? metaLine : null} - {variantRow?.answers} +
    + {multiline ? metaLine : null} + + )}
    {detailsTooltip}
    diff --git a/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx index 10c88c064ddf2..c66f2d0530ce2 100644 --- a/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx +++ b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx @@ -2,12 +2,8 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { - CheckIcon, ChevronDownIcon, - CircleAlertIcon, - CircleDashedIcon, MessageCircleQuestionIcon, - PowerIcon, ShieldQuestionIcon, UserRoundIcon, } from "lucide-react"; @@ -16,106 +12,11 @@ import type { ReactNode } from "react"; import { cn } from "~/lib/utils"; import { resolveSidebarThreadStatus, type SidebarSection } from "../../Sidebar.logic"; import { sidebarFolderRowId } from "../SidebarProjectFolderHeader"; -import { workerBucket, type DetailedFolderItem, type WorkerBucket } from "./sidebarVariant.logic"; - -const WORKER_BUCKETS: readonly WorkerBucket[] = ["need", "failed", "working", "done", "idle"]; - -const BUCKET_BAR: Record = { - need: "bg-warning-foreground", - failed: "bg-error-foreground", - working: "bg-info-foreground", - done: "bg-success-foreground", - idle: "bg-secondary-label", -}; - -const BUCKET_TEXT: Record = { - need: "text-warning-foreground", - failed: "text-error-foreground", - working: "text-info-foreground", - done: "text-success-foreground", - idle: "text-secondary-label", -}; - -const BUCKET_LABEL: Record = { - need: "need you", - failed: "failed", - working: "working", - done: "done", - idle: "idle", -}; - -const BUCKET_ICON = { - need: ShieldQuestionIcon, - failed: CircleAlertIcon, - working: CircleDashedIcon, - done: CheckIcon, - idle: PowerIcon, -} as const; +import type { DetailedFolderItem } from "./sidebarVariant.logic"; const SECONDARY_ROW_CLASS_NAME = "sidebar-variant-tokens w-full cursor-pointer rounded-md text-left text-xs outline-none hover:bg-sidebar-row-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"; -function WorkerStrip(props: { - rowId: string; - parentRow: string; - workers: readonly EnvironmentThreadShell[]; - open: boolean; - onToggle: () => void; -}) { - const buckets = props.workers.map(workerBucket); - const counts = WORKER_BUCKETS.flatMap((bucket) => { - const count = buckets.filter((candidate) => candidate === bucket).length; - return count > 0 ? [{ bucket, count }] : []; - }); - const summary = counts.map(({ bucket, count }) => `${count} ${BUCKET_LABEL[bucket]}`).join(", "); - return ( -
  • - -
  • - ); -} - function ParkedToggle(props: { rowId: string; parentRow: string; @@ -133,7 +34,7 @@ function ParkedToggle(props: { onClick={props.onToggle} className={cn( SECONDARY_ROW_CLASS_NAME, - "flex h-7 items-center gap-2.5 ps-9 pe-2.5 text-secondary-label hover:text-foreground", + "flex h-7 items-center gap-2.5 ps-11 pe-3 text-secondary-label hover:text-foreground", )} > {props.label} @@ -223,14 +124,13 @@ export function DetailedSidebarGroups< routeThreadKey: string | null; projectNameOf: (thread: EnvironmentThreadShell) => string | null; onNavigate: (ref: ScopedThreadRef) => void; - onToggleWorkers: (parentKey: string, open: boolean) => void; onToggleParked: (projectKey: string) => void; renderHeader: (folder: TFolder) => ReactNode; renderRow: ( thread: EnvironmentThreadShell, section: SidebarSection, folderRow: string, - nested: boolean, + item: { nested: boolean; lastNested: boolean; workersOpen: boolean | null }, ) => ReactNode; }) { return ( @@ -243,18 +143,7 @@ export function DetailedSidebarGroups< ...folder.items.map((item) => { switch (item.kind) { case "thread": - return props.renderRow(item.thread, item.section, folderRow, item.nested); - case "workers": - return ( - props.onToggleWorkers(item.parentKey, item.open)} - /> - ); + return props.renderRow(item.thread, item.section, folderRow, item); case "parked": return ( event.stopPropagation(); function AnswerBody(props: { detail: string; + inline: boolean; mono?: boolean; note?: string | null; error: string | null; @@ -30,18 +30,43 @@ function AnswerBody(props: { }) { return ( <> -

    - {props.detail} -

    - {props.note ?

    {props.note}

    : null} + {props.inline ? ( + + + } + > + {props.detail} + + {props.detail} + + ) : ( +

    + {props.detail} +

    + )} + {props.note ? ( +

    + {props.note} +

    + ) : null}
    {props.children}
    {props.error !== null ? ( -

    +

    {props.error}

    ) : null} @@ -55,6 +80,7 @@ function RowAnswerContent(props: { onOpenThread: (() => void) | null; }) { const { thread, onOpenThread } = props; + const inline = onOpenThread === null; const pending = useAtomValue( environmentThreadDetails.pendingRequestsAtom(scopeThreadRef(thread.environmentId, thread.id)), ); @@ -82,6 +108,7 @@ function RowAnswerContent(props: { return ( + @@ -131,7 +158,7 @@ function RowAnswerContent(props: { ) : null; } return ( - + {question.options.map((option) => { const button = (
    ); } -/** Slim: the status word opens the answers, so the rows below never move. */ +/** Slim: the pill opens the answers, so the rows below never move. */ export function RowAnswersPopover(props: { thread: EnvironmentThreadShell; label: string; @@ -205,14 +235,13 @@ export function RowAnswersPopover(props: { onClick={stopRowActivation} onDoubleClick={stopRowActivation} className={cn( - "inline-flex h-6 cursor-pointer items-center gap-0.5 rounded-sm text-xs font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + "relative cursor-pointer outline-none after:absolute after:-inset-y-1 after:inset-x-0 hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", props.className, )} /> } > - {props.label} - + {props.label} {/* A row click navigates, and portals still bubble React events to the row. */} { }); }); +describe("detailedFolderShows", () => { + it("keeps an empty folder only when the sidebar's predicate does", () => { + expect(detailedFolderShows({ active: [], settled: [] }, true)).toBe(true); + expect(detailedFolderShows({ active: [], settled: [] }, false)).toBe(false); + expect(detailedFolderShows({ active: [], settled: ["thread"] }, false)).toBe(true); + }); +}); + describe("rowAttention", () => { it("marks asks, failures and unseen finishes only", () => { - expect(rowAttention("approval", false)).toBe("need"); - expect(rowAttention("limited", false)).toBe("need"); - expect(rowAttention("failed", true)).toBe("failed"); - expect(rowAttention("ready", true)).toBe("done"); + expect(rowAttention("approval", false)).toBe("warning"); + expect(rowAttention("limited", false)).toBe("warning"); + expect(rowAttention("input", false)).toBe("input"); + expect(rowAttention("request", false)).toBe("guest"); + expect(rowAttention("failed", true)).toBe("error"); + expect(rowAttention("ready", true)).toBe("success"); expect(rowAttention("ready", false)).toBeUndefined(); expect(rowAttention("working", true)).toBeUndefined(); }); @@ -186,14 +198,15 @@ describe("workers", () => { it("folds workers until one needs the user, and honors a manual choice", () => { const folded = workersByParent([parent, quiet]); - expect(ids(arrange({ active: [parent], workers: folded }))).toEqual(["p", "workers"]); + const closed = arrange({ active: [parent], workers: folded }); + expect(ids(closed)).toEqual(["p"]); + expect(closed.items[0]).toMatchObject({ workersOpen: false }); const needs = workersByParent([parent, quiet, asking]); - expect(ids(arrange({ active: [parent], workers: needs }))).toEqual([ - "p", - "workers", - "w1", - "w2", - ]); + const open = arrange({ active: [parent], workers: needs }); + expect(ids(open)).toEqual(["p", "w1", "w2"]); + expect(open.items[0]).toMatchObject({ workersOpen: true }); + expect(open.items[1]).toMatchObject({ nested: true, lastNested: false }); + expect(open.items[2]).toMatchObject({ nested: true, lastNested: true }); expect( ids( arrange({ @@ -202,16 +215,23 @@ describe("workers", () => { workerFold: new Map([["environment-a:p", false]]), }), ), - ).toEqual(["p", "workers"]); + ).toEqual(["p"]); }); - it("shows an open worker under its parent while the strip is folded", () => { + it("shows an open worker under its parent while the row is folded", () => { const workers = workersByParent([parent, quiet]); expect(ids(arrange({ active: [parent], workers, routeThreadKey: "environment-a:w1" }))).toEqual( - ["p", "workers", "w1"], + ["p", "w1"], ); }); + it("counts workers and how many share the most urgent state", () => { + const running = worker("w3", "p", { runtime: { status: "running" } }); + expect(workerSummary([quiet])).toBe("1 worker"); + expect(workerSummary([quiet, running])).toBe("2 workers · 1 working"); + expect(workerSummary([running, asking, quiet])).toBe("3 workers · 1 approval"); + }); + it("counts a thread as waiting only when it asks itself", () => { const raised = shell("r", "2026-01-01T00:00:00.000Z", { pendingSubagentRequest: "approval" }); const archived = shell("x", "2026-01-01T00:00:00.000Z", { diff --git a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts index baa0f1ef3432d..1966bcfa6f114 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts +++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts @@ -10,8 +10,7 @@ import { } from "../../Sidebar.logic"; export type VariantRowStyle = "folder-detailed" | "folder-slim"; -export type RowAttention = "need" | "failed" | "done"; -export type WorkerBucket = "need" | "failed" | "working" | "done" | "idle"; +export type RowAttention = "warning" | "input" | "guest" | "error" | "success"; /** The row a variant draws, or null for T3's own row in that place. */ export function variantRowStyle( @@ -24,31 +23,72 @@ export function variantRowStyle( return null; } +/** The state colour of a row that wants the user, or undefined for a quiet row. */ export function rowAttention( status: SidebarThreadStatus, isUnread: boolean, ): RowAttention | undefined { switch (status) { case "approval": + case "limited": + return "warning"; case "input": + return "input"; case "request": - case "limited": - return "need"; + return "guest"; case "failed": - return "failed"; + return "error"; case "ready": - return isUnread ? "done" : undefined; + return isUnread ? "success" : undefined; default: return undefined; } } -export function workerBucket(thread: EnvironmentThreadShell): WorkerBucket { +function workerNeedsYou(thread: EnvironmentThreadShell): boolean { const status = resolveSidebarThreadStatus(thread); - if (status === "approval" || status === "input" || status === "request") return "need"; - if (status === "failed" || status === "limited") return "failed"; - if (status === "working" || status === "preparing" || status === "waiting") return "working"; - return thread.latestRun?.completedAt || hasUnseenCompletion(thread) ? "done" : "idle"; + return status === "approval" || status === "input" || status === "request"; +} + +/** A worker's rank and the count's two word forms; a higher rank names the count. */ +function workerState(thread: EnvironmentThreadShell): readonly [number, string, string] | null { + switch (resolveSidebarThreadStatus(thread)) { + case "failed": + return [8, "error", "errors"]; + case "limited": + return [8, "limited", "limited"]; + case "approval": + return [7, "approval", "approvals"]; + case "input": + return [6, "question", "questions"]; + case "request": + return [6, "request", "requests"]; + case "working": + case "preparing": + case "waiting": + return [5, "working", "working"]; + default: + return thread.latestRun?.completedAt || hasUnseenCompletion(thread) + ? [2, "done", "done"] + : null; + } +} + +export function workerTotal(count: number): string { + return `${count} ${count === 1 ? "worker" : "workers"}`; +} + +/** The total, then how many share the most urgent state: "2 workers · 1 working". */ +export function workerSummary(workers: readonly EnvironmentThreadShell[]): string { + const total = workerTotal(workers.length); + const states = workers.map(workerState).filter((state) => state !== null); + const top = states.reduce<(typeof states)[number] | null>( + (best, state) => (best === null || state[0] > best[0] ? state : best), + null, + ); + if (top === null) return total; + const count = states.filter((state) => state[1] === top[1]).length; + return `${total} · ${count} ${count === 1 ? top[1] : top[2]}`; } export type WorkerMap = ReadonlyMap; @@ -89,18 +129,24 @@ function parkedLabel(snoozed: number, settled: number): string { .join(" · "); } +/** A folder shows while it holds a thread, or when the sidebar keeps it empty. */ +export function detailedFolderShows( + lists: Readonly>, + keepEmpty: boolean, +): boolean { + return keepEmpty || Object.values(lists).some((list) => list.length > 0); +} + export type DetailedFolderItem = | { readonly kind: "thread"; readonly thread: EnvironmentThreadShell; readonly section: SidebarSection; readonly nested: boolean; - } - | { - readonly kind: "workers"; - readonly parentKey: string; - readonly workers: readonly EnvironmentThreadShell[]; - readonly open: boolean; + /** The last worker shown under its parent, where the guide turns in. */ + readonly lastNested: boolean; + /** Whether its worker rows show; null when the row cannot fold them. */ + readonly workersOpen: boolean | null; } | { readonly kind: "parked"; readonly label: string; readonly open: boolean }; @@ -127,25 +173,32 @@ export function arrangeDetailedFolder(input: { const items: DetailedFolderItem[] = []; for (const thread of main) { if (collapsed && !isRouteRow(thread)) continue; + const workers = workersOf(input.workers, thread); + const parentKey = threadKeyOf(thread); + const open = + !collapsed && + (input.workerFold.get(parentKey) ?? workers.some((worker) => workerNeedsYou(worker))); items.push({ kind: "thread", thread, section: thread.pinnedAt != null ? "pinned" : "active", nested: false, + lastNested: false, + workersOpen: workers.length > 0 && !collapsed ? open : null, + }); + const shown = arrangeStableRows(workers).filter( + (worker) => open || threadKeyOf(worker) === input.routeThreadKey, + ); + shown.forEach((worker, index) => { + items.push({ + kind: "thread", + thread: worker, + section: "active", + nested: true, + lastNested: index === shown.length - 1, + workersOpen: null, + }); }); - const workers = workersOf(input.workers, thread); - if (workers.length === 0) continue; - const parentKey = threadKeyOf(thread); - const open = - !collapsed && - (input.workerFold.get(parentKey) ?? - workers.some((worker) => workerBucket(worker) === "need")); - if (!collapsed) items.push({ kind: "workers", parentKey, workers, open }); - for (const worker of arrangeStableRows(workers)) { - if (open || threadKeyOf(worker) === input.routeThreadKey) { - items.push({ kind: "thread", thread: worker, section: "active", nested: true }); - } - } } const parkedCount = input.snoozed.length + input.settled.length; const parkedOpen = !collapsed && input.parkedOpen; @@ -162,7 +215,14 @@ export function arrangeDetailedFolder(input: { ] as const) { for (const thread of list) { if (parkedOpen || isRouteRow(thread)) { - items.push({ kind: "thread", thread, section, nested: false }); + items.push({ + kind: "thread", + thread, + section, + nested: false, + lastNested: false, + workersOpen: null, + }); } } } diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index ea3c6157c83e5..82a85b70e3832 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -1,50 +1,37 @@ +/* Two-row-wide tiles slide four row widths left, as background-position -200% to 200% does. */ @keyframes sidebar-row-sweep { - 0% { - transform: translateX(-100%); - animation-timing-function: steps(9); + from { + transform: translateX(0); } - 20% { - transform: translateX(-84.4%); - animation-timing-function: steps(9); - } - 40% { - transform: translateX(-47.2%); - animation-timing-function: steps(9); - } - 60% { - transform: translateX(-2.8%); - animation-timing-function: steps(9); - } - 80% { - transform: translateX(34.4%); - animation-timing-function: steps(9); - } - 100% { - transform: translateX(50%); + to { + transform: translateX(-66.6667%); } } @keyframes sidebar-status-pulse { 0%, - 40%, 100% { opacity: 1; - animation-timing-function: steps(6); } - 50%, - 90% { - opacity: 0.8; - animation-timing-function: steps(6); + 50% { + opacity: 0.5; } } /* Some built-in themes put these tokens under 4.5:1 at 12px; mixing in foreground lifts them. */ @utility sidebar-variant-tokens { --contrast-secondary-label: color-mix(in srgb, var(--foreground) 72%, transparent); + --row-muted: color-mix(in srgb, var(--foreground) 80%, transparent); --variant-warning: color-mix(in srgb, var(--warning-foreground) 65%, var(--foreground)); --variant-success: color-mix(in srgb, var(--success-foreground) 65%, var(--foreground)); --variant-info: color-mix(in srgb, var(--info-foreground) 65%, var(--foreground)); --variant-error: color-mix(in srgb, var(--error-foreground) 65%, var(--foreground)); + --variant-guest: color-mix(in srgb, var(--guest-foreground) 65%, var(--foreground)); + --variant-input: color-mix(in srgb, var(--color-indigo-600) 65%, var(--foreground)); + + .dark & { + --variant-input: color-mix(in srgb, var(--color-indigo-300) 65%, var(--foreground)); + } &[aria-current="page"] { --variant-warning: color-mix(in srgb, var(--warning-foreground) 50%, var(--foreground)); @@ -66,18 +53,14 @@ color: var(--variant-error); } & .text-muted-foreground { - color: var(--contrast-secondary-label); + color: var(--row-muted); } & .text-guest-foreground { - color: color-mix(in srgb, var(--guest-foreground) 65%, var(--foreground)); + color: var(--variant-guest); } & .text-indigo-600, & .sidebar-input-mark { - color: color-mix(in srgb, var(--color-indigo-600) 65%, var(--foreground)); - } - .dark & .text-indigo-600, - .dark & .sidebar-input-mark { - color: color-mix(in srgb, var(--color-indigo-300) 65%, var(--foreground)); + color: var(--variant-input); } & .bg-warning-foreground { background-color: var(--variant-warning); @@ -106,6 +89,7 @@ color: var(--color-indigo-600); } +/* Six row widths of two-row-wide tiles, so the slide never shows an edge. */ @utility sidebar-row-sweep { position: absolute; inset: 0; @@ -119,16 +103,17 @@ position: absolute; inset-block: 0; left: 0; - width: 200%; - background: linear-gradient( + width: 600%; + background-image: linear-gradient( to right, transparent 0%, - color-mix(in srgb, var(--foreground) 2.5%, transparent) 40%, - color-mix(in srgb, var(--foreground) 6%, transparent) 50%, - color-mix(in srgb, var(--foreground) 2.5%, transparent) 60%, + rgb(255 255 255 / 0.02) 40%, + rgb(255 255 255 / 0.04) 50%, + rgb(255 255 255 / 0.02) 60%, transparent 100% ); - animation: sidebar-row-sweep 3s infinite; + background-size: 33.3333% 100%; + animation: sidebar-row-sweep 3s ease-in-out infinite; animation-play-state: var(--visible-animation-state, paused); will-change: var(--visible-animation-will-change, auto); } @@ -139,7 +124,7 @@ } @utility sidebar-status-dot { - animation: sidebar-status-pulse 2s infinite; + animation: sidebar-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation-play-state: var(--visible-animation-state, paused); will-change: var(--visible-animation-will-change, auto); @@ -149,109 +134,190 @@ } } -@utility sidebar-status-spin { - animation: spin 2.2s steps(30) infinite; - animation-play-state: var(--visible-animation-state, paused); - will-change: var(--visible-animation-will-change, auto); +/* As tall as line 2, with the state colour mixed 14% into the row. */ +@utility sidebar-row-pill { + height: 1.03125rem; + padding-inline: 0.4375rem; + background-color: color-mix(in srgb, currentColor 14%, transparent); +} - @media (prefers-reduced-motion: reduce), (forced-colors: active) { - animation: none; - will-change: auto; - } +/* Centres the capitals in the pill in any font, whatever fraction the row lands on. */ +@utility sidebar-row-pill-word { + line-height: 1.03125rem; + text-box: trim-both cap alphabetic; +} + +@utility sidebar-row-title { + font-size: 0.8125rem; + line-height: 1.5; } -@utility sidebar-row-mark-notch { - mask-image: radial-gradient(circle 6px at 17px 17px, transparent 95%, #000 100%); +/* 55% primary painted over the row's fill is primary mixed 55% into that fill. */ +@utility sidebar-row-stroke { + --tw-ring-color: color-mix(in srgb, var(--primary) 55%, transparent); } -@utility sidebar-row-detailed { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 0.125rem 0.5rem; - isolation: isolate; +@utility sidebar-row-look { + --row-fill: transparent; + --row-state: transparent; + --row-tint: color-mix(in srgb, var(--row-state) 10%, transparent); + background-color: var(--row-fill); + background-image: linear-gradient(var(--row-tint), var(--row-tint)); - & [data-title-line] { - grid-area: 1 / 1 / 2 / 2; + &:hover { + --row-fill: var(--sidebar-row-hover); } - & > [data-meta-line] { - grid-area: 2 / 1 / 3 / 3; - margin-top: 0; + &[data-surface="draft"] { + --row-fill: color-mix(in srgb, var(--warning) 4%, transparent); } - & [data-status-slot], - & [role="status"] { - height: 1.25rem; - margin-block: -0.125rem; + &[data-surface="draft"]:hover { + --row-fill: color-mix(in srgb, var(--warning) 8%, transparent); } - /* The status joins the branch line, so the title gets the row's full width. */ - &:has(> [data-meta-line]) [data-title-line] { - grid-column-end: -1; + &[data-surface="selected"] { + --row-fill: var(--sidebar-row-selected); } - &:has(> [data-meta-line]) [data-status-slot], - &:has(> [data-meta-line]) [role="status"] { - grid-area: 2 / 3 / 3 / 4; + &[data-surface="active"] { + --row-fill: var(--sidebar-row-active); + --row-tint: transparent; } - & > [data-row-answers] { - grid-column: 1 / -1; + &[data-surface="worker-active"] { + --row-fill: color-mix(in srgb, var(--primary) 8%, transparent); } - &::before, - &::after { - content: ""; - position: absolute; - pointer-events: none; - opacity: 0; - transition: opacity 200ms ease; + &[data-surface="worker-selected"] { + --row-fill: color-mix(in srgb, var(--primary) 7%, transparent); + } - @media (prefers-reduced-motion: reduce) { - transition: none; - } + &[data-surface^="worker-"]:hover { + --row-fill: var(--sidebar-row-hover); } - &::before { - inset: 0; - z-index: -1; - background: var(--attention-tint, transparent); + &[data-attention="warning"] { + --row-state: var(--variant-warning); + } + + &[data-attention="input"] { + --row-state: var(--variant-input); } - &::after { + &[data-attention="guest"] { + --row-state: var(--variant-guest); + } + + &[data-attention="error"] { + --row-state: var(--variant-error); + } + + &[data-attention="success"] { + --row-state: var(--variant-success); + } + + &[data-attention]::after { + content: ""; + position: absolute; left: 2px; - inset-block: 0.625rem; + inset-block: 10px; width: 3px; border-radius: 1.5px; - background: var(--attention-bar, transparent); + background: var(--row-state); + pointer-events: none; } +} - &[data-attention]::before, - &[data-attention]::after { - opacity: 1; +/* An opaque copy of the row's own layers, so the mark reads the same on every row state. */ +@utility sidebar-row-disc { + background-color: var(--background); + background-image: + linear-gradient(var(--row-tint), var(--row-tint)), + linear-gradient(var(--row-fill), var(--row-fill)), + linear-gradient(var(--sidebar), var(--sidebar)); + + [data-mobile="true"] & { + background-image: + linear-gradient(var(--row-tint), var(--row-tint)), + linear-gradient(var(--row-fill), var(--row-fill)), + linear-gradient(var(--color-sidebar), var(--color-sidebar)); } +} - &[data-attention="need"] { - --attention-tint: color-mix(in srgb, var(--warning) 8%, transparent); - --attention-bar: var(--variant-warning); +/* While the row asks, the answers take line 2 and the fold chip only counts. */ +@utility sidebar-row-line2 { + line-height: 1.5; + + /* Taller controls overhang the line, so the row keeps its height. */ + & [data-line2-rest] > * { + margin-block: -0.5rem; } - &[data-attention="failed"] { - --attention-tint: color-mix(in srgb, var(--error) 10%, transparent); - --attention-bar: var(--variant-error); + & [data-chip-short] { + display: none; } - &[data-attention="done"] { - --attention-tint: color-mix(in srgb, var(--success) 10%, transparent); - --attention-bar: var(--variant-success); + &:has([data-row-answers]:not(:empty)) [data-line2-rest], + &:has([data-row-answers]:not(:empty)) [data-chip-full] { + display: none; } - &[aria-current="page"]::before { - opacity: 0; + &:has([data-row-answers]:not(:empty)) [data-chip-short] { + display: inline; } } +/* The tree line from a row's icon down its workers, with an elbow into the last one. */ +@utility sidebar-worker-guide { + --guide: color-mix(in srgb, var(--muted-foreground) 35%, var(--sidebar)); + + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 18px; + width: 1px; + background: var(--guide); + } + + &[data-last-worker]::before { + bottom: 50%; + } + + &[data-last-worker]::after { + content: ""; + position: absolute; + top: calc(50% - 1px); + left: 18px; + width: 4px; + height: 1px; + background: var(--guide); + } +} + +[data-row-answers] button { + height: 1.125rem; + min-height: 0; + padding-inline: 0.4375rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; +} + +[data-row-answers] button[aria-label="More approval options"] { + width: 1.125rem; + padding-inline: 0; +} + +/* The pills are 18px tall, and this keeps their hit area at 24px. */ +[data-row-answers] button::after { + content: ""; + position: absolute; + inset: -3px 0; +} + /* Keeps a focused or revealed row clear of the sticky Waiting on you list. */ ul:has(> [data-testid="sidebar-waiting-on-you"]) > li > [data-sidebar-row] { scroll-margin-bottom: 14rem; diff --git a/apps/web/src/components/sidebar/variants/useDetailedFolders.ts b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts index 7015bc773339c..9db364e8fe5e5 100644 --- a/apps/web/src/components/sidebar/variants/useDetailedFolders.ts +++ b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts @@ -6,7 +6,11 @@ import { useLocalStorage } from "../../../hooks/useLocalStorage"; import { resolveProjectExpanded } from "../../../uiStateStore"; import type { SidebarProjectSnapshot } from "../../../sidebarProjectGrouping"; import { groupSidebarThreadsByProject, sidebarFolderExpansionKeys } from "../../Sidebar.logic"; -import { arrangeDetailedFolder, workersByParent } from "./sidebarVariant.logic"; +import { + arrangeDetailedFolder, + detailedFolderShows, + workersByParent, +} from "./sidebarVariant.logic"; const PARKED_OPEN_KEY = "t3code:sidebar:detailed-parked-open"; const ParkedOpenKeys = Schema.Array(Schema.String); @@ -24,6 +28,7 @@ export function useDetailedFolders( readonly projectExpandedById: Readonly>; readonly routeRowKey: string | null; readonly routeThreadKey: string | null; + readonly keepEmpty: (group: SidebarProjectSnapshot) => boolean; } | null, ) { const [workerFold, setWorkerFold] = useState>(new Map()); @@ -55,7 +60,7 @@ export function useDetailedFolders( snoozed: snoozed.get(key) ?? [], settled: settled.get(key) ?? [], }; - if (Object.values(lists).every((list) => list.length === 0) && !group.pinned) return []; + if (!detailedFolderShows(lists, input.keepEmpty(group))) return []; const collapsed = !resolveProjectExpanded( input.projectExpandedById, sidebarFolderExpansionKeys(group), diff --git a/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx new file mode 100644 index 0000000000000..489e93d1aa004 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/variantRow.browser.test.tsx @@ -0,0 +1,45 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { render } from "../../../../test/render"; +import type { SidebarProviderEntry } from "../SidebarThreadRow"; +import { RowProviderMark } from "./variantRow"; + +const entry = (instanceId: string, displayName: string) => + ({ + instanceId: ProviderInstanceId.make(instanceId), + driverKind: ProviderDriverKind.make("codex"), + displayName, + enabled: true, + models: [], + }) as unknown as SidebarProviderEntry; + +const personal = entry("codex", "Codex"); +const work = entry("codex_work", "Codex Work"); + +it("tells two instances of one provider apart on the mark", async () => { + await using screen = await render( + , + ); + await expect.element(screen.getByText("CW")).toBeInTheDocument(); +}); + +it("adds nothing when the provider has one instance", async () => { + await using screen = await render( + , + ); + expect(screen.container.textContent).toBe(""); +}); diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx new file mode 100644 index 0000000000000..6babd5a0eeaf0 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -0,0 +1,120 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../../test/render"; +import { installSidebarTree } from "../../../lib/sidebarTree"; + +vi.mock("./RowAnswers", () => ({ + InlineRowAnswers: () =>
    Allow
    , + RowAnswersPopover: () => null, +})); + +import { WorkerFoldChip, resolveVariantRow } from "./variantRow"; + +const thread = { + title: "A thread", + hasPendingApprovals: false, + hasPendingUserInput: false, +} as unknown as EnvironmentThreadShell; + +const row = (overrides: Partial[0]>) => + resolveVariantRow({ + style: "folder-detailed", + threadKey: "env:parent", + thread, + status: "ready", + statusWord: null, + wokePill: null, + time: "1m", + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + hasDraftSurface: false, + nested: false, + lastNested: false, + inFolder: true, + repo: null, + mark: null, + title: Title, + draft: null, + pin: null, + where: branch, + lineExtras: null, + remote: null, + terminal: null, + jumpHint: null, + workerCount: null, + workerSummary: null, + workersOpen: null, + onToggleWorkers: () => {}, + answersOpen: false, + onAnswersOpenChange: () => {}, + onOpenThread: () => {}, + statusSlot: (content: ReactNode) => {content}, + ...overrides, + }).body; + +it("keeps the terminal control on a worker's line", async () => { + await using screen = await render( +
    {row({ nested: true, terminal: })}
    , + ); + const button = screen.getByRole("button", { name: "Open terminal" }).element(); + expect(button.closest("[data-row-line1]")).not.toBeNull(); +}); + +it("keeps a parent's terminal control beside its answers", async () => { + await using screen = await render( +
    + {row({ + thread: { ...thread, hasPendingApprovals: true }, + terminal: , + })} +
    , + ); + const button = screen.getByRole("button", { name: "Open terminal" }).element(); + expect(button.closest("[data-line2-rest]")).toBeNull(); +}); + +it("shows a jump label on a worker and on a parent showing answers", async () => { + const hint = Ctrl 1; + await using worker = await render(
    {row({ nested: true, jumpHint: hint })}
    ); + expect(worker.getByText("Ctrl 1").element().closest("[data-row-line1]")).not.toBeNull(); + await using parent = await render( +
    {row({ thread: { ...thread, hasPendingApprovals: true }, jumpHint: hint })}
    , + ); + const label = parent.getByText("Ctrl 1").element(); + expect(label.closest("[data-row-line1]")).not.toBeNull(); + expect(label.closest("[data-status-slot]")).toBeNull(); +}); + +it("takes the fold chip into the sidebar's arrow keys", async () => { + const onToggle = vi.fn(); + await using screen = await render( +
    +
    + +
    +
    , + ); + const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement); + const chip = screen + .getByRole("button", { name: "2 workers · 1 working", exact: true }) + .element() as HTMLElement; + const parent = chip.parentElement!; + parent.focus(); + parent.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + expect(document.activeElement).toBe(chip); + chip.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + expect(onToggle).toHaveBeenCalledOnce(); + chip.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(document.activeElement).toBe(parent); + cleanup(); +}); diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index b8b1d7821cc1c..9ffc2fa6d12b7 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -1,202 +1,318 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { BotIcon, CircleDashedIcon } from "lucide-react"; -import type { ReactNode } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import type { MouseEvent, ReactNode } from "react"; import { cn } from "~/lib/utils"; -import { textDirection } from "~/textDirection"; import { observeVisibleAnimation } from "../../../lib/visibleAnimation"; -import { ActivityShimmerOverlay } from "../../chat/TimelineRowParts"; -import { ProjectFavicon } from "../../ProjectFavicon"; +import { shouldShowInstanceBadge } from "../../../providerInstances"; +import { ProviderInstanceIcon, providerTextColor } from "../../chat/ProviderInstanceIcon"; import type { SidebarThreadStatus } from "../../Sidebar.logic"; import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers"; -import { rowAttention, type VariantRowStyle } from "./sidebarVariant.logic"; +import type { SidebarProviderEntry } from "../SidebarThreadRow"; +import { rowAttention, workerTotal, type VariantRowStyle } from "./sidebarVariant.logic"; -function PulsingDot() { +/** As tall as line 2, with the state colour mixed 14% into the row. */ +export const ROW_PILL_CLASS_NAME = + "sidebar-row-pill inline-flex shrink-0 items-center gap-1 rounded-full text-2xs font-semibold whitespace-nowrap"; + +const stopRowActivation = (event: MouseEvent) => event.stopPropagation(); + +/** The provider's logo in full colour, with initials when the provider has several instances. */ +export function RowProviderMark(props: { + entry: SidebarProviderEntry; + entries: ReadonlyMap; + className: string; +}) { + const color = providerTextColor(props.entry.driverKind); return ( - + + + ); } -function SpinningMark() { +function RowLead(props: { + repo: ReactNode; + mark: ((className: string) => ReactNode) | null; +}): ReactNode { return ( - + + {props.repo !== null ? ( + <> + + {props.repo} + + {props.mark ? ( + + {props.mark("size-2")} + + ) : null} + + ) : props.mark ? ( + + {props.mark("size-3.5")} + + ) : null} + ); } -function workerLabel(count: number, needsYou: boolean): string { - return `${count} ${count === 1 ? "worker" : "workers"}${needsYou ? ", one needs you" : ""}`; +/** The count on line 2 that folds a row's workers; the sidebar's arrow keys treat it as a row. */ +export function WorkerFoldChip(props: { + threadKey: string; + open: boolean; + summary: string; + total: string; + onToggle: () => void; +}) { + return ( + + ); } -/** The parts a variant adds to T3's folder row; everything else stays T3's row. */ +/** Detailed and Slim rows, where Slim is this row without line 2. */ export function resolveVariantRow(input: { style: VariantRowStyle; + threadKey: string; thread: EnvironmentThreadShell; status: SidebarThreadStatus; statusWord: { label: string; className: string } | null; - statusIcon: ReactNode; - duration: ReactNode; + wokePill: ReactNode; time: ReactNode; isUnread: boolean; + isWoke: boolean; isActive: boolean; + isSelected: boolean; + hasDraftSurface: boolean; nested: boolean; - project: Parameters[0]["project"] | null; - showProjectMark: boolean; + lastNested: boolean; + inFolder: boolean; + repo: ReactNode; + mark: ((className: string) => ReactNode) | null; title: ReactNode; - titleDirection: ReturnType; - workerCount: number; - workerNeedsYou: boolean; + draft: ReactNode; + pin: ReactNode; + where: ReactNode; + lineExtras: ReactNode; + remote: ReactNode; + terminal: ReactNode; + jumpHint: ReactNode; + workerCount: number | null; + workerSummary: string | null; + workersOpen: boolean | null; + onToggleWorkers: () => void; answersOpen: boolean; onAnswersOpenChange: (open: boolean) => void; onOpenThread: () => void; - providerIcon: ((size: "badge" | "alone") => ReactNode) | null; + statusSlot: (content: ReactNode, keepsLabel: boolean) => ReactNode; }) { - const { style, thread, status, statusWord } = input; + const { thread, status, statusWord, nested } = input; const working = status === "working" || status === "preparing"; const asksYou = thread.hasPendingApprovals || thread.hasPendingUserInput; - const detailed = style === "folder-detailed"; + const detailed = input.style === "folder-detailed"; const answersInPopover = !detailed && asksYou; + const attention = nested + ? undefined + : (rowAttention(status, input.isUnread) ?? (input.isWoke ? ("warning" as const) : undefined)); - const mark = working ? ( - detailed ? ( - - ) : ( - - ) - ) : ( - (input.statusIcon ?? - (input.showProjectMark && input.project ? ( - - ) : null)) - ); - - const speaksUp = - status === "approval" || - status === "input" || - status === "request" || - status === "failed" || - status === "limited"; - - const slot = - answersInPopover && statusWord ? ( + const pill = + input.wokePill ?? + (statusWord === null ? null : answersInPopover ? ( - ) : (detailed || speaksUp) && statusWord ? ( - - {statusWord.label} - {detailed && working ? ( - - {input.duration} - + ) : ( + + {working ? ( + ) : null} + {statusWord.label} + + )); + + const pinned = input.pin ? ( + {input.pin} + ) : null; + const lead = nested ? ( + + {input.mark?.("size-3")} + + ) : ( + + ); + const lineOne = ( +
    + {lead} + {input.title} + {input.draft} + {pinned} + {nested ? input.terminal : null} + {input.jumpHint} + {!detailed ? ( + <> + {input.workerCount !== null ? ( + + + {input.workerCount} + + ) : null} + {input.terminal} + {input.statusSlot( + pill ?? {input.time}, + answersInPopover, + )} + + ) : pill !== null ? ( + {pill} + ) : null} +
    + ); + + const chip = + input.workerCount === null ? null : input.workersOpen === null ? ( + + {input.workerSummary} - ) : working ? ( - {input.duration} ) : ( - {input.time} + ); - const attention = detailed ? rowAttention(status, input.isUnread) : undefined; + const lineTwo = + detailed && !nested ? ( +
    + {input.remote ? ( + + {input.remote} + + ) : null} + {chip} + {asksYou ? : null} + + {input.workerCount !== null ? : input.where} + + {input.terminal} + + {input.lineExtras} + {input.statusSlot(input.time, false)} + +
    + ) : null; return { rowClassName: cn( - "sidebar-variant-tokens ps-9", - detailed ? "sidebar-row-detailed py-1.5 pe-2.5" : "flex h-9 items-center gap-2.5 pe-2.5", - input.nested && "ps-13", + "sidebar-variant-tokens sidebar-row-look", + nested ? "rounded-md px-2 py-0.75" : "rounded-lg", + !nested && (detailed ? "px-3 py-2" : "flex h-9 flex-col justify-center px-3"), + !nested && (input.isActive || input.isSelected) && "sidebar-row-stroke ring-1 ring-inset", ), - // A tinted row carries its own bar, so the guide stops at its edge and resumes after it. - rootClassName: - input.nested && attention === undefined - ? "relative before:absolute before:inset-y-0 before:left-[1.0625rem] before:w-px before:bg-border" - : undefined, + rootClassName: cn( + nested + ? "sidebar-worker-guide relative ms-3 -mt-px ps-6 [contain-intrinsic-size:auto_--spacing(6)]" + : detailed + ? "[contain-intrinsic-size:auto_--spacing(14)]" + : "[contain-intrinsic-size:auto_--spacing(9)]", + !nested && input.inFolder && "ms-3", + ), + rootAttributes: nested && input.lastNested ? { "data-last-worker": "" } : {}, + surface: nested + ? input.isActive + ? "worker-active" + : input.isSelected + ? "worker-selected" + : undefined + : input.isActive + ? "active" + : input.isSelected + ? "selected" + : input.hasDraftSurface + ? "draft" + : undefined, attention, - labelSuffix: - input.workerCount > 0 ? `, ${workerLabel(input.workerCount, input.workerNeedsYou)}` : "", - decor: ( + labelSuffix: input.workerSummary !== null ? `, ${input.workerSummary}` : "", + body: ( <> - {detailed && working ? ( + {working && !nested ? ( ) : null} - {mark !== null || input.providerIcon !== null ? ( - - - {mark ?? ( - {input.providerIcon?.("alone")} - )} - - {mark !== null && input.providerIcon !== null ? ( - - {input.providerIcon("badge")} - - ) : null} - + {lineOne} + {lineTwo} + {nested && asksYou ? ( + ) : null} ), - title: - !detailed && working && !input.isActive ? ( - - {input.title} - - - {thread.title} - - - - ) : ( - input.title - ), - beforeSlot: - input.workerCount > 0 ? ( - - - {input.workerCount} - - ) : null, - slot, - keepsSlot: answersInPopover, - answers: detailed && asksYou ? : null, }; } From aef73c3f799810352f7bb9fab8fc6685ae438e18 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 16:35:16 +0100 Subject: [PATCH 19/42] fix(web): Settle stays reachable beside answers on Detailed rows, and long answers fit The status slot sat in the part of line 2 that hides while a row shows answers, so an approval or simple question lost hover and keyboard Settle. It now stays on the line, with the time. A long option label kept its button at 176px in a 135px line at the 208px sidebar, which pushed it past the row's left edge. Buttons now shrink to the width they have, and the label truncates. --- .../sidebar/variants/RowAnswers.tsx | 2 +- .../sidebar/variants/sidebarVariants.css | 4 +++- .../variantRow.regressions.browser.test.tsx | 22 +++++++++++++++++++ .../sidebar/variants/variantRow.tsx | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx index d18c0243f1455..41d61ba9e5f70 100644 --- a/apps/web/src/components/sidebar/variants/RowAnswers.tsx +++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx @@ -61,7 +61,7 @@ function AnswerBody(props: { {props.note}

    ) : null} -
    {props.children}
    +
    {props.children}
    {props.error !== null ? (

    * { + & [data-line2-rest] > *, + & [data-status-slot] { margin-block: -0.5rem; } @@ -304,6 +305,7 @@ border-radius: 9999px; font-size: 0.6875rem; font-weight: 600; + max-width: 100%; } [data-row-answers] button[aria-label="More approval options"] { diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx index 6babd5a0eeaf0..bee57b4fdc48b 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -2,6 +2,8 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/model import type { ReactNode } from "react"; import { expect, it, vi } from "vite-plus/test"; +import "~/index.css"; + import { render } from "../../../../test/render"; import { installSidebarTree } from "../../../lib/sidebarTree"; @@ -64,6 +66,26 @@ it("keeps the terminal control on a worker's line", async () => { expect(button.closest("[data-row-line1]")).not.toBeNull(); }); +it("keeps Settle reachable on a Detailed row showing answers", async () => { + await using screen = await render( +

    + {row({ + thread: { ...thread, hasPendingApprovals: true }, + statusSlot: (content: ReactNode) => ( + + {content} +
    , + ); + const settle = screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement; + expect(settle.getClientRects().length).toBeGreaterThan(0); + settle.focus(); + expect(document.activeElement).toBe(settle); +}); + it("keeps a parent's terminal control beside its answers", async () => { await using screen = await render(
    diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index 9ffc2fa6d12b7..650c526e785c7 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -266,8 +266,8 @@ export function resolveVariantRow(input: { {input.terminal} {input.lineExtras} - {input.statusSlot(input.time, false)} + {input.statusSlot(input.time, false)}
    ) : null; From 402f8426ba25d5618eed3914b33c097aff813e44 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 17:24:44 +0100 Subject: [PATCH 20/42] fix(web): number keys act only outside the answer box Text typed into the box now always stays text. While a question waits and the user is not typing, the composer leaves focus free on load and when the question arrives, so the number keys work straight away. Other keys still start an answer through the composer's type-to-focus, which now leaves a digit alone when that digit picks an option. Dictation stops the shortcuts. --- apps/web/src/components/ChatView.tsx | 17 +++- apps/web/src/components/chat/ChatComposer.tsx | 35 ++++++++ ...endingUserInputPanel.keys.browser.test.tsx | 84 +++++++++++++++++++ .../chat/ComposerPendingUserInputPanel.tsx | 56 ++++++++++--- .../chat/useChatShortcuts.browser.test.tsx | 12 ++- .../src/components/chat/useChatShortcuts.ts | 1 + 6 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.keys.browser.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 101e47a617c28..21562c78d6d4e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1819,6 +1819,15 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.focusAtEnd(); }; const focusComposerStable = useStableCallback(focusComposer); + // why: the number keys pick a question's option only while focus is outside the answer box. + const focusComposerUnlessQuestionWaits = useStableCallback(() => { + if ( + !activePendingProgress?.activeQuestion || + activePendingUserInput?.responseCapability === "not_resumable" + ) { + focusComposer(); + } + }); const canInterruptRunningThread = canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); const onInterrupt = async () => { @@ -2137,12 +2146,12 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { - focusComposerStable(); + focusComposerUnlessQuestionWaits(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, embedded, focusComposerStable, terminalUiState.terminalOpen]); + }, [activeThread?.id, embedded, focusComposerUnlessQuestionWaits, terminalUiState.terminalOpen]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { @@ -2159,7 +2168,7 @@ export default function ChatView(props: ChatViewProps) { !isInSideChatPane(document.activeElement) && shouldRefocusComposerOnWindowFocus(document.activeElement) ) { - focusComposerStable(); + focusComposerUnlessQuestionWaits(); } }); }); @@ -2172,7 +2181,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread?.id, embedded, - focusComposerStable, + focusComposerUnlessQuestionWaits, isMobileViewport, terminalUiState.terminalOpen, ]); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5c2b9b647c05d..f530f9e815df9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -295,6 +295,8 @@ function consumePasteAsTextShortcut(deadline: { current: number }): boolean { export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; + /** Whether an open question's number shortcut will pick an option with this key. */ + questionTakesKey: (key: string) => boolean; addDroppedFiles: (files: File[]) => void; addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; @@ -1083,6 +1085,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); const backgroundThread = use(BackgroundThreadContext); + const answerableQuestionId = + !backgroundThread && pendingUserInputs[0]?.responseCapability !== "not_resumable" + ? (activePendingProgress?.activeQuestion?.id ?? null) + : null; + const hadAnswerableQuestionRef = useRef(false); + const questionOptionKeyRef = useRef<((key: string) => boolean) | null>(null); + const syncFocusWithQuestion = useEffectEvent(() => { + const active = document.activeElement; + if (answerableQuestionId === null) { + if (!isMobileViewport && (active === null || active === document.body)) { + composerEditorRef.current?.focusAtEnd(); + } + return; + } + const typing = prompt.trim() !== "" || activePendingProgress?.customAnswer.trim() !== ""; + if ( + !typing && + active instanceof HTMLElement && + active.isContentEditable && + composerFormRef.current?.contains(active) + ) { + active.blur(); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves DOM focus when a question opens or closes, so the number keys work and typing can resume + useEffect(() => { + const hadQuestion = hadAnswerableQuestionRef.current; + hadAnswerableQuestionRef.current = answerableQuestionId !== null; + if (hadQuestion || answerableQuestionId !== null) syncFocusWithQuestion(); + }, [answerableQuestionId]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to keydown, window blur and the desktop paste-as-text event to arm plain-text paste useEffect(() => { // A background composer listens on its own form, so it never hears the main composer's keys. @@ -2945,6 +2977,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + questionTakesKey: (key: string) => questionOptionKeyRef.current?.(key) ?? false, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files).then((inserted) => { if (!inserted) focusComposer(); @@ -3323,6 +3356,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} onDismiss={onDismissActivePendingUserInput} + voiceInputActive={voiceInputActive} + optionKeyRef={questionOptionKeyRef} /> ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? ( ({ + default: (({ text }: { text: string }) =>

    {text}

    ) as never, +})); + +import { render } from "../../../test/render"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +const renderPanel = async (voiceInputActive = false) => { + const onToggleOption = vi.fn(); + const optionKeyRef: { current: ((key: string) => boolean) | null } = { current: null }; + const screen = await render( +
    +
    + {}} + onDismiss={() => {}} + voiceInputActive={voiceInputActive} + optionKeyRef={optionKeyRef} + /> +
    , + ); + return { screen, onToggleOption, optionKeyRef }; +}; + +it("picks an option with a number key while focus is outside the box", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + await userEvent.keyboard("2"); + expect(onToggleOption).toHaveBeenCalledWith("format", "JSON"); +}); + +it("leaves digits to an empty answer box", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + (screen.getByTestId("box").element() as HTMLElement).focus(); + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); +}); + +it("claims only the digits that have an option, so the rest stay text", async () => { + const { screen, optionKeyRef } = await renderPanel(); + await using _ = screen; + expect(optionKeyRef.current?.("2")).toBe(true); + expect(optionKeyRef.current?.("7")).toBe(false); + expect(optionKeyRef.current?.("a")).toBe(false); +}); + +it("takes no keys while dictation runs", async () => { + const { screen, onToggleOption, optionKeyRef } = await renderPanel(true); + await using _ = screen; + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); + expect(optionKeyRef.current).toBeNull(); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index a18c3d44158ca..2a0a44fa10e55 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,6 +1,6 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; import { markdownProseDirection, textDirection } from "~/textDirection"; -import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; +import { memo, use, useEffect, useEffectEvent, useRef, useState, type RefObject } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; @@ -25,6 +25,10 @@ interface PendingUserInputPanelProps { onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: RuntimeRequestId) => void; + /** Dictation is running, so keys belong to it and pick nothing. */ + voiceInputActive?: boolean; + /** Set while the shortcuts listen; says whether a key picks an option, so it is not typed. */ + optionKeyRef?: RefObject<((key: string) => boolean) | null>; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -38,6 +42,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn onToggleOption, onAdvance, onDismiss, + voiceInputActive = false, + optionKeyRef, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -56,6 +62,8 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn onToggleOption={onToggleOption} onAdvance={onAdvance} onDismiss={onDismiss} + voiceInputActive={voiceInputActive} + optionKeyRef={optionKeyRef} /> ); }); @@ -71,6 +79,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onToggleOption, onAdvance, onDismiss, + voiceInputActive, + optionKeyRef, }: { prompt: PendingUserInput; cwd: string | undefined; @@ -82,6 +92,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: RuntimeRequestId) => void; + voiceInputActive: boolean; + optionKeyRef: RefObject<((key: string) => boolean) | null> | undefined; }) { // Message-mode requests remain answerable after their provider turn ends. const canRespond = prompt.responseCapability !== "not_resumable"; @@ -149,7 +161,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for digit shortcuts while a question is answerable useEffect(() => { - if (!activeQuestion || responseDisabled || isCollapsed || backgroundThread) return; + if ( + !activeQuestion || + responseDisabled || + isCollapsed || + backgroundThread || + voiceInputActive + ) { + return; + } + const optionForKey = (key: string) => + /^[1-9]$/.test(key) ? activeQuestion.options[Number(key) - 1] : undefined; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -157,23 +179,31 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } - if (target instanceof HTMLElement) { - // The composer takes focus on load, so an empty one must still answer to the shortcuts. - const editor = target.closest('[contenteditable]:not([contenteditable="false"])'); - if (editor?.textContent?.trim()) return; + if ( + target instanceof HTMLElement && + target.closest('[contenteditable]:not([contenteditable="false"])') + ) { + return; } - const digit = Number.parseInt(event.key, 10); - if (Number.isNaN(digit) || digit < 1 || digit > 9) return; - const optionIndex = digit - 1; - if (optionIndex >= activeQuestion.options.length) return; - const option = activeQuestion.options[optionIndex]; + const option = optionForKey(event.key); if (!option) return; event.preventDefault(); selectOptionFromShortcut(activeQuestion.id, option.value ?? option.label); }; + if (optionKeyRef) optionKeyRef.current = (key) => optionForKey(key) !== undefined; document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, backgroundThread, isCollapsed, responseDisabled]); + return () => { + if (optionKeyRef) optionKeyRef.current = null; + document.removeEventListener("keydown", handler); + }; + }, [ + activeQuestion, + backgroundThread, + isCollapsed, + optionKeyRef, + responseDisabled, + voiceInputActive, + ]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx index d04247d279792..cd51bd99ac742 100644 --- a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx +++ b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx @@ -54,9 +54,10 @@ function Chat(props: { composerKeys: string[]; launcher?: { keys: string; receiv scriptKeybindings: [], getShortcutContext: () => shortcutContext, threadFindActive: false, - // Type-to-focus only calls insertTextAtEnd, so the stand-in composer implements just that. + // Type-to-focus only calls these two, so the stand-in composer implements just them. composerRef: { current: { + questionTakesKey: (key: string) => key === "2", insertTextAtEnd: (key: string) => { props.composerKeys.push(key); return true; @@ -86,6 +87,15 @@ it("leaves the launcher's letters to the launcher, in either case", async () => expect(composerKeys).toEqual([]); }); +it("leaves a digit that picks a question's option out of the composer", async () => { + const composerKeys: string[] = []; + await using screen = await render(, options); + expect(screen.container).toBeDefined(); + await userEvent.keyboard("2"); + await userEvent.keyboard("7"); + expect(composerKeys).toEqual(["7"]); +}); + it("still types other letters into the composer while the launcher is open", async () => { const composerKeys: string[] = []; const received: string[] = []; diff --git a/apps/web/src/components/chat/useChatShortcuts.ts b/apps/web/src/components/chat/useChatShortcuts.ts index 637546f65d2bc..8ef1a70dc49cf 100644 --- a/apps/web/src/components/chat/useChatShortcuts.ts +++ b/apps/web/src/components/chat/useChatShortcuts.ts @@ -73,6 +73,7 @@ export function useChatShortcuts(input: ChatShortcutsInput) { !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && shouldTypeToFocusComposer(event) && + !input.composerRef.current?.questionTakesKey(event.key) && input.composerRef.current?.insertTextAtEnd(event.key) === true ) { event.preventDefault(); From 7249e5c993855af4817535ffe1ca9912d7baeee6 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 17:27:03 +0100 Subject: [PATCH 21/42] fix(web): Slim counts workers with a static robot, not a chevron The chevron looked clickable and folded nothing, because Slim has no worker rows. The count now has an accessible name. --- .../variants/variantRow.regressions.browser.test.tsx | 11 +++++++++++ .../src/components/sidebar/variants/variantRow.tsx | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx index bee57b4fdc48b..8a46e531cb126 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -86,6 +86,17 @@ it("keeps Settle reachable on a Detailed row showing answers", async () => { expect(document.activeElement).toBe(settle); }); +it("counts Slim's workers with a static robot and a name", async () => { + await using screen = await render( +
    {row({ style: "folder-slim", workerCount: 2, workerSummary: "2 workers · 1 working" })}
    , + ); + const count = screen.getByRole("img", { name: "2 workers · 1 working" }).element(); + expect(count.textContent).toBe("2"); + expect(count.querySelector(".lucide-bot")).not.toBeNull(); + expect(count.closest("button")).toBeNull(); + expect(getComputedStyle(count).cursor).not.toBe("pointer"); +}); + it("keeps a parent's terminal control beside its answers", async () => { await using screen = await render(
    diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index 650c526e785c7..d91fdef6d6793 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -1,5 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; -import { ChevronRightIcon } from "lucide-react"; +import { BotIcon, ChevronRightIcon } from "lucide-react"; import type { MouseEvent, ReactNode } from "react"; import { cn } from "~/lib/utils"; @@ -212,11 +212,12 @@ export function resolveVariantRow(input: { <> {input.workerCount !== null ? ( - + {input.workerCount} ) : null} From ddde15ef3b16b898dea27eaa7cbbfbf7683803a6 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 17:33:37 +0100 Subject: [PATCH 22/42] fix(web): Detailed answers that do not fit take their own line At the 208px sidebar long answer buttons shrank to one letter. Answers that do not fit line 2 now take a full line under it, and only the row showing them grows. The fold chip, terminal, time and Settle stay on line 2. The answer pills' hit area was 22px, not 24px, because the inset ignored the 1px border. It is 24px now, and Settle's slot no longer takes the top of a button below it. --- .../variants/RowAnswers.browser.test.tsx | 88 +++++++++++++++++++ .../sidebar/variants/RowAnswers.tsx | 5 +- .../sidebar/variants/sidebarVariants.css | 35 +++++++- .../sidebar/variants/variantRow.tsx | 6 +- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx index 7d12df7ab93ce..98159386e161f 100644 --- a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx @@ -5,6 +5,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; +import "~/index.css"; + import { render } from "../../../../test/render"; const state = vi.hoisted(() => ({ @@ -24,6 +26,7 @@ vi.mock("../../../state/threads", () => ({ })); import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers"; +import { resolveVariantRow } from "./variantRow"; const environmentId = EnvironmentId.make("environment-a"); const threadId = ThreadId.make("thread-a"); @@ -129,3 +132,88 @@ it("offers the thread from the popover when the row cannot answer", async () => await screen.getByRole("button", { name: "Open thread" }).click(); expect(onOpenThread).toHaveBeenCalledOnce(); }); + +const rowShowingAnswers = (lineWidth: number) => ( +
    + { + resolveVariantRow({ + style: "folder-detailed", + threadKey: "environment-a:thread-a", + thread: { ...thread, title: "A thread", hasPendingUserInput: true }, + status: "ready", + statusWord: null, + wokePill: null, + time: "1m", + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + hasDraftSurface: false, + nested: false, + lastNested: false, + inFolder: true, + repo: null, + mark: null, + title: Title, + draft: null, + pin: null, + where: branch, + lineExtras: null, + remote: null, + terminal:
    +); + +const longLabels = question({ + options: [{ label: "Round every line on its own and accept drift" }, { label: "Per total" }], +}); + +it("gives answers that do not fit a full line under the chip, terminal and Settle", async () => { + setup({ userInputs: [longLabels] }); + await using screen = await render(rowShowingAnswers(135)); + const line = screen.container.querySelector("[data-row-line2]")!.getBoundingClientRect(); + const answers = screen.container.querySelector("[data-row-answers]")!.getBoundingClientRect(); + expect(answers.width).toBeCloseTo(line.width, 0); + for (const name of ["Open terminal", "Settle thread"]) { + const control = screen.getByRole("button", { name }).element().getBoundingClientRect(); + expect(control.bottom).toBeLessThanOrEqual(answers.top); + } + const chip = screen.container.querySelector("[data-worker-fold]")!.getBoundingClientRect(); + expect(chip.bottom).toBeLessThanOrEqual(answers.top); + const option = screen.getByRole("button", { name: /Round every line/ }).element(); + const box = option.getBoundingClientRect(); + expect(box.width).toBeGreaterThan(100); + expect(box.right).toBeLessThanOrEqual(line.right + 0.5); + expect(box.left).toBeGreaterThanOrEqual(line.left - 0.5); + const middle = box.top + box.height / 2; + for (const offset of [-11.5, 11.5]) { + const hit = document.elementFromPoint(box.left + box.width / 2, middle + offset); + expect(option.contains(hit)).toBe(true); + } +}); + +it("keeps answers on line 2 when they fit", async () => { + setup({ userInputs: [question()] }); + await using screen = await render(rowShowingAnswers(400)); + const answers = screen.container.querySelector("[data-row-answers]")!.getBoundingClientRect(); + const settle = screen.getByRole("button", { name: "Settle thread" }).element(); + expect(settle.getBoundingClientRect().left).toBeGreaterThanOrEqual(answers.right); + const chip = screen.container.querySelector("[data-worker-fold]")!.getBoundingClientRect(); + expect(Math.abs(chip.top + chip.height / 2 - (answers.top + answers.height / 2))).toBeLessThan(2); +}); diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx index 41d61ba9e5f70..8d739dd9bdf63 100644 --- a/apps/web/src/components/sidebar/variants/RowAnswers.tsx +++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx @@ -35,6 +35,7 @@ function AnswerBody(props: { ) : null} -
    {props.children}
    +
    + {props.children} +
    {props.error !== null ? (

    [data-status-slot] { + order: -1; + } + + &:has([data-row-answers]:not(:empty)) > [data-worker-fold], + &:has([data-row-answers]:not(:empty)) > [data-worker-chip] { + order: 1; + } + + /* Settle's slot overhangs the line, and must not take the top of the button below it. */ + & > [data-row-answers] { + position: relative; + z-index: 1; + order: 2; + flex: 1 1 auto; + max-width: 100%; + } } /* The tree line from a row's icon down its workers, with an elbow into the last one. */ @@ -298,9 +322,16 @@ } } +/* Only the buttons decide whether the answers fit, and a long detail gets a line of its own. */ +[data-answer-detail] { + flex-basis: 1rem; + contain: inline-size; +} + [data-row-answers] button { height: 1.125rem; min-height: 0; + min-width: 1.5rem; padding-inline: 0.4375rem; border-radius: 9999px; font-size: 0.6875rem; @@ -313,11 +344,11 @@ padding-inline: 0; } -/* The pills are 18px tall, and this keeps their hit area at 24px. */ +/* The pills are 18px tall with a 1px border, and this keeps their hit area at 24px. */ [data-row-answers] button::after { content: ""; position: absolute; - inset: -3px 0; + inset: -4px 0; } /* Keeps a focused or revealed row clear of the sticky Waiting on you list. */ diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index d91fdef6d6793..48697e4335733 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -235,7 +235,11 @@ export function resolveVariantRow(input: { const chip = input.workerCount === null ? null : input.workersOpen === null ? ( - + {input.workerSummary} ) : ( From 5b4fb3f521a50e3249e7305e5a44991ae12e192d Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 17:35:30 +0100 Subject: [PATCH 23/42] feat(web): a setting for the terminal's actions strip The strip costs every user about two terminal rows, so it is a choice now, off by default. Off, the terminal is main's. --- .../ThreadTerminalDrawer.browser.test.tsx | 15 +++++++++- .../src/components/ThreadTerminalDrawer.tsx | 3 +- .../components/settings/SettingsPanels.tsx | 29 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++++ packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 2 ++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..cd1c3442f2c8d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -14,6 +14,7 @@ import { act, fakeTimers, render } from "../../test/render"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; const fixture = vi.hoisted(() => ({ + terminalActionsStrip: false, statusByThreadId: new Map(), pendingSurfaces: [] as Array<() => void>, surfaceOptions: [] as GhosttyTerminalSurfaceOptions[], @@ -102,7 +103,7 @@ vi.mock("../hooks/useSettings", async () => { const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings"); return { useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => T) => - select(DEFAULT_CLIENT_SETTINGS), + select({ ...DEFAULT_CLIENT_SETTINGS, terminalActionsStrip: fixture.terminalActionsStrip }), }; }); @@ -248,3 +249,15 @@ it("only activates terminal paths confirmed to exist on their environment", asyn await hover("~/notes.ts:5:2"); expect(canActivate("~/notes.ts:5:2")).toBe(true); }); + +it("reserves the actions strip only when the setting is on", async () => { + const { other: threadRef } = setup(); + fixture.terminalActionsStrip = false; + await using off = await render( {}} />); + expect(off.container.querySelector(".pt-8")).toBeNull(); + await off.unmount(); + fixture.terminalActionsStrip = true; + await using on = await render( {}} />); + expect(on.container.querySelector(".pt-8")).not.toBeNull(); + fixture.terminalActionsStrip = false; +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ae8018c5d6651..cf98a09bf7b10 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1650,6 +1650,7 @@ export default function ThreadTerminalDrawer({ const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const terminalActionsStrip = useClientSettings((settings) => settings.terminalActionsStrip); const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); @@ -1904,7 +1905,7 @@ export default function ThreadTerminalDrawer({

    void) { ...(settings.chatWorkingShimmer !== DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer ? ["Working shimmer"] : []), + ...(settings.terminalActionsStrip !== DEFAULT_UNIFIED_SETTINGS.terminalActionsStrip + ? ["Terminal actions strip"] + : []), ...(settings.sidebarHideLoneProvider !== DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider ? ["Hide lone provider logo"] : []), @@ -664,6 +667,7 @@ export function useSettingsRestore(onRestored?: () => void) { windowTitleWaitingCount: DEFAULT_UNIFIED_SETTINGS.windowTitleWaitingCount, sidebarHideLoneProvider: DEFAULT_UNIFIED_SETTINGS.sidebarHideLoneProvider, chatWorkingShimmer: DEFAULT_UNIFIED_SETTINGS.chatWorkingShimmer, + terminalActionsStrip: DEFAULT_UNIFIED_SETTINGS.terminalActionsStrip, composerQuietStopWhileWaiting: DEFAULT_UNIFIED_SETTINGS.composerQuietStopWhileWaiting, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, @@ -1178,6 +1182,31 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ + terminalActionsStrip: DEFAULT_UNIFIED_SETTINGS.terminalActionsStrip, + }) + } + /> + ) : null + } + control={ + + updateSettings({ terminalActionsStrip: Boolean(checked) }) + } + aria-label="Terminal actions strip" + /> + } + /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aa6222fdc0f26..d9ae4ed111840 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -664,6 +664,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["notification toast popup completion input approval failure"], }, + { + id: "terminal-actions-strip", + title: "Terminal actions strip", + to: "/settings/general", + searchTerms: ["terminal actions buttons strip overlap output first line"], + }, { id: "chat-working-shimmer", title: "Working shimmer", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 5ddb59b0b8146..5591a347f7498 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -66,6 +66,7 @@ describe("opt-in display settings", () => { expect(decoded.windowTitleWaitingCount).toBe(false); expect(decoded.sidebarHideLoneProvider).toBe(false); expect(decoded.chatWorkingShimmer).toBe(false); + expect(decoded.terminalActionsStrip).toBe(false); expect(decoded.composerQuietStopWhileWaiting).toBe(false); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2570cc4ca49d3..47a76eb43bab3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -495,6 +495,7 @@ export const ClientSettingsSchema = Schema.Struct({ windowTitleWaitingCount: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarHideLoneProvider: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), chatWorkingShimmer: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + terminalActionsStrip: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), composerQuietStopWhileWaiting: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1889,6 +1890,7 @@ export const ClientSettingsPatch = Schema.Struct({ windowTitleWaitingCount: Schema.optionalKey(Schema.Boolean), sidebarHideLoneProvider: Schema.optionalKey(Schema.Boolean), chatWorkingShimmer: Schema.optionalKey(Schema.Boolean), + terminalActionsStrip: Schema.optionalKey(Schema.Boolean), composerQuietStopWhileWaiting: Schema.optionalKey(Schema.Boolean), sendShortcut: Schema.optionalKey(Schema.Literals(["enter", "mod-enter-multiline", "mod-enter"])), followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), From 6abd78a5b2584f1e54c611494f138104fa1bc7c1 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:03:09 +0100 Subject: [PATCH 24/42] fix(web): Detailed and Slim pills and dots reach their contrast targets Pill text fell to 3.67:1 and the working dot to 2.12:1 at its dimmest on some themes. Foreground at 50% opacity is itself under 3:1 on several themes, so mixing alone could not fix the dot, and its pulse now bottoms out at 0.6. The input pill's dark: colour was also outranking the token. These mixes are the mildest found that pass on all 12 themes. --- .../sidebar/variants/sidebarVariants.css | 32 +++++++++++-------- .../sidebar/variants/variantRow.tsx | 2 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index d62c34f257b7d..71be0ab497391 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -13,31 +13,32 @@ 100% { opacity: 1; } + /* Full foreground at 50% is under 3:1 on some themes, so this is the dimmest that passes. */ 50% { - opacity: 0.5; + opacity: 0.6; } } -/* Some built-in themes put these tokens under 4.5:1 at 12px; mixing in foreground lifts them. */ +/* These are the mildest mixes that keep pill text at 4.5:1 on all 12 built-in themes. */ @utility sidebar-variant-tokens { --contrast-secondary-label: color-mix(in srgb, var(--foreground) 72%, transparent); --row-muted: color-mix(in srgb, var(--foreground) 80%, transparent); - --variant-warning: color-mix(in srgb, var(--warning-foreground) 65%, var(--foreground)); - --variant-success: color-mix(in srgb, var(--success-foreground) 65%, var(--foreground)); - --variant-info: color-mix(in srgb, var(--info-foreground) 65%, var(--foreground)); - --variant-error: color-mix(in srgb, var(--error-foreground) 65%, var(--foreground)); - --variant-guest: color-mix(in srgb, var(--guest-foreground) 65%, var(--foreground)); - --variant-input: color-mix(in srgb, var(--color-indigo-600) 65%, var(--foreground)); + --variant-warning: color-mix(in srgb, var(--warning-foreground) 40%, var(--foreground)); + --variant-success: color-mix(in srgb, var(--success-foreground) 40%, var(--foreground)); + --variant-info: color-mix(in srgb, var(--info-foreground) 40%, var(--foreground)); + --variant-error: color-mix(in srgb, var(--error-foreground) 40%, var(--foreground)); + --variant-guest: color-mix(in srgb, var(--guest-foreground) 40%, var(--foreground)); + --variant-input: color-mix(in srgb, var(--color-indigo-600) 40%, var(--foreground)); .dark & { - --variant-input: color-mix(in srgb, var(--color-indigo-300) 65%, var(--foreground)); + --variant-input: color-mix(in srgb, var(--color-indigo-300) 40%, var(--foreground)); } &[aria-current="page"] { - --variant-warning: color-mix(in srgb, var(--warning-foreground) 50%, var(--foreground)); - --variant-success: color-mix(in srgb, var(--success-foreground) 50%, var(--foreground)); - --variant-info: color-mix(in srgb, var(--info-foreground) 50%, var(--foreground)); - --variant-error: color-mix(in srgb, var(--error-foreground) 50%, var(--foreground)); + --variant-warning: color-mix(in srgb, var(--warning-foreground) 30%, var(--foreground)); + --variant-success: color-mix(in srgb, var(--success-foreground) 30%, var(--foreground)); + --variant-info: color-mix(in srgb, var(--info-foreground) 30%, var(--foreground)); + --variant-error: color-mix(in srgb, var(--error-foreground) 30%, var(--foreground)); } & .text-warning-foreground { @@ -62,6 +63,10 @@ & .sidebar-input-mark { color: var(--variant-input); } + /* The pill's dark: colour would otherwise outrank the token. */ + && [class*="dark:text-indigo-300"] { + color: var(--variant-input); + } & .bg-warning-foreground { background-color: var(--variant-warning); } @@ -124,6 +129,7 @@ } @utility sidebar-status-dot { + background-color: color-mix(in srgb, currentColor 30%, var(--foreground)); animation: sidebar-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation-play-state: var(--visible-animation-state, paused); will-change: var(--visible-animation-will-change, auto); diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index 48697e4335733..df7924c3d3fac 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -177,7 +177,7 @@ export function resolveVariantRow(input: { aria-hidden data-row-pill-dot ref={observeVisibleAnimation} - className="sidebar-status-dot block size-1.5 shrink-0 rounded-full bg-current" + className="sidebar-status-dot block size-1.5 shrink-0 rounded-full" /> ) : null} {statusWord.label} From db418a9a64f4951d22c9d065109b2536862c3021 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:03:09 +0100 Subject: [PATCH 25/42] fix(web): commit 6's surfaces use T3's full muted tokens At /75 the palette, keybindings and diagnostics stayed under 4.5:1 on most themes. At full strength they pass on all 12. Working rows use the full token too, for their project and branch labels as well. Some themes' own tokens still fall short on those rows, and no theme file changes here. --- .../src/components/CommandPaletteResults.tsx | 16 +++----- .../src/components/ThreadCommandSubtitle.tsx | 4 +- .../settings/DiagnosticsSettings.tsx | 14 +++---- .../settings/KeybindingsSettings.tsx | 4 +- .../settings/ResourceTelemetryDiagnostics.tsx | 38 +++++++++---------- .../components/sidebar/SidebarThreadRow.tsx | 10 ++++- .../variantRow.regressions.browser.test.tsx | 4 +- 7 files changed, 47 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 17cf91d85a036..ee2a77a2ab422 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -181,9 +181,7 @@ function ThreadRowContext({ .filter((part) => part && part !== item.matchHint?.text) .join(" · "); return context ? ( - - {context} - + {context} ) : null; } @@ -205,9 +203,7 @@ function DisabledCommandPaletteResultRow(props: { ) : props.item.matchHint ? ( ) : props.item.description ? ( - - {props.item.description} - + {props.item.description} ) : null} ) : ( @@ -257,9 +253,7 @@ function CommandPaletteResultRow(props: { ) : props.item.matchHint ? ( ) : props.item.description ? ( - - {props.item.description} - + {props.item.description} ) : null} ) : ( @@ -278,13 +272,13 @@ function CommandPaletteResultRow(props: { /> ) : null} {props.item.timestamp ? ( - + {props.item.timestamp} ) : null} {shortcutLabel ? {shortcutLabel} : null} {props.item.kind === "submenu" ? ( - + ) : null} ); diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index da07d8665f50b..14633062a7b4f 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -13,7 +13,7 @@ export type ThreadCommandSubtitleVariant = const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = "favicon-workspace-harness"; -export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/75"; +export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground"; export function CommandPaletteMetaDot() { return ·; @@ -58,7 +58,7 @@ export function ThreadCommandSubtitle(props: { return ( diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 05ab7a720db83..81c989ef42c1d 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -80,7 +80,7 @@ function StatBlock({ }) { return (
    -
    +
    {label} {tooltip ? ( @@ -88,7 +88,7 @@ function StatBlock({ render={
    , + ); + const time = screen.getByTestId("time").element(); + expect(getComputedStyle(time).opacity).toBe("1"); + (screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement).focus(); + expect(getComputedStyle(time).opacity).toBe("0"); +}); + it("counts Slim's workers with a static robot and a name", async () => { await using screen = await render(
    From 478b41fed5498eb28adc245edb0205c565792dec Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:14:36 +0100 Subject: [PATCH 27/42] fix(web): Detailed and Slim rows keep their marks in forced colors Forced colors drop fills and shadows, which took the dot, the attention bar and the active stroke with them. --- .../sidebar/variants/sidebarVariants.css | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index 6d70d171a8c39..b9f62f1e21297 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -130,6 +130,11 @@ @utility sidebar-status-dot { background-color: color-mix(in srgb, currentColor 30%, var(--foreground)); + + @media (forced-colors: active) { + background-color: CanvasText; + forced-color-adjust: none; + } animation: sidebar-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation-play-state: var(--visible-animation-state, paused); will-change: var(--visible-animation-will-change, auto); @@ -238,6 +243,20 @@ & [data-status-slot]:has(:focus-visible) > span:first-child { opacity: 0; } + + /* Forced colors drop fills and shadows, so the bar and the stroke need system colours. */ + @media (forced-colors: active) { + &[data-attention]::after { + background: CanvasText; + forced-color-adjust: none; + } + + &[data-surface="active"], + &[data-surface="selected"] { + outline: 1px solid Highlight; + outline-offset: -1px; + } + } } /* An opaque copy of the row's own layers, so the mark reads the same on every row state. */ From d576ca05a6cd812412de9d5f1cedb7ff9fcc629a Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:14:36 +0100 Subject: [PATCH 28/42] fix(web): row animations stop while the sidebar is collapsed A collapsed sidebar's rows skip rendering, so the visibility observer never reports them gone, and their sweeps and dots kept running. --- .../src/components/sidebar/variants/sidebarVariants.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index b9f62f1e21297..8c6deb2067fa4 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -123,6 +123,11 @@ will-change: var(--visible-animation-will-change, auto); } + /* A collapsed sidebar's rows skip rendering, so their visibility observer never reports them. */ + [data-slot="sidebar"][data-state="collapsed"] &::before { + animation: none; + } + @media (prefers-reduced-motion: reduce), (forced-colors: active) { display: none; } @@ -131,6 +136,10 @@ @utility sidebar-status-dot { background-color: color-mix(in srgb, currentColor 30%, var(--foreground)); + [data-slot="sidebar"][data-state="collapsed"] & { + animation: none; + } + @media (forced-colors: active) { background-color: CanvasText; forced-color-adjust: none; From 487cb2c3d26b12f7fa4a35aa950f3f74f14843ed Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:14:36 +0100 Subject: [PATCH 29/42] test(web): fold chip closing and focus recovery --- .../variantRow.regressions.browser.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx index 82de6c7e53a0b..87d72887f24bf 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -107,6 +107,54 @@ it("hides the time under Settle while Settle has keyboard focus", async () => { expect(getComputedStyle(time).opacity).toBe("0"); }); +it("closes an expanded fold chip with Left, and keeps focus on it", async () => { + const onToggle = vi.fn(); + await using screen = await render( +
    + +
    , + ); + const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement); + const chip = screen.getByRole("button", { name: "2 workers · 1 working", exact: true }); + const element = chip.element() as HTMLElement; + expect(element.getAttribute("aria-expanded")).toBe("true"); + element.focus(); + element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })); + expect(onToggle).toHaveBeenCalledOnce(); + expect(document.activeElement).toBe(element); + cleanup(); +}); + +it("returns focus to the worker's parent when a focused worker row goes away", async () => { + await using screen = await render( +
    +
    +
    +
    +
    +
    , + ); + const cleanup = installSidebarTree(screen.getByTestId("tree").element() as HTMLElement); + const parent = screen.getByRole("button", { name: "Parent" }).element(); + const worker = screen.getByRole("button", { name: "Worker" }).element() as HTMLElement; + worker.focus(); + worker.remove(); + await vi.waitFor(() => expect(document.activeElement).toBe(parent)); + cleanup(); +}); + it("counts Slim's workers with a static robot and a name", async () => { await using screen = await render(
    From 5646f86f195654299a27c98e4ba1a42a46c07ad0 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:19:18 +0100 Subject: [PATCH 30/42] refactor(web): drop rules and row attributes nothing reads The status bar colours were left from the first Detailed look, and the row attributes served only checks kept outside the repo. --- .../sidebar/variants/sidebarVariants.css | 12 ----------- .../sidebar/variants/variantRow.tsx | 21 ++++++------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index 8c6deb2067fa4..6201e21c4ae11 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -67,18 +67,6 @@ && [class*="dark:text-indigo-300"] { color: var(--variant-input); } - & .bg-warning-foreground { - background-color: var(--variant-warning); - } - & .bg-success-foreground { - background-color: var(--variant-success); - } - & .bg-info-foreground { - background-color: var(--variant-info); - } - & .bg-error-foreground { - background-color: var(--variant-error); - } } /* The sheet paints the root sidebar token, while the docked sidebar rescopes it locally. */ diff --git a/apps/web/src/components/sidebar/variants/variantRow.tsx b/apps/web/src/components/sidebar/variants/variantRow.tsx index df7924c3d3fac..a27404c03498c 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -50,25 +50,18 @@ function RowLead(props: { mark: ((className: string) => ReactNode) | null; }): ReactNode { return ( - + {props.repo !== null ? ( <> - - {props.repo} - + {props.repo} {props.mark ? ( - + {props.mark("size-2")} ) : null} ) : props.mark ? ( - - {props.mark("size-3.5")} - + {props.mark("size-3.5")} ) : null} ); @@ -171,11 +164,10 @@ export function resolveVariantRow(input: { onOpenThread={input.onOpenThread} /> ) : ( - + {working ? ( @@ -188,7 +180,7 @@ export function resolveVariantRow(input: { {input.pin} ) : null; const lead = nested ? ( - + {input.mark?.("size-3")} ) : ( @@ -214,7 +206,6 @@ export function resolveVariantRow(input: { From 53964471fa323a0d9de93fd338d8531fc6ff6edf Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 18:51:33 +0100 Subject: [PATCH 31/42] docs: keep the sidebar layout section to its settings path --- docs/user/thread-sidebar.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 536de60a0a5fc..3d1e4c43c845f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -161,17 +161,9 @@ cannot drag threads, and the Working section is off. Each device keeps its own c ### Choose a sidebar layout (beta) -**Settings → General → Sidebar layout** on web and desktop picks how the sidebar lists threads. It -applies at once, and each device keeps its own choice. - -- **Default** is the sidebar described above, shaped by the Working section and Boring sidebar - settings. -- **Detailed** keeps each thread in its project whatever its state. Pinned threads stay at the top - of their folder, and Snoozed and Settled sit behind a row at the end of it. Workers nest under - the thread that started them. **Waiting on you** lists every thread that needs an answer, and you - can answer on the row without opening the thread. -- **Slim** shows one line per thread in project folders, with workers folded to a count. Click the - status of a thread that asks for something to answer it without opening the thread. +**Settings → General → Sidebar layout** on web and desktop picks Default, Detailed or Slim. Each +device keeps its own choice. Detailed and Slim let you answer a question or approval from its row +without opening the thread. Detailed and Slim always group by project and have no Working section, so those settings are locked while either is on. Your values for them return when you go back to Default. From 0b30bea5bdd3b8cfaf5d0b1d1c8cbe21d4893a47 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 19:18:25 +0100 Subject: [PATCH 32/42] fix(web): Detailed and Slim pills keep their state colour Mixing every state 60 to 70% toward the foreground so the worst theme passed turned Working pale on every theme, away from pitwall's blue. A lightness clamp leaves hue and chroma alone. Grove, Ocean, Ember and Iris dark have lighter surfaces, so they get a higher floor and a dimmer limit of their own. Pitwall's 0.5 dot stays under 3:1 on 8 of the 12 themes. --- .../sidebar/variants/sidebarVariants.css | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index 6201e21c4ae11..ac606805d196e 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -13,32 +13,47 @@ 100% { opacity: 1; } - /* Full foreground at 50% is under 3:1 on some themes, so this is the dimmest that passes. */ + /* Pitwall's 0.5 stays under 3:1 on 8 of the 12 themes at any clamp. */ 50% { - opacity: 0.6; + opacity: var(--row-dot-dim); } } -/* These are the mildest mixes that keep pill text at 4.5:1 on all 12 built-in themes. */ +/* State colours keep their hue and chroma; only lightness is clamped to hold 4.5:1 on these rows. */ @utility sidebar-variant-tokens { --contrast-secondary-label: color-mix(in srgb, var(--foreground) 72%, transparent); --row-muted: color-mix(in srgb, var(--foreground) 80%, transparent); - --variant-warning: color-mix(in srgb, var(--warning-foreground) 40%, var(--foreground)); - --variant-success: color-mix(in srgb, var(--success-foreground) 40%, var(--foreground)); - --variant-info: color-mix(in srgb, var(--info-foreground) 40%, var(--foreground)); - --variant-error: color-mix(in srgb, var(--error-foreground) 40%, var(--foreground)); - --variant-guest: color-mix(in srgb, var(--guest-foreground) 40%, var(--foreground)); - --variant-input: color-mix(in srgb, var(--color-indigo-600) 40%, var(--foreground)); + --row-state-floor: 0.75; + --row-dot-dim: 0.6; + --variant-warning: oklch(from var(--warning-foreground) min(l, 0.38) c h); + --variant-success: oklch(from var(--success-foreground) min(l, 0.38) c h); + --variant-info: oklch(from var(--info-foreground) min(l, 0.38) c h); + --variant-error: oklch(from var(--error-foreground) min(l, 0.38) c h); + --variant-guest: oklch(from var(--guest-foreground) min(l, 0.38) c h); + --variant-input: oklch(from var(--color-indigo-600) min(l, 0.38) c h); .dark & { - --variant-input: color-mix(in srgb, var(--color-indigo-300) 40%, var(--foreground)); + --variant-warning: oklch(from var(--warning-foreground) max(l, var(--row-state-floor)) c h); + --variant-success: oklch(from var(--success-foreground) max(l, var(--row-state-floor)) c h); + --variant-info: oklch(from var(--info-foreground) max(l, var(--row-state-floor)) c h); + --variant-error: oklch(from var(--error-foreground) max(l, var(--row-state-floor)) c h); + --variant-guest: oklch(from var(--guest-foreground) max(l, var(--row-state-floor)) c h); + --variant-input: oklch(from var(--color-indigo-300) max(l, var(--row-state-floor)) c h); } - &[aria-current="page"] { - --variant-warning: color-mix(in srgb, var(--warning-foreground) 30%, var(--foreground)); - --variant-success: color-mix(in srgb, var(--success-foreground) 30%, var(--foreground)); - --variant-info: color-mix(in srgb, var(--info-foreground) 30%, var(--foreground)); - --variant-error: color-mix(in srgb, var(--error-foreground) 30%, var(--foreground)); + /* These dark themes have lighter surfaces, so their colours need a higher floor to pass. */ + .dark[data-theme-id="grove"] & { + --row-state-floor: 0.95; + --row-dot-dim: 0.7; + } + .dark[data-theme-id="ocean"] &, + .dark[data-theme-id="ember"] & { + --row-state-floor: 0.9; + --row-dot-dim: 0.7; + } + .dark[data-theme-id="iris"] & { + --row-state-floor: 0.85; + --row-dot-dim: 0.7; } & .text-warning-foreground { @@ -122,7 +137,7 @@ } @utility sidebar-status-dot { - background-color: color-mix(in srgb, currentColor 30%, var(--foreground)); + background-color: currentColor; [data-slot="sidebar"][data-state="collapsed"] & { animation: none; From 87fa7406ffa7a0579cf7be4e959bc69e0cbfe2f9 Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 20:02:26 +0100 Subject: [PATCH 33/42] fix(web): activating a thread with a question lets go of the row The composer no longer takes focus while a question waits, so focus stayed on the row just activated. Type-to-focus refuses keys from a row and the option keys did not, so "Use 2 fields" lost its letters and the 2 picked an option, which can submit a one-question request. Activating such a thread now takes focus off the row, and option keys leave focused controls, secret requests and open layers alone, except inside the question panel. --- apps/web/src/components/Sidebar.tsx | 6 +- ...endingUserInputPanel.keys.browser.test.tsx | 67 +++++++++++++++++++ .../chat/ComposerPendingUserInputPanel.tsx | 5 ++ .../components/chat/composerFocusRedirect.ts | 9 +++ apps/web/src/lib/sidebarTree.browser.test.ts | 21 +++++- apps/web/src/lib/sidebarTree.ts | 14 ++++ 6 files changed, 120 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a7f203baf860a..5e809948bc76f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { attachSidebarTree } from "../lib/sidebarTree"; +import { attachSidebarTree, releaseSidebarRowFocus } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; @@ -1479,6 +1479,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (isMobile) { setOpenMobile(false); } + // why: a question's number keys and type-to-focus both refuse keys aimed at a focused row. + if (threadByKeyRef.current.get(scopedThreadKey(threadRef))?.hasPendingUserInput) { + releaseSidebarRowFocus(); + } return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.keys.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.keys.browser.test.tsx index 6a6cb1df9baaf..9109f82d78bb6 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.keys.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.keys.browser.test.tsx @@ -7,6 +7,7 @@ vi.mock(import("../ChatMarkdown"), () => ({ })); import { render } from "../../../test/render"; +import { installSidebarTree, releaseSidebarRowFocus } from "../../lib/sidebarTree"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; const renderPanel = async (voiceInputActive = false) => { @@ -82,3 +83,69 @@ it("takes no keys while dictation runs", async () => { expect(onToggleOption).not.toHaveBeenCalled(); expect(optionKeyRef.current).toBeNull(); }); + +it("leaves digits to an interactive element outside the panel", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + const outside = document.createElement("button"); + document.body.append(outside); + outside.focus(); + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); + outside.remove(); +}); + +it("still picks with a digit while an option inside the panel has focus", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + (screen.getByRole("button", { name: /CSV/ }).element() as HTMLElement).focus(); + await userEvent.keyboard("2"); + expect(onToggleOption).toHaveBeenCalledWith("format", "JSON"); +}); + +it("leaves digits alone while a menu is open", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + const menu = document.createElement("div"); + menu.dataset.slot = "menu-popup"; + menu.toggleAttribute("data-open", true); + document.body.append(menu); + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); + menu.remove(); +}); + +it("leaves digits to a secret request", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + const secret = document.createElement("div"); + secret.dataset.v2ItemType = "secret_request"; + secret.tabIndex = 0; + document.body.append(secret); + secret.focus(); + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); + secret.remove(); +}); + +it("picks after activating a real sidebar row that then lets go of focus", async () => { + const { screen, onToggleOption } = await renderPanel(); + await using _ = screen; + const root = document.createElement("div"); + const row = document.createElement("div"); + row.setAttribute("role", "button"); + row.tabIndex = 0; + row.dataset.sidebarRow = "env:thread"; + row.addEventListener("click", releaseSidebarRowFocus); + root.append(row); + document.body.append(root); + const dispose = installSidebarTree(root); + row.focus(); + await userEvent.keyboard("2"); + expect(onToggleOption).not.toHaveBeenCalled(); + row.click(); + await userEvent.keyboard("2"); + expect(onToggleOption).toHaveBeenCalledWith("format", "JSON"); + dispose(); + root.remove(); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a0a44fa10e55..ab1ebc2f19140 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -11,6 +11,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collaps import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; import { isInSideChatPane } from "~/lib/sideChatPane"; +import { keyBelongsToFocusedControl } from "./composerFocusRedirect"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -185,6 +186,9 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) { return; } + const insidePanel = + target instanceof Element && target.closest("[data-pending-user-input-panel]") !== null; + if (!insidePanel && keyBelongsToFocusedControl(event)) return; const option = optionForKey(event.key); if (!option) return; event.preventDefault(); @@ -222,6 +226,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return ( { diff --git a/apps/web/src/components/chat/composerFocusRedirect.ts b/apps/web/src/components/chat/composerFocusRedirect.ts index f7730b367925e..3ce2d73aa0480 100644 --- a/apps/web/src/components/chat/composerFocusRedirect.ts +++ b/apps/web/src/components/chat/composerFocusRedirect.ts @@ -69,6 +69,15 @@ export function shouldRedirectInputToComposer(event: Event): boolean { return true; } +/** Whether a focused control, a secret request or an open layer owns this key, not a shortcut. */ +export function keyBelongsToFocusedControl(event: Event): boolean { + return ( + eventPathContainsSelector(event, SECRET_REQUEST_SELECTOR) || + eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR) || + document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) !== null + ); +} + export function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index c6bb67bd2cb99..9e968c74733cc 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import { installSidebarTree } from "./sidebarTree"; +import { installSidebarTree, releaseSidebarRowFocus } from "./sidebarTree"; function setup() { const root = document.createElement("div"); @@ -163,6 +163,25 @@ describe("sidebar keyboard navigation", () => { expect(tree.root.scrollTop).toBe(0); }); + it("does not pull focus back to a row that let go of it", async () => { + using tree = setup(); + const first = tree.row("first"); + tree.install(); + first.focus(); + releaseSidebarRowFocus(); + expect(document.activeElement).toBe(document.body); + // why: this observer registers after the tree's, so it runs after the tree has reacted. + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(tree.root, { childList: true, subtree: true }); + tree.row("added"); + }); + expect(document.activeElement).toBe(document.body); + }); + it("does not pull focus back after a click outside the rows", async () => { using tree = setup(); const first = tree.row("first"); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 7a6183b5e4acb..aadf772e41f6d 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -1,4 +1,13 @@ const ROW = "[data-sidebar-row]"; +const RELEASE_FOCUS_EVENT = "sidebar-tree:release-focus"; + +/** Takes focus off the active sidebar row, so the tree does not restore it to that row. */ +export function releaseSidebarRowFocus() { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !active.closest(ROW)) return; + active.dispatchEvent(new Event(RELEASE_FOCUS_EVENT, { bubbles: true })); + active.blur(); +} /** Ref callback for a sidebar list; installs its keyboard navigation and returns its cleanup. */ export function attachSidebarTree(element: HTMLElement | null) { @@ -69,6 +78,9 @@ export function installSidebarTree(root: HTMLElement) { : null; ownsFocus = focusable !== null && root.contains(focusable); }; + const onReleaseFocus = () => { + ownsFocus = false; + }; const onFocus = (event: FocusEvent) => { ownsFocus = root.contains(event.target as Node); if (!ownsFocus || !(event.target instanceof HTMLElement)) return; @@ -171,12 +183,14 @@ export function installSidebarTree(root: HTMLElement) { attributeFilter: ["hidden", "inert", "aria-hidden"], }); document.addEventListener("focusin", onFocus); + root.addEventListener(RELEASE_FOCUS_EVENT, onReleaseFocus); document.addEventListener("pointerdown", onPointerDown, true); root.addEventListener("keydown", onKeyDown, true); root.addEventListener("keyup", onKeyUp, true); return () => { observer.disconnect(); document.removeEventListener("focusin", onFocus); + root.removeEventListener(RELEASE_FOCUS_EVENT, onReleaseFocus); document.removeEventListener("pointerdown", onPointerDown, true); root.removeEventListener("keydown", onKeyDown, true); root.removeEventListener("keyup", onKeyUp, true); From 511a038133b204b33000edcf6ab5a772d29b66ac Mon Sep 17 00:00:00 2001 From: Quantic Studios Date: Fri, 6 Nov 2026 20:04:56 +0100 Subject: [PATCH 34/42] fix(web): keyboard focus hides the time only for the action beside it The rule hid the slot's first span whichever descendant had focus, and on Slim that span holds the answer button and the Woke button, so Tab made them and their ring vanish. It now follows the same conditions as the hover rule. --- .../components/sidebar/SidebarThreadRow.tsx | 1 + .../variants/RowAnswers.browser.test.tsx | 83 +++++++++++++++++++ .../sidebar/variants/sidebarVariants.css | 4 +- .../variantRow.regressions.browser.test.tsx | 2 +- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index ce2ad6a087a3d..ec5711cbd41bb 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1267,6 +1267,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} > { const chip = screen.container.querySelector("[data-worker-fold]")!.getBoundingClientRect(); expect(Math.abs(chip.top + chip.height / 2 - (answers.top + answers.height / 2))).toBeLessThan(2); }); + +const slimRow = (extra: Partial[0]>) => ( +
    + { + resolveVariantRow({ + style: "folder-slim", + threadKey: "environment-a:thread-a", + thread: { ...thread, title: "A thread", hasPendingUserInput: true }, + status: "ready", + statusWord: { label: "Input", className: "text-indigo-600" }, + wokePill: null, + time: "1m", + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + hasDraftSurface: false, + nested: false, + lastNested: false, + inFolder: false, + repo: null, + mark: null, + title: Title, + draft: null, + pin: null, + where: null, + lineExtras: null, + remote: null, + terminal: null, + jumpHint: null, + workerCount: null, + workerSummary: null, + workersOpen: null, + onToggleWorkers: () => {}, + answersOpen: false, + onAnswersOpenChange: () => {}, + onOpenThread: () => {}, + statusSlot: (content, keepsLabel) => ( + + + {content} + + {keepsLabel ? null :
    +); + +it("keeps Slim's answer button visible while it has keyboard focus", async () => { + setup({ userInputs: [question()] }); + await using screen = await render(slimRow({})); + const answer = screen.getByRole("button", { name: /answer without opening/ }).element(); + // why: script focus only counts as focus-visible after keyboard input. + await userEvent.keyboard("{Shift}"); + (answer as HTMLElement).focus(); + expect(answer.matches(":focus-visible")).toBe(true); + expect(getComputedStyle(screen.getByTestId("label").element()).opacity).toBe("1"); +}); + +it("hides the time only while the action beside it has keyboard focus", async () => { + setup({}); + await using screen = await render( + slimRow({ + thread: { ...thread, title: "A thread" }, + statusWord: null, + wokePill: , + }), + ); + const label = screen.getByTestId("label").element(); + await userEvent.keyboard("{Shift}"); + (screen.getByRole("button", { name: "Woke" }).element() as HTMLElement).focus(); + expect(getComputedStyle(label).opacity).toBe("1"); + (screen.getByRole("button", { name: "Settle thread" }).element() as HTMLElement).focus(); + expect(getComputedStyle(label).opacity).toBe("0"); +}); diff --git a/apps/web/src/components/sidebar/variants/sidebarVariants.css b/apps/web/src/components/sidebar/variants/sidebarVariants.css index ac606805d196e..69cf098e93ee4 100644 --- a/apps/web/src/components/sidebar/variants/sidebarVariants.css +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -251,8 +251,8 @@ pointer-events: none; } - /* Hover and pointer rows hide the time for Settle, and keyboard focus has to as well. */ - & [data-status-slot]:has(:focus-visible) > span:first-child { + /* Hover hides the time for Settle, so keyboard focus on that action has to as well. */ + & [data-status-slot]:has(> button:focus-visible) > [data-yields-to-action] { opacity: 0; } diff --git a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx index 87d72887f24bf..63f928d9a882a 100644 --- a/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -92,7 +92,7 @@ it("hides the time under Settle while Settle has keyboard focus", async () => { {row({ statusSlot: (content: ReactNode) => ( - + {content}
    @@ -210,6 +214,53 @@ it("gives answers that do not fit a full line under the chip, terminal and Settl } }); +it("keeps a coarse pointer's answer areas off the terminal and Settle beside them", async () => { + setup({ approvals: [approval] }); + const session = cdp(); + // Chromium's touch emulation is what flips (pointer: coarse); emulated media features cannot. + await session.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + try { + for (const workerCount of [null, 2]) { + await using screen = await render( + rowShowingAnswers(320, { + thread: { ...thread, title: "A thread", hasPendingApprovals: true }, + workerCount, + workerSummary: workerCount === null ? null : "2 workers · 1 working", + terminal: ( + ); - return option.warning ? ( + return ( - {option.warning} + +
    {option.label}
    + {option.warning} +
    - ) : ( - button ); })} {moreOptions.length > 0 ? ( From bac7bdaf222f0ca919455faca8ea9290ec0ccd62 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 21:14:26 +0000 Subject: [PATCH 42/42] fix(web): close sidebar answers when opening a thread --- .../variants/RowAnswers.browser.test.tsx | 41 ++++++++++++++----- .../sidebar/variants/RowAnswers.tsx | 26 ++++++++++-- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx index 25bfe38e0ecc8..1ab24edba5c89 100644 --- a/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx +++ b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx @@ -3,6 +3,7 @@ import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; +import { useState } from "react"; import { expect, it, vi } from "vite-plus/test"; import { cdp, userEvent } from "vite-plus/test/browser"; @@ -117,21 +118,39 @@ it("draws nothing on the row for a question that needs the full thread", async ( expect(screen.container.querySelector("[data-row-answers]")?.childElementCount).toBe(0); }); -it("offers the thread from the popover when the row cannot answer", async () => { +it("closes the popover and focuses the question when opening its thread", async () => { setup({ userInputs: [question({ multiSelect: true })] }); const onOpenThread = vi.fn(); - await using screen = await render( - {}} - onOpenThread={onOpenThread} - />, - ); + function AnswerPopover() { + const [open, setOpen] = useState(true); + return ( + <> + +
    + +
    + + ); + } + await using screen = await render(); await screen.getByRole("button", { name: "Open thread" }).click(); expect(onOpenThread).toHaveBeenCalledOnce(); + await expect.element(screen.getByRole("button", { name: "Open thread" })).not.toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Question", exact: true })).toHaveFocus(); + await screen.getByRole("button", { name: "Input: answer without opening the thread" }).click(); + await expect.element(screen.getByRole("button", { name: "Open thread" })).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await expect.element(screen.getByRole("button", { name: "Open thread" })).not.toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Input: answer without opening the thread" })) + .toHaveFocus(); }); const rowShowingAnswers = ( diff --git a/apps/web/src/components/sidebar/variants/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx index 8d739dd9bdf63..11e9c3c9b662b 100644 --- a/apps/web/src/components/sidebar/variants/RowAnswers.tsx +++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx @@ -7,7 +7,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { fullAccessOverrideExplanation } from "@t3tools/client-runtime/state/thread-requests"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; -import { useState, type MouseEvent, type ReactNode } from "react"; +import { useRef, useState, type MouseEvent, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import { useEnvironmentScope } from "../../../state/session"; @@ -228,8 +228,15 @@ export function RowAnswersPopover(props: { onOpenChange: (open: boolean) => void; onOpenThread: () => void; }) { + const openingThread = useRef(false); return ( - + { + if (open) openingThread.current = false; + props.onOpenChange(open); + }} + > + openingThread.current + ? (document.querySelector("[data-pending-user-input-panel] button") ?? + false) + : true + } onClick={stopRowActivation} onDoubleClick={stopRowActivation} >
    - + { + openingThread.current = true; + props.onOpenChange(false); + props.onOpenThread(); + }} + />