diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3be9175c4f2e4..ab10957b2c2b7 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -20,6 +20,7 @@ import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocati import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoordinator"; import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { waitingOnYouCountAtom } from "./components/sidebar/variants/sidebarVariant.logic"; import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; import { resolveServerBackedAppDisplayName } from "./branding.logic"; @@ -117,19 +118,48 @@ function startClientAppearance(): void { apply(); } +const waitingThreadCountAtom = waitingOnYouCountAtom(environmentThreadShells); + 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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4561188df0cc..88d997f392352 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -308,6 +308,7 @@ import { getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, + threadFailureIsShownElsewhere, } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRunsControl"; import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; @@ -997,7 +998,10 @@ export default function ChatView(props: ChatViewProps) { snoozedUntil: activeThreadShell?.snoozedUntil, }); const timelineThreadError = - limitRecoveryRun !== null && visibleThreadError === serverRuntime?.lastError + serverRuntime?.status === "failed" && + threadFailureIsShownElsewhere(serverRuntime.lastErrorClass, serverRuntime.lastErrorCode) && + activeThreadShell?.latestRun && + visibleThreadError === serverRuntime.lastError ? null : visibleThreadError; @@ -1799,6 +1803,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 () => { @@ -2117,12 +2130,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(() => { @@ -2139,7 +2152,7 @@ export default function ChatView(props: ChatViewProps) { !isInSideChatPane(document.activeElement) && shouldRefocusComposerOnWindowFocus(document.activeElement) ) { - focusComposerStable(); + focusComposerUnlessQuestionWaits(); } }); }); @@ -2152,7 +2165,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeThread?.id, embedded, - focusComposerStable, + focusComposerUnlessQuestionWaits, isMobileViewport, terminalUiState.terminalOpen, ]); diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index f337f555382b5..a944202ef40c4 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -186,9 +186,7 @@ function ThreadRowContext({ .filter((part) => part && part !== item.matchHint?.text) .join(" · "); return context ? ( - - {context} - + {context} ) : null; } @@ -210,9 +208,7 @@ function DisabledCommandPaletteResultRow(props: { ) : props.item.matchHint ? ( ) : props.item.description ? ( - - {props.item.description} - + {props.item.description} ) : null} ) : ( @@ -272,9 +268,7 @@ function CommandPaletteResultRow(props: { ) : props.item.matchHint ? ( ) : props.item.description ? ( - - {props.item.description} - + {props.item.description} ) : null} ) : ( @@ -293,13 +287,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/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed8..a3259b10ff1ee 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -284,6 +284,18 @@ describe("resolveSidebarThreadStatus", () => { ).toBe(false); }); + it("never recedes a thread waiting on approval, even once read", () => { + expect( + shouldRecedeSidebarThread({ + status: "approval", + isUnread: false, + isWoke: false, + isActive: false, + isSelected: false, + }), + ).toBe(false); + }); + it("reports worktree setup as preparing, and failed only while the latest run failed", () => { expect( resolveSidebarThreadStatus({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef229d1424c38..c80fbc3c9c276 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -834,6 +834,7 @@ export function shouldRecedeSidebarThread(input: { if ( input.isActive || input.isSelected || + input.status === "approval" || input.status === "input" || input.status === "request" ) { @@ -842,7 +843,7 @@ export function shouldRecedeSidebarThread(input: { if (input.status === "preparing" || input.status === "working" || input.status === "waiting") { return true; } - if (input.status === "ready" || input.status === "approval") { + if (input.status === "ready") { return !input.isUnread && !input.isWoke; } return false; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2b1a8403935c6..38d1886e3bdb0 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 { @@ -134,7 +134,8 @@ import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import type { SidebarVariant } from "@t3tools/contracts/settings"; +import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -263,6 +264,15 @@ import { type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { DetailedSidebarGroups } from "./sidebar/variants/DetailedSidebarGroups"; +import { + threadsWaitingOnYou, + variantRowStyle, + workerSummary, + workersByParent, + workersOf, +} from "./sidebar/variants/sidebarVariant.logic"; +import { useDetailedFolders } from "./sidebar/variants/useDetailedFolders"; import { SidebarProjectFolderHeader, sidebarFolderRowId, @@ -424,6 +434,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, @@ -436,6 +448,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 = @@ -492,7 +506,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); @@ -541,7 +555,23 @@ 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, + keepEmpty, + } + : null, + ); + const orderedThreads = detailedFolders?.orderedThreads ?? [ ...pinnedThreads, ...(projectFolders === null ? activeThreads @@ -587,6 +617,7 @@ function useSidebarThreads(input: { workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), projectFolders, + detailedFolders, toggleProjectFolder: (group: SidebarProjectSnapshot) => { const keys = sidebarFolderExpansionKeys(group); setProjectExpanded(keys, !resolveProjectExpanded(projectExpandedById, keys)); @@ -990,8 +1021,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); }, }); - const boringSidebarEnabled = useClientSettings((settings) => settings.boringSidebarEnabled); + const boringSidebarEnabled = useBoringSidebarEnabled(); 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; @@ -1315,6 +1347,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve workingShelfExpanded, toggleWorkingShelf, projectFolders, + detailedFolders, toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, @@ -1345,6 +1378,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 @@ -1445,6 +1480,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), @@ -3339,24 +3378,41 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve )} > {(() => { + const workerMap = + sidebarVariant !== "default" ? 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, + detailedItem?: { + nested: boolean; + lastNested: boolean; + workersOpen: boolean | null; + }, ) => { 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) : []; + const nested = detailedItem?.nested ?? false; return ( 0 ? workers.length : null} + workerSummary={workers.length > 0 ? workerSummary(workers) : null} + workersOpen={detailedItem?.workersOpen ?? null} + onToggleWorkers={detailedFolders?.toggleWorkers} navigationParent={ folderRow ?? (section === "working" || @@ -3384,8 +3446,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={ @@ -3407,10 +3469,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={ @@ -3470,6 +3530,36 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve onDraftContextMenu={handleDraftContextMenu} />, ]; + if (detailedFolders !== null) { + items.push( + + projectDisplayNameByKey.get( + `${thread.environmentId}:${thread.projectId}`, + ) ?? null + } + onNavigate={navigateToThread} + 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. @@ -3614,7 +3704,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/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 530014734d38f..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/70"; +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/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..529ff9df84492 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 }), }; }); @@ -142,6 +143,7 @@ function setup() { environmentId: EnvironmentId.make("environment-b"), threadId: ThreadId.make("thread-b"), }; + fixture.terminalActionsStrip = false; fixture.statusByThreadId = new Map([ [original.threadId, "exited"], [other.threadId, "running"], @@ -248,3 +250,13 @@ 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(); + 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(); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 049589d0b671a..c93719647a101 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); @@ -1894,7 +1895,12 @@ export default function ThreadTerminalDrawer({ )} -
    +
    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; @@ -488,6 +491,9 @@ const NO_PROVIDER_SKILLS: ServerProvider["skills"] = []; const NO_PROVIDER_SLASH_COMMANDS: ServerProvider["slashCommands"] = []; export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + const quietStopWhileWaiting = useClientSettings( + (settings) => settings.composerQuietStopWhileWaiting, + ); const { composerDraftTarget, environmentId, @@ -1025,6 +1031,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. @@ -2906,6 +2942,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(); @@ -3279,6 +3316,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onToggleOption={onSelectActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} onDismiss={onDismissActivePendingUserInput} + voiceInputActive={voiceInputActive} + optionKeyRef={questionOptionKeyRef} /> ) : null} @@ -3583,6 +3622,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingAction={pendingPrimaryAction} isRunning={phase === "running"} canInterrupt={canInterrupt} + quietStop={ + quietStopWhileWaiting && + (pendingApprovals.length > 0 || pendingUserInputs.length > 0) + } followUpBehavior={followUpBehavior} canSteer={!isCompacting} alternateShortcutLabel={shortcutLabelForCommand( 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}
    {children} diff --git a/apps/web/src/components/chat/ComposerFooterControls.tsx b/apps/web/src/components/chat/ComposerFooterControls.tsx index 2e2e9323b3eda..29095aa7913ce 100644 --- a/apps/web/src/components/chat/ComposerFooterControls.tsx +++ b/apps/web/src/components/chat/ComposerFooterControls.tsx @@ -199,6 +199,7 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA } | null; isRunning: boolean; canInterrupt: boolean; + quietStop: boolean; followUpBehavior: "queue" | "steer"; canSteer: boolean; alternateShortcutLabel: string | null; @@ -226,6 +227,7 @@ export const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryA pendingAction={props.pendingAction} isRunning={props.isRunning} canInterrupt={props.canInterrupt} + quietStop={props.quietStop} followUpBehavior={props.followUpBehavior} canSteer={props.canSteer} alternateShortcutLabel={props.alternateShortcutLabel} diff --git a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx index 67860e45ef2b1..cca26760109a1 100644 --- a/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx +++ b/apps/web/src/components/chat/ComposerPendingApprovalActions.tsx @@ -5,13 +5,12 @@ import { } from "@t3tools/contracts"; import { memo } from "react"; import { EllipsisIcon, TriangleAlertIcon } from "lucide-react"; -import { Button, InlineButton } from "../ui/button"; +import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; interface ComposerPendingApprovalActionsProps { - presentation?: "composer" | "inline"; requestId: RuntimeRequestId; isResponding: boolean; canRespond: boolean; @@ -31,7 +30,6 @@ const DEFAULT_APPROVAL_OPTIONS = [ ] satisfies ReadonlyArray; export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({ - presentation = "composer", requestId, isResponding, canRespond, @@ -61,52 +59,36 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro const content = ( <> {option.warning ? : null} - - {option.label} - + {option.label} ); - const button = - presentation === "inline" ? ( - - {content} - - ) : ( - - ); - return option.warning ? ( + const button = ( + + ); + return ( - {option.warning} + +
    {option.label}
    + {option.warning} +
    - ) : ( - button ); })} {moreOptions.length > 0 ? ( - ) : ( - + ) + ) : !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 = + 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, + wokePill: isWokeStatus ? ( + + + + Woke + + + } + /> + Dismiss Woke notification + + ) : null, + time: relativeTime, + isUnread, + isWoke, + isActive: props.isActive, + isSelected: isSelected || props.sweepAction !== null, + hasDraftSurface: hasUnsentDraft, + nested: props.nested ?? false, + lastNested: props.lastNested ?? false, + inFolder: + props.navigationParent !== undefined && !props.navigationParent.startsWith("shelf:"), + repo: props.project ? ( + + ) : null, + mark: rowProviderEntry + ? (className) => ( + + ) + : null, + title, + 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), + statusSlot, + }) + : null; return (
  • - + } > - {/* `contents` keeps one-line rows a single flex row. */} -
    - {accessibleTitle} - {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 ? ( - + {draftIndicator} + {title} + {multiline ? titleLineIcons : null} + {pinIndicator} + {multiline ? null : terminalStatusIcon} + {isRegeneratingTitle ? ( + + Regenerating title + ) : null} - - )} - {draftIndicator} - {title} - {multiline ? titleLineIcons : null} - {pinIndicator} - {multiline ? null : terminalStatusIcon} - {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) ? ( + {statusSlot( + (inFolder ? isWokeStatus : isWoke) ? ( ) : ( - - - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - - } - > - - - Settle thread - + {relativeTime} + ), + false, )} - {jumpHintLabel ? : null} - - )} - {props.sweepAction !== null ? dragDestination : null} -
    - {multiline ? metaLine : null} +
    + {multiline ? metaLine : null} + + )}
    {detailsTooltip}
    @@ -1566,7 +1742,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP aria-label="Settle thread" onClick={handleSettleClick} onPointerDown={handleActionPointerDown} - className="-mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + className="group-any-hover/sidebar-row:relative group-any-hover/sidebar-row:after:absolute group-any-hover/sidebar-row:after:-inset-y-0.5 group-any-hover/sidebar-row:after:inset-x-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" /> } > 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..c66f2d0530ce2 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/DetailedSidebarGroups.tsx @@ -0,0 +1,170 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + ChevronDownIcon, + MessageCircleQuestionIcon, + 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 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 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; + onToggleParked: (projectKey: string) => void; + renderHeader: (folder: TFolder) => ReactNode; + renderRow: ( + thread: EnvironmentThreadShell, + section: SidebarSection, + folderRow: string, + item: { nested: boolean; lastNested: boolean; workersOpen: boolean | null }, + ) => 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); + 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..1ab24edba5c89 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/RowAnswers.browser.test.tsx @@ -0,0 +1,373 @@ +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 { useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; +import { cdp, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; + +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"; +import { resolveVariantRow } from "./variantRow"; + +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("closes the popover and focuses the question when opening its thread", async () => { + setup({ userInputs: [question({ multiSelect: true })] }); + const onOpenThread = vi.fn(); + 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 = ( + lineWidth: number, + extra: Partial[0]> = {}, +) => ( +
    + { + 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; + // A wrapped pill's area meets this one's at the 24px edge, and hit tests snap fractional rows. + for (const offset of [-11, 11]) { + const hit = document.elementFromPoint(box.left + box.width / 2, middle + offset); + expect(option.contains(hit)).toBe(true); + } +}); + +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: ( +
    +); + +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/RowAnswers.tsx b/apps/web/src/components/sidebar/variants/RowAnswers.tsx new file mode 100644 index 0000000000000..11e9c3c9b662b --- /dev/null +++ b/apps/web/src/components/sidebar/variants/RowAnswers.tsx @@ -0,0 +1,284 @@ +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 { useRef, 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; + inline: boolean; + mono?: boolean; + note?: string | null; + error: string | null; + children: ReactNode; +}) { + return ( + <> + {props.inline ? ( + + + } + > + {props.detail} + + {props.detail} + + ) : ( +

    + {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 inline = onOpenThread === null; + 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: on line 2 of a row, or under a worker's title. */ +export function InlineRowAnswers(props: { thread: EnvironmentThreadShell; className?: string }) { + return ( +
    + +
    + ); +} + +/** Slim: the pill 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; +}) { + const openingThread = useRef(false); + return ( + { + if (open) openingThread.current = false; + props.onOpenChange(open); + }} + > + + } + > + {props.label} + + {/* A row click navigates, and portals still bubble React events to the row. */} + + openingThread.current + ? (document.querySelector("[data-pending-user-input-panel] button") ?? + false) + : true + } + onClick={stopRowActivation} + onDoubleClick={stopRowActivation} + > +
    + { + openingThread.current = true; + props.onOpenChange(false); + props.onOpenThread(); + }} + /> +
    +
    +
    + ); +} 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..2740ffa296577 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarLayout.ts @@ -0,0 +1,23 @@ +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" + | "sidebarWorkingShelfEnabled" + >, +) { + const locked = settings.sidebarVariant !== "default"; + const folders = locked || settings.boringSidebarEnabled; + 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..305c46f5842a3 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.test.ts @@ -0,0 +1,388 @@ +import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { createEnvironmentThreadShellAtoms } from "@t3tools/client-runtime/state/threads"; +import { + EnvironmentId, + ProjectId, + RuntimeRequestId, + ThreadId, + type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, type SidebarVariant } from "@t3tools/contracts/settings"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveSidebarLayout } from "./sidebarLayout"; +import { + arrangeDetailedFolder, + arrangeStableRows, + detailedFolderShows, + rowAttention, + threadsWaitingOnYou, + waitingOnYouCountAtom, + variantRowStyle, + workerSummary, + 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 sidebarWorkingShelfEnabled of flags) { + const layout = resolveSidebarLayout({ + sidebarVariant, + boringSidebarEnabled, + boringSidebarMultilineRows, + sidebarWorkingShelfEnabled, + }); + expect(layout.locked).toBe(sidebarVariant !== "default"); + expect(layout.folders).toBe(sidebarVariant !== "default" || boringSidebarEnabled); + expect(layout.workingSection).toBe( + sidebarVariant === "default" && !boringSidebarEnabled && 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("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("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(); + }); +}); + +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]); + 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]); + 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({ + active: [parent], + workers: needs, + workerFold: new Map([["environment-a:p", false]]), + }), + ), + ).toEqual(["p"]); + }); + + 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", "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", { + 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"]); + }); +}); + +describe("waitingOnYouCountAtom", () => { + const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const projectId = ProjectId.make("project-a"); + const here = EnvironmentId.make("environment-a"); + const there = EnvironmentId.make("environment-b"); + const approval = { + id: RuntimeRequestId.make("request-a"), + kind: "command" as const, + createdAt: now, + }; + const wire = ( + id: string, + overrides: Partial = {}, + ): OrchestrationV2ThreadShell => ({ + id: ThreadId.make(id), + projectId, + title: id, + providerInstanceId: "codex" as OrchestrationV2ThreadShell["providerInstanceId"], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + } as OrchestrationV2ThreadShell["modelSelection"], + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: ThreadId.make(id), parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + latestRunId: null, + activeRunId: null, + status: "idle", + pendingRuntimeRequest: null, + latestVisibleMessage: null, + latestUserMessageAt: null, + hasActionableProposedPlan: false, + itemCount: 0, + visibleItemCount: 0, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + ...overrides, + }); + const asking = (id: string, overrides: Partial = {}) => + wire(id, { pendingRuntimeRequest: approval, ...overrides }); + const worker = (id: string, parent: string) => + asking(id, { + lineage: { + rootThreadId: ThreadId.make(parent), + parentThreadId: ThreadId.make(parent), + relationshipToParent: "subagent", + }, + }); + + function count( + threads: Record, + options: { hiddenTags?: ReadonlyArray; disabled?: EnvironmentId } = {}, + ) { + const ids = Object.keys(threads).map((id) => EnvironmentId.make(id)); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map( + ids.map((id) => [ + id, + { + target: new PrimaryConnectionTarget({ + environmentId: id, + label: "Environment", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + enabled: id !== options.disabled, + }, + ]), + ), + }); + const shells = createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom: (id) => + Atom.make({ + schemaVersion: 1, + snapshotSequence: 0, + projects: [], + threads: threads[id] ?? [], + archivedThreads: [], + }), + hiddenThreadTagsAtom: () => Atom.make(options.hiddenTags ?? null), + }); + const registry = AtomRegistry.make(); + const value = registry.get(waitingOnYouCountAtom(shells)); + registry.dispose(); + return value; + } + + it("counts each thread asking you, workers on their own", () => { + expect( + count({ + [here]: [wire("parent"), worker("worker-a", "parent"), worker("worker-b", "parent")], + }), + ).toBe(2); + }); + + it("leaves out a thread the tag filter hides", () => { + expect( + count( + { [here]: [asking("shown"), asking("hidden", { tags: ["later"] })] }, + { hiddenTags: ["later"] }, + ), + ).toBe(1); + }); + + it("leaves out an archived thread", () => { + expect(count({ [here]: [asking("open"), asking("archived", { archivedAt: now })] })).toBe(1); + }); + + it("leaves out a switched off environment", () => { + expect(count({ [here]: [asking("a")], [there]: [asking("b")] }, { disabled: there })).toBe(1); + }); +}); 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..613459b90d706 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariant.logic.ts @@ -0,0 +1,254 @@ +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 { Atom } from "effect/reactivity"; + +import { + hasUnseenCompletion, + resolveSidebarThreadStatus, + type SidebarSection, + type SidebarThreadStatus, +} from "../../Sidebar.logic"; + +export type VariantRowStyle = "folder-detailed" | "folder-slim"; +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( + 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; +} + +/** 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": + return "guest"; + case "failed": + return "error"; + case "ready": + return isUnread ? "success" : undefined; + default: + return undefined; + } +} + +function workerNeedsYou(thread: EnvironmentThreadShell): boolean { + const status = resolveSidebarThreadStatus(thread); + 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; + +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(" · "); +} + +/** 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; + /** 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 }; + +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; + 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 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, + lastNested: false, + workersOpen: null, + }); + } + } + } + return { items, hiddenThreads: collapsed ? main.filter((thread) => !isRouteRow(thread)) : [] }; +} + +/** How many rows the Waiting on you list shows across every project, for the window title. */ +export function waitingOnYouCountAtom(shells: { + quietThreadShellsAtom: Atom.Atom>; +}): Atom.Atom { + return Atom.make((get) => threadsWaitingOnYou(get(shells.quietThreadShellsAtom), null).length); +} + +/** 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..51257100303b9 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/sidebarVariants.css @@ -0,0 +1,419 @@ +/* Two-row-wide tiles slide four row widths left, as background-position -200% to 200% does. */ +@keyframes sidebar-row-sweep { + from { + transform: translateX(0); + } + to { + transform: translateX(-66.6667%); + } +} + +@keyframes sidebar-status-pulse { + 0%, + 100% { + opacity: 1; + } + /* Pitwall's 0.5 stays under 3:1 on 8 of the 12 themes at any clamp. */ + 50% { + opacity: var(--row-dot-dim); + } +} + +/* Lightness is clamped to hold 4.5:1. On light themes chroma stays inside sRGB at that lightness, or the browser clips the hue: amber turns red. */ +@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); + --row-state-floor: 0.75; + --row-dot-dim: 0.6; + --variant-warning: oklch(from var(--warning-foreground) min(l, 0.38) min(c, 0.1) h); + --variant-success: oklch(from var(--success-foreground) min(l, 0.38) min(c, 0.078) 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) min(c, 0.15) h); + --variant-guest: oklch(from var(--guest-foreground) min(l, 0.38) min(c, 0.18) h); + --input-state: var(--color-indigo-600); + --variant-input: oklch(from var(--input-state) min(l, 0.38) c h); + + .dark & { + --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); + --input-state: var(--color-indigo-300); + --variant-input: oklch(from var(--input-state) max(l, var(--row-state-floor)) c h); + } + + /* 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 { + --pill-state: var(--warning-foreground); + color: var(--variant-warning); + } + & .text-success-foreground { + --pill-state: var(--success-foreground); + color: var(--variant-success); + } + & .text-info-foreground { + --pill-state: var(--info-foreground); + color: var(--variant-info); + } + & .text-error-foreground { + --pill-state: var(--error-foreground); + color: var(--variant-error); + } + & .text-muted-foreground { + color: var(--row-muted); + } + & .text-guest-foreground { + --pill-state: var(--guest-foreground); + color: var(--variant-guest); + } + & .text-indigo-600, + & .sidebar-input-mark { + --pill-state: var(--input-state); + color: var(--variant-input); + } + /* The pill's dark: colour would otherwise outrank the token. */ + && [class*="dark:text-indigo-300"] { + --pill-state: var(--input-state); + color: var(--variant-input); + } +} + +/* 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); +} + +/* Six row widths of two-row-wide tiles, so the slide never shows an edge. */ +@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: 600%; + background-image: linear-gradient( + to right, + transparent 0%, + rgb(255 255 255 / 0.02) 40%, + rgb(255 255 255 / 0.04) 50%, + rgb(255 255 255 / 0.02) 60%, + transparent 100% + ); + 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); + } + + /* 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; + } +} + +@utility sidebar-status-dot { + background-color: currentColor; + + [data-slot="sidebar"][data-state="collapsed"] & { + animation: none; + } + + @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); + + @media (prefers-reduced-motion: reduce), (forced-colors: active) { + animation: none; + 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, var(--pill-state, currentColor) 14%, transparent); +} + +/* 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; +} + +/* 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-look { + --row-fill: transparent; + --row-state: transparent; + --row-tint-state: transparent; + --row-tint: color-mix(in srgb, var(--row-tint-state) 10%, transparent); + background-color: var(--row-fill); + background-image: linear-gradient(var(--row-tint), var(--row-tint)); + + &:hover { + --row-fill: var(--sidebar-row-hover); + } + + &[data-surface="draft"] { + --row-fill: color-mix(in srgb, var(--warning) 4%, transparent); + } + + &[data-surface="draft"]:hover { + --row-fill: color-mix(in srgb, var(--warning) 8%, transparent); + } + + &[data-surface="selected"] { + --row-fill: var(--sidebar-row-selected); + } + + &[data-surface="active"] { + --row-fill: var(--sidebar-row-active); + --row-tint: transparent; + } + + &[data-surface="worker-active"] { + --row-fill: color-mix(in srgb, var(--primary) 8%, transparent); + } + + &[data-surface="worker-selected"] { + --row-fill: color-mix(in srgb, var(--primary) 7%, transparent); + } + + &[data-surface^="worker-"]:hover { + --row-fill: var(--sidebar-row-hover); + } + + &[data-attention="warning"] { + --row-state: var(--variant-warning); + --row-tint-state: var(--warning-foreground); + } + + &[data-attention="input"] { + --row-state: var(--variant-input); + --row-tint-state: var(--input-state); + } + + &[data-attention="guest"] { + --row-state: var(--variant-guest); + --row-tint-state: var(--guest-foreground); + } + + &[data-attention="error"] { + --row-state: var(--variant-error); + --row-tint-state: var(--error-foreground); + } + + &[data-attention="success"] { + --row-state: var(--variant-success); + --row-tint-state: var(--success-foreground); + } + + &[data-attention]::after { + content: ""; + position: absolute; + left: 2px; + inset-block: 10px; + width: 3px; + border-radius: 1.5px; + background: var(--row-state); + pointer-events: none; + } + + /* 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; + } + + /* 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. */ +@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)); + } +} + +/* 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] > *, + & [data-status-slot] { + margin-block: -0.5rem; + } + + & [data-chip-short] { + display: none; + } + + &:has([data-row-answers]:not(:empty)) [data-line2-rest], + &:has([data-row-answers]:not(:empty)) [data-chip-full] { + display: none; + } + + &:has([data-row-answers]:not(:empty)) [data-chip-short] { + display: inline; + } + + /* Filled from the right, so answers that do not fit wrap alone to a full line under the rest. */ + &:has([data-row-answers]:not(:empty)) { + flex-flow: row-reverse wrap; + row-gap: 0.25rem; + } + + &:has([data-row-answers]:not(:empty)) > [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. */ +@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); + } +} + +/* 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; + font-weight: 600; + max-width: 100%; +} + +[data-row-answers] button[aria-label="More approval options"] { + width: 1.125rem; + padding-inline: 0; +} + +/* The pills are 18px tall with a 1px border, and this keeps their hit area at 24px, + also on a coarse pointer, where Button's 44px area would cover the terminal beside them. */ +[data-row-answers] button::after { + content: ""; + position: absolute; + inset: -4px 0; + width: auto; + height: auto; + min-width: 0; + min-height: 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..9db364e8fe5e5 --- /dev/null +++ b/apps/web/src/components/sidebar/variants/useDetailedFolders.ts @@ -0,0 +1,99 @@ +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, + detailedFolderShows, + 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; + readonly keepEmpty: (group: SidebarProjectSnapshot) => boolean; + } | 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 (!detailedFolderShows(lists, input.keepEmpty(group))) 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.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..63f928d9a882a --- /dev/null +++ b/apps/web/src/components/sidebar/variants/variantRow.regressions.browser.test.tsx @@ -0,0 +1,224 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +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"; + +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 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("hides the time under Settle while Settle has keyboard focus", async () => { + await using screen = await render( +
    + {row({ + statusSlot: (content: ReactNode) => ( + + + {content} + +
    , + ); + 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("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( +
    + {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( +
    + {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 new file mode 100644 index 0000000000000..a27404c03498c --- /dev/null +++ b/apps/web/src/components/sidebar/variants/variantRow.tsx @@ -0,0 +1,314 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { BotIcon, ChevronRightIcon } from "lucide-react"; +import type { MouseEvent, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "../../../lib/visibleAnimation"; +import { shouldShowInstanceBadge } from "../../../providerInstances"; +import { ProviderInstanceIcon, providerTextColor } from "../../chat/ProviderInstanceIcon"; +import type { SidebarThreadStatus } from "../../Sidebar.logic"; +import { InlineRowAnswers, RowAnswersPopover } from "./RowAnswers"; +import type { SidebarProviderEntry } from "../SidebarThreadRow"; +import { rowAttention, workerTotal, type VariantRowStyle } from "./sidebarVariant.logic"; + +/** 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 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} + + ); +} + +/** 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 ( + + ); +} + +/** 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; + wokePill: ReactNode; + time: ReactNode; + isUnread: boolean; + isWoke: boolean; + isActive: boolean; + isSelected: boolean; + hasDraftSurface: boolean; + nested: boolean; + lastNested: boolean; + inFolder: boolean; + repo: ReactNode; + mark: ((className: string) => ReactNode) | null; + title: ReactNode; + 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; + statusSlot: (content: ReactNode, keepsLabel: boolean) => ReactNode; +}) { + const { thread, status, statusWord, nested } = input; + const working = status === "working" || status === "preparing"; + const asksYou = thread.hasPendingApprovals || thread.hasPendingUserInput; + 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 pill = + input.wokePill ?? + (statusWord === null ? null : answersInPopover ? ( + + ) : ( + + {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} + + ) : ( + + ); + + 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 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", + ), + 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.workerSummary !== null ? `, ${input.workerSummary}` : "", + body: ( + <> + {working && !nested ? ( + + ) : null} + {lineOne} + {lineTwo} + {nested && asksYou ? ( + + ) : null} + + ), + }; +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0830513e5be45..3142faa65dc5b 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -101,7 +101,7 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { export { Button, buttonVariants, type ButtonSize, type ButtonVariant }; const inlineButtonVariants = cva( - "inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 [text-align:var(--inline-button-text-align,center)] [white-space:var(--inline-button-white-space,nowrap)] hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64", + "relative after:absolute after:-inset-y-1 after:-inset-x-0.5 inline-flex shrink-0 cursor-pointer items-center gap-0.5 font-medium underline-offset-2 [text-align:var(--inline-button-text-align,center)] [white-space:var(--inline-button-white-space,nowrap)] hover:underline focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-64", { defaultVariants: { tone: "default" }, variants: { diff --git a/apps/web/src/components/ui/panel-tab-close-button.tsx b/apps/web/src/components/ui/panel-tab-close-button.tsx index fb6f56a21521c..d05ae29225ee1 100644 --- a/apps/web/src/components/ui/panel-tab-close-button.tsx +++ b/apps/web/src/components/ui/panel-tab-close-button.tsx @@ -18,7 +18,7 @@ export function PanelTabCloseButton({ const button = (