From cb53c77d346fc63d529284123fe3399d6f692180 Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 19 Apr 2026 12:15:33 +0200 Subject: [PATCH 1/8] Persist last thread route and trim long user messages - restore the last opened thread when returning to the sidebar - collapse long user messages behind a Show more toggle - dispose terminal runtimes when terminals or threads are closed - keep prior-turn plans from reviving after completion --- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/Sidebar.tsx | 54 ++++++++++++++++++- .../src/components/Sidebar.uiState.test.ts | 35 ++++++++++++ apps/web/src/components/Sidebar.uiState.ts | 31 +++++++++++ apps/web/src/components/WorkspaceView.tsx | 2 + .../components/chat/MessagesTimeline.test.tsx | 48 +++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 36 +++++++++++-- .../chat/userMessagePreview.test.ts | 36 +++++++++++++ .../src/components/chat/userMessagePreview.ts | 37 +++++++++++++ .../terminal/terminalRuntimeRegistry.ts | 13 +++++ apps/web/src/components/timelineHeight.ts | 13 ++--- apps/web/src/session-logic.test.ts | 32 +++++++++++ apps/web/src/session-logic.ts | 16 +++++- 13 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/userMessagePreview.test.ts create mode 100644 apps/web/src/components/chat/userMessagePreview.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 21809f08..e928e8d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,6 +173,7 @@ import { import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -2529,6 +2530,7 @@ export default function ChatView({ if (!confirmed) { return; } + terminalRuntimeRegistry.disposeTerminal(activeThreadId, terminalId); const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af21607f..52bd0f0c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,6 +110,7 @@ import { ProjectSidebarIcon } from "./ProjectSidebarIcon"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; import { ThreadRunningSpinner } from "./ThreadRunningSpinner"; import { RenameThreadDialog } from "./RenameThreadDialog"; +import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; import { SidebarSearchPalette } from "./SidebarSearchPalette"; import { useHandleNewChat } from "../hooks/useHandleNewChat"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -1027,6 +1028,9 @@ export default function Sidebar() { const [chatThreadListExpanded, setChatThreadListExpanded] = useState( () => readSidebarUiState().chatThreadListExpanded, ); + const [lastThreadRoute, setLastThreadRoute] = useState( + () => readSidebarUiState().lastThreadRoute, + ); const [expandedSubagentParentIds, setExpandedSubagentParentIds] = useState>( () => new Set(), ); @@ -1507,9 +1511,32 @@ export default function Sidebar() { navigateToWorkspace(routeWorkspaceId ?? fallbackWorkspaceId); return; } + + if (lastThreadRoute) { + const restorableThread = sidebarThreadSummaryById[lastThreadRoute.threadId] ?? null; + if (restorableThread) { + void navigate({ + to: "/$threadId", + params: { threadId: ThreadId.makeUnsafe(lastThreadRoute.threadId) }, + search: () => ({ + splitViewId: lastThreadRoute.splitViewId, + }), + }); + return; + } + } + void handleNewChat({ fresh: true }); }, - [handleNewChat, navigateToWorkspace, routeWorkspaceId, workspacePages], + [ + handleNewChat, + lastThreadRoute, + navigate, + navigateToWorkspace, + routeWorkspaceId, + sidebarThreadSummaryById, + workspacePages, + ], ); const handleCreateWorkspace = useCallback(() => { @@ -1553,6 +1580,7 @@ export default function Sidebar() { ); if (api && typeof api.terminal.close === "function") { + terminalRuntimeRegistry.disposeThread(workspaceThread); await Promise.allSettled( terminalState.terminalIds.map((terminalId) => api.terminal.close({ @@ -1960,6 +1988,7 @@ export default function Sidebar() { } try { + terminalRuntimeRegistry.disposeThread(threadId); await api.terminal.close({ threadId, deleteHistory: true }); } catch { // Terminal may already be closed @@ -3213,8 +3242,29 @@ export default function Sidebar() { chatSectionExpanded, chatThreadListExpanded, expandedProjectThreadListCwds: [...expandedThreadListsByProject], + lastThreadRoute, + }); + }, [chatSectionExpanded, chatThreadListExpanded, expandedThreadListsByProject, lastThreadRoute]); + + useEffect(() => { + if (isOnWorkspace || isOnSettings || routeThreadId === null) { + return; + } + + const nextLastThreadRoute = { + threadId: routeThreadId, + ...(routeSearch.splitViewId ? { splitViewId: routeSearch.splitViewId } : {}), + }; + setLastThreadRoute((current) => { + if ( + current?.threadId === nextLastThreadRoute.threadId && + current?.splitViewId === nextLastThreadRoute.splitViewId + ) { + return current; + } + return nextLastThreadRoute; }); - }, [chatSectionExpanded, chatThreadListExpanded, expandedThreadListsByProject]); + }, [isOnSettings, isOnWorkspace, routeSearch.splitViewId, routeThreadId]); useEffect(() => { if (!activeSidebarThreadId) { diff --git a/apps/web/src/components/Sidebar.uiState.test.ts b/apps/web/src/components/Sidebar.uiState.test.ts index a4bc57b6..fa70d676 100644 --- a/apps/web/src/components/Sidebar.uiState.test.ts +++ b/apps/web/src/components/Sidebar.uiState.test.ts @@ -39,6 +39,7 @@ describe("Sidebar.uiState", () => { chatSectionExpanded: false, chatThreadListExpanded: false, expandedProjectThreadListCwds: [], + lastThreadRoute: null, }); }); @@ -51,6 +52,10 @@ describe("Sidebar.uiState", () => { "/Users/tester/Code/demo/", "/Users/tester/Code/other", ], + lastThreadRoute: { + threadId: "thread-123", + splitViewId: "split-456", + }, }); expect(readSidebarUiState()).toEqual({ @@ -60,6 +65,10 @@ describe("Sidebar.uiState", () => { normalizeSidebarProjectThreadListCwd("/Users/tester/Code/demo"), normalizeSidebarProjectThreadListCwd("/Users/tester/Code/other"), ], + lastThreadRoute: { + threadId: "thread-123", + splitViewId: "split-456", + }, }); }); @@ -70,6 +79,10 @@ describe("Sidebar.uiState", () => { chatSectionExpanded: true, chatThreadListExpanded: false, expandedProjectThreadListCwds: ["/Users/tester/Code/demo", 42, null, ""], + lastThreadRoute: { + threadId: "thread-123", + splitViewId: 42, + }, }), ); @@ -79,6 +92,28 @@ describe("Sidebar.uiState", () => { expandedProjectThreadListCwds: [ normalizeSidebarProjectThreadListCwd("/Users/tester/Code/demo"), ], + lastThreadRoute: { + threadId: "thread-123", + }, + }); + }); + + it("drops malformed persisted last thread routes", () => { + window.localStorage.setItem( + "t3code:sidebar-ui:v1", + JSON.stringify({ + lastThreadRoute: { + threadId: 42, + splitViewId: "split-123", + }, + }), + ); + + expect(readSidebarUiState()).toEqual({ + chatSectionExpanded: false, + chatThreadListExpanded: false, + expandedProjectThreadListCwds: [], + lastThreadRoute: null, }); }); }); diff --git a/apps/web/src/components/Sidebar.uiState.ts b/apps/web/src/components/Sidebar.uiState.ts index 8d37407f..061657f0 100644 --- a/apps/web/src/components/Sidebar.uiState.ts +++ b/apps/web/src/components/Sidebar.uiState.ts @@ -6,12 +6,17 @@ export type SidebarUiState = { chatSectionExpanded: boolean; chatThreadListExpanded: boolean; expandedProjectThreadListCwds: string[]; + lastThreadRoute: { + threadId: string; + splitViewId?: string | undefined; + } | null; }; const DEFAULT_SIDEBAR_UI_STATE: SidebarUiState = { chatSectionExpanded: false, chatThreadListExpanded: false, expandedProjectThreadListCwds: [], + lastThreadRoute: null, }; export function normalizeSidebarProjectThreadListCwd(cwd: string): string { @@ -33,8 +38,25 @@ export function readSidebarUiState(): SidebarUiState { chatSectionExpanded?: boolean; chatThreadListExpanded?: boolean; expandedProjectThreadListCwds?: string[]; + lastThreadRoute?: { + threadId?: unknown; + splitViewId?: unknown; + } | null; }; + const lastThreadRoute = + parsed.lastThreadRoute && + typeof parsed.lastThreadRoute.threadId === "string" && + parsed.lastThreadRoute.threadId.length > 0 + ? { + threadId: parsed.lastThreadRoute.threadId, + ...(typeof parsed.lastThreadRoute.splitViewId === "string" && + parsed.lastThreadRoute.splitViewId.length > 0 + ? { splitViewId: parsed.lastThreadRoute.splitViewId } + : {}), + } + : null; + return { chatSectionExpanded: parsed.chatSectionExpanded === true, chatThreadListExpanded: parsed.chatThreadListExpanded === true, @@ -46,6 +68,7 @@ export function readSidebarUiState(): SidebarUiState { .filter((cwd) => cwd.length > 0), ), ], + lastThreadRoute, }; } catch { return DEFAULT_SIDEBAR_UI_STATE; @@ -70,6 +93,14 @@ export function persistSidebarUiState(input: SidebarUiState): void { .filter((cwd) => cwd.length > 0), ), ], + lastThreadRoute: input.lastThreadRoute + ? { + threadId: input.lastThreadRoute.threadId, + ...(input.lastThreadRoute.splitViewId + ? { splitViewId: input.lastThreadRoute.splitViewId } + : {}), + } + : null, }), ); } catch { diff --git a/apps/web/src/components/WorkspaceView.tsx b/apps/web/src/components/WorkspaceView.tsx index 0e37407a..46117eb7 100644 --- a/apps/web/src/components/WorkspaceView.tsx +++ b/apps/web/src/components/WorkspaceView.tsx @@ -27,6 +27,7 @@ import { ensureTerminalIdsForPreset, type WorkspaceLayoutPresetId, } from "~/workspaceTerminalLayoutPresets"; +import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; function randomTerminalId(): string { if (typeof crypto.randomUUID === "function") { @@ -281,6 +282,7 @@ export default function WorkspaceView({ workspaceId }: { workspaceId: string }) if (!confirmed) { return; } + terminalRuntimeRegistry.disposeTerminal(threadId, terminalId); const fallbackExitWrite = () => api?.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index fbbd87ce..93540565 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2,6 +2,7 @@ import { MessageId, TurnId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { formatShortTimestamp } from "../../timestampFormat"; +import { COLLAPSED_USER_MESSAGE_MAX_CHARS } from "./userMessagePreview"; function matchMedia() { return { @@ -140,6 +141,53 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(" { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const hiddenTail = "TAIL_SHOULD_STAY_HIDDEN"; + const longText = `${"a".repeat(COLLAPSED_USER_MESSAGE_MAX_CHARS)}${hiddenTail}`; + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Show more"); + expect(markup).not.toContain(hiddenTail); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index deff0680..d01f3ee9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -93,6 +93,7 @@ import { resolveSubagentPresentation, } from "../../lib/subagentPresentation"; import { RiRobot3Line } from "react-icons/ri"; +import { deriveUserMessagePreviewState } from "./userMessagePreview"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const MAX_VISIBLE_INLINE_TOOL_ENTRIES = 4; @@ -212,6 +213,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedFileChangesByMessageId, setExpandedFileChangesByMessageId] = useState< Record >({}); + const [expandedUserMessagesById, setExpandedUserMessagesById] = useState>( + {}, + ); useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -398,6 +402,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (timelineWidthPx === null) return; scheduleVirtualizerMeasure(); }, [scheduleVirtualizerMeasure, timelineWidthPx]); + useEffect(() => { + scheduleVirtualizerMeasure(); + }, [expandedUserMessagesById, scheduleVirtualizerMeasure]); useLayoutEffect(() => { if (!scrollContainer || typeof ResizeObserver === "undefined") return; @@ -560,12 +567,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ text: selection.text, })); const terminalContexts = displayedUserMessage.contexts; + const userMessagePreview = deriveUserMessagePreviewState( + displayedUserMessage.visibleText, + { + expanded: expandedUserMessagesById[row.message.id] ?? false, + }, + ); const showUserText = - displayedUserMessage.visibleText.trim().length > 0 || terminalContexts.length > 0; + userMessagePreview.text.trim().length > 0 || terminalContexts.length > 0; const bubbleIsChipOnly = showUserText && terminalContexts.length === 0 && - hasOnlyInlineSkillChips(displayedUserMessage.visibleText); + hasOnlyInlineSkillChips(userMessagePreview.text); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
@@ -603,12 +616,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} >
)} + {userMessagePreview.collapsible && ( + + )}
{displayedUserMessage.copyText && ( diff --git a/apps/web/src/components/chat/userMessagePreview.test.ts b/apps/web/src/components/chat/userMessagePreview.test.ts new file mode 100644 index 00000000..fd289a2d --- /dev/null +++ b/apps/web/src/components/chat/userMessagePreview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + COLLAPSED_USER_MESSAGE_MAX_CHARS, + deriveUserMessagePreviewState, +} from "./userMessagePreview"; + +describe("userMessagePreview", () => { + it("keeps short user messages untouched", () => { + expect(deriveUserMessagePreviewState("short message")).toEqual({ + text: "short message", + collapsible: false, + truncated: false, + }); + }); + + it("truncates collapsed user messages to the message-only 600-char budget", () => { + const text = `${"a".repeat(COLLAPSED_USER_MESSAGE_MAX_CHARS)}tail`; + + expect(deriveUserMessagePreviewState(text)).toEqual({ + text: "a".repeat(COLLAPSED_USER_MESSAGE_MAX_CHARS), + collapsible: true, + truncated: true, + }); + }); + + it("returns the full text again once the message is expanded", () => { + const text = `${"a".repeat(COLLAPSED_USER_MESSAGE_MAX_CHARS)}tail`; + + expect(deriveUserMessagePreviewState(text, { expanded: true })).toEqual({ + text, + collapsible: true, + truncated: false, + }); + }); +}); diff --git a/apps/web/src/components/chat/userMessagePreview.ts b/apps/web/src/components/chat/userMessagePreview.ts new file mode 100644 index 00000000..f01c1948 --- /dev/null +++ b/apps/web/src/components/chat/userMessagePreview.ts @@ -0,0 +1,37 @@ +export const COLLAPSED_USER_MESSAGE_MAX_CHARS = 600; + +export interface UserMessagePreviewState { + text: string; + collapsible: boolean; + truncated: boolean; +} + +export function deriveUserMessagePreviewState( + text: string, + options?: { + expanded?: boolean; + maxChars?: number; + }, +): UserMessagePreviewState { + const expanded = options?.expanded ?? false; + const requestedMaxChars = options?.maxChars; + const safeMaxChars = + typeof requestedMaxChars === "number" && Number.isFinite(requestedMaxChars) + ? Math.floor(requestedMaxChars) + : COLLAPSED_USER_MESSAGE_MAX_CHARS; + const maxChars = Math.max(0, safeMaxChars); + + if (expanded || text.length <= maxChars) { + return { + text, + collapsible: text.length > maxChars, + truncated: false, + }; + } + + return { + text: text.slice(0, maxChars), + collapsible: true, + truncated: true, + }; +} diff --git a/apps/web/src/components/terminal/terminalRuntimeRegistry.ts b/apps/web/src/components/terminal/terminalRuntimeRegistry.ts index f6919de9..921d56a3 100644 --- a/apps/web/src/components/terminal/terminalRuntimeRegistry.ts +++ b/apps/web/src/components/terminal/terminalRuntimeRegistry.ts @@ -19,6 +19,7 @@ import type { TerminalRuntimeEntry, TerminalRuntimeViewState, } from "./terminalRuntimeTypes"; +import { buildTerminalRuntimeKey } from "./terminalRuntimeTypes"; export { buildTerminalRuntimeKey, type TerminalRuntimeCallbacks } from "./terminalRuntimeTypes"; @@ -72,6 +73,18 @@ class TerminalRuntimeRegistry { this.entries.delete(runtimeKey); } + disposeTerminal(threadId: string, terminalId: string): void { + this.dispose(buildTerminalRuntimeKey(threadId, terminalId)); + } + + disposeThread(threadId: string): void { + for (const runtimeKey of [...this.entries.keys()]) { + if (runtimeKey.startsWith(`${threadId}::`)) { + this.dispose(runtimeKey); + } + } + } + focus(runtimeKey: string): void { this.entries.get(runtimeKey)?.terminal.focus(); } diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 1da68a7d..6b8d5202 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -8,6 +8,7 @@ import { DEFAULT_CHAT_FONT_SIZE_PX, normalizeChatFontSizePx } from "../appSettin import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../lib/turnDiffTree"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; +import { deriveUserMessagePreviewState } from "./chat/userMessagePreview"; import { getChatTranscriptAssistantCharWidthPx, getChatTranscriptLineHeightPx, @@ -22,6 +23,7 @@ const USER_ATTACHMENT_THUMBNAIL_SIZE_PX = 60; const USER_ATTACHMENT_THUMBNAIL_GAP_PX = 8; const USER_ATTACHMENT_THUMBNAILS_PER_ROW = 4; const USER_ATTACHMENT_ROW_MARGIN_BOTTOM_PX = 4; +const USER_MESSAGE_TOGGLE_HEIGHT_PX = 20; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; @@ -282,15 +284,13 @@ export function estimateTimelineMessageHeight( const displayedUserMessage = deriveDisplayedUserMessageState(message.text, { hideImageOnlyBootstrapPrompt: (message.attachments?.length ?? 0) > 0, }); + const userMessagePreview = deriveUserMessagePreviewState(displayedUserMessage.visibleText); const renderedText = displayedUserMessage.contexts.length > 0 - ? [ - buildInlineTerminalContextText(displayedUserMessage.contexts), - displayedUserMessage.visibleText, - ] + ? [buildInlineTerminalContextText(displayedUserMessage.contexts), userMessagePreview.text] .filter((part) => part.length > 0) .join(" ") - : displayedUserMessage.visibleText; + : userMessagePreview.text; const estimatedLines = renderedText.length > 0 ? estimateWrappedLineCount(renderedText, charsPerLine) : 0; const imageAttachmentCount = @@ -312,7 +312,8 @@ export function estimateTimelineMessageHeight( assistantSelectionHeight + (renderedText.length > 0 ? USER_ATTACHMENT_ROW_MARGIN_BOTTOM_PX : 0) : 0; - return USER_BASE_HEIGHT_PX + estimatedLines * lineHeightPx + attachmentHeight; + const toggleHeight = userMessagePreview.collapsible ? USER_MESSAGE_TOGGLE_HEIGHT_PX : 0; + return USER_BASE_HEIGHT_PX + estimatedLines * lineHeightPx + attachmentHeight + toggleHeight; } // `system` messages are not rendered in the chat timeline, but keep a stable diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index d50fa893..3c4ac1b7 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -381,6 +381,38 @@ describe("deriveActivePlanState", () => { expect(deriveActivePlanState(activities, TurnId.makeUnsafe("turn-2"))).toBeNull(); }); + + it("does not revive an unfinished prior-turn plan once that turn has completed", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unfinished-plan-from-turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [ + { step: "Inspect theme implementation", status: "pending" }, + { step: "Patch token plumbing", status: "pending" }, + ], + }, + }), + makeActivity({ + id: "turn-1-completed", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.completed", + summary: "Turn completed", + tone: "info", + turnId: "turn-1", + payload: { + state: "completed", + }, + }), + ]; + + expect(deriveActivePlanState(activities, TurnId.makeUnsafe("turn-2"))).toBeNull(); + }); }); describe("deriveActiveBackgroundTasksState", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index bc1ebe15..daae90bf 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -9,7 +9,6 @@ import { type UserInputQuestion, type ThreadId, type TurnId, - PROVIDER_DISPLAY_NAMES, } from "@t3tools/contracts"; import { decodeSubagentAgentStates, @@ -403,6 +402,17 @@ export function deriveActivePlanState( ): ActivePlanState | null { const ordered = [...activities].toSorted(compareActivitiesByOrder); const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + const settledTurnIds = new Set(); + + // A prior-turn plan only stays visible while that originating turn is still unresolved. + for (const activity of ordered) { + if (!activity.turnId) { + continue; + } + if (activity.kind === "turn.completed" || activity.kind === "turn.aborted") { + settledTurnIds.add(activity.turnId); + } + } const toActivePlanState = (activity: OrchestrationThreadActivity): ActivePlanState | null => { const payload = @@ -468,6 +478,10 @@ export function deriveActivePlanState( return null; } + if (latestPriorPlan.turnId && settledTurnIds.has(latestPriorPlan.turnId)) { + return null; + } + return latestPriorPlan.steps.some((step) => step.status !== "completed") ? latestPriorPlan : null; } From 8df5dc7267274bcaa08ac32dfc3bef7d2e2cdcea Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 19 Apr 2026 12:46:47 +0200 Subject: [PATCH 2/8] Bump version and add 0.0.31 release notes - Publish 0.0.31 across desktop, server, web, and contracts - Add 0.0.31 whats new entry and update release icons --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- apps/web/src/components/WhatsNewDialog.tsx | 4 +-- apps/web/src/lib/icons.tsx | 2 ++ apps/web/src/whatsNew/WhatsNewPopoutCard.tsx | 4 +-- apps/web/src/whatsNew/entries.ts | 36 ++++++++++++++++++++ packages/contracts/package.json | 2 +- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index afee12f7..f7591db9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.30", + "version": "0.0.31", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index f40cd32b..d58f27db 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.30", + "version": "0.0.31", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 9ac5295d..ed5914bc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.30", + "version": "0.0.31", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/WhatsNewDialog.tsx b/apps/web/src/components/WhatsNewDialog.tsx index aa50229c..8395bc22 100644 --- a/apps/web/src/components/WhatsNewDialog.tsx +++ b/apps/web/src/components/WhatsNewDialog.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; -import { ArrowLeftIcon, ArrowRightIcon, RocketIcon } from "~/lib/icons"; +import { ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon } from "~/lib/icons"; import { ChangelogAccordion } from "../whatsNew/ChangelogAccordion"; import { FeatureSection } from "../whatsNew/FeatureSection"; @@ -127,7 +127,7 @@ function CurrentHeader({ return (
- +
What’s new? diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx index 430a8b46..bcfc4c1b 100644 --- a/apps/web/src/lib/icons.tsx +++ b/apps/web/src/lib/icons.tsx @@ -12,6 +12,7 @@ import { IconArrowDown, IconArrowLeft, IconArrowRight, + IconArrowUp, IconArrowsUpDown, IconBell, IconBolt, @@ -109,6 +110,7 @@ export const ArrowLeftIcon = adaptIcon(IconArrowLeft); export const BellIcon = adaptIcon(IconBell); export const ArrowRightIcon = adaptIcon(IconArrowRight); export const ArrowDownIcon = adaptIcon(IconArrowDown); +export const ArrowUpIcon = adaptIcon(IconArrowUp); export const ArrowUpDownIcon = adaptIcon(IconArrowsUpDown); export const BotIcon = adaptIcon(IconRobot); export const BugIcon = adaptIcon(IconBug); diff --git a/apps/web/src/whatsNew/WhatsNewPopoutCard.tsx b/apps/web/src/whatsNew/WhatsNewPopoutCard.tsx index b3197d08..2635f667 100644 --- a/apps/web/src/whatsNew/WhatsNewPopoutCard.tsx +++ b/apps/web/src/whatsNew/WhatsNewPopoutCard.tsx @@ -7,7 +7,7 @@ import { type KeyboardEvent } from "react"; -import { RocketIcon, XIcon } from "~/lib/icons"; +import { ArrowUpIcon, XIcon } from "~/lib/icons"; import { cn } from "~/lib/utils"; import type { WhatsNewEntry } from "./logic"; @@ -115,7 +115,7 @@ export function WhatsNewPopoutCard({ className="flex h-full w-full items-center justify-center bg-[radial-gradient(120%_140%_at_10%_0%,color-mix(in_srgb,var(--color-primary)_38%,transparent)_0%,transparent_60%),radial-gradient(100%_120%_at_100%_100%,color-mix(in_srgb,var(--color-primary)_22%,transparent)_0%,transparent_70%)]" > - +
)} diff --git a/apps/web/src/whatsNew/entries.ts b/apps/web/src/whatsNew/entries.ts index bb103f03..5ada6c2a 100644 --- a/apps/web/src/whatsNew/entries.ts +++ b/apps/web/src/whatsNew/entries.ts @@ -21,6 +21,42 @@ import type { WhatsNewEntry } from "./logic"; export const WHATS_NEW_ENTRIES: readonly WhatsNewEntry[] = [ + { + version: "0.0.31", + date: "Apr 19", + features: [ + { + id: "gemini-provider-support", + title: "♊ Gemini support is here", + description: + "Use Gemini alongside Codex and Claude Agent, with provider-aware models and handoff support built into the app.", + }, + { + id: "custom-provider-binaries", + title: "🛠️ Custom binary paths for every provider", + description: + "Point DP Code at your own Codex, Claude, or Gemini binary when your setup lives outside the default install path.", + }, + { + id: "assistant-selections-as-context", + title: "📎 Reuse assistant replies as attachments", + description: + "Select parts of an assistant response and send them back as structured context in your next prompt.", + }, + { + id: "stronger-thread-continuity", + title: "🧵 Better thread continuity", + description: + "The app now remembers your last open thread, carries pull request context into draft threads, and keeps sidebar state more stable.", + }, + { + id: "stability-and-update-polish", + title: "🩹 Smoother recovery and update checks", + description: + "Project creation recovery, foreground update checks, and a few rough edges around long messages and download state have been tightened up.", + }, + ], + }, { version: "0.0.30", date: "Apr 18", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f460e6d1..4cd79dfa 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.30", + "version": "0.0.31", "private": true, "files": [ "dist" From 86c9d91f926b8a5be0c765e2067c71c3f9a80893 Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 19 Apr 2026 13:01:00 +0200 Subject: [PATCH 3/8] test: fix stale CI expectations --- .../Layers/ProjectionSnapshotQuery.test.ts | 2 ++ .../src/orchestration/projector.test.ts | 1 + .../web/src/components/timelineHeight.test.ts | 24 ++++++++++++++----- apps/web/src/composerDraftStore.test.ts | 2 ++ apps/web/src/lib/providerAvailability.test.ts | 2 +- apps/web/src/lib/threadBootstrap.test.ts | 10 +++++--- 6 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index f618d744..789e3bb3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -307,6 +307,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestUserMessageAt: "2026-02-24T00:00:03.500Z", hasPendingApprovals: true, hasPendingUserInput: true, @@ -746,6 +747,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestTurn: { turnId: asTurnId("turn-shell"), state: "completed", diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index feea2d29..7967efcd 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -93,6 +93,7 @@ describe("orchestration projector", () => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index fde9ee35..a04c2d59 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -32,7 +32,7 @@ describe("estimateTimelineMessageHeight", () => { estimateTimelineMessageHeight({ role: "user", text: "hello", - attachments: [{ id: "1" }], + attachments: [{ id: "1", type: "image" }], }), ).toBe(180); @@ -40,7 +40,10 @@ describe("estimateTimelineMessageHeight", () => { estimateTimelineMessageHeight({ role: "user", text: "hello", - attachments: [{ id: "1" }, { id: "2" }], + attachments: [ + { id: "1", type: "image" }, + { id: "2", type: "image" }, + ], }), ).toBe(180); }); @@ -50,7 +53,11 @@ describe("estimateTimelineMessageHeight", () => { estimateTimelineMessageHeight({ role: "user", text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], + attachments: [ + { id: "1", type: "image" }, + { id: "2", type: "image" }, + { id: "3", type: "image" }, + ], }), ).toBe(180); @@ -58,18 +65,23 @@ describe("estimateTimelineMessageHeight", () => { estimateTimelineMessageHeight({ role: "user", text: "hello", - attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], + attachments: [ + { id: "1", type: "image" }, + { id: "2", type: "image" }, + { id: "3", type: "image" }, + { id: "4", type: "image" }, + ], }), ).toBe(180); }); - it("does not cap long user message estimates", () => { + it("caps long user message estimates to the collapsed preview", () => { expect( estimateTimelineMessageHeight({ role: "user", text: "a".repeat(56 * 120), }), - ).toBe(2496); + ).toBe(336); }); it("counts explicit newlines for user message estimates", () => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e664dbaa..d8385de3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -618,6 +618,7 @@ describe("composerDraftStore project draft thread mapping", () => { runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", + lastKnownPr: null, }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ projectId, @@ -628,6 +629,7 @@ describe("composerDraftStore project draft thread mapping", () => { runtimeMode: "full-access", interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", + lastKnownPr: null, }); }); diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index 97c40a43..b72ba91b 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -29,7 +29,7 @@ describe("normalizeProviderStatusForLocalConfig", () => { available: true, status: "warning", message: - "Gemini uses a custom local binary path in this app. Availability will be confirmed when you start a Gemini session.", + "Gemini uses a custom local binary path in this app. Availability will be confirmed when you start a session.", }); }); diff --git a/apps/web/src/lib/threadBootstrap.test.ts b/apps/web/src/lib/threadBootstrap.test.ts index acf609ac..7eb728dd 100644 --- a/apps/web/src/lib/threadBootstrap.test.ts +++ b/apps/web/src/lib/threadBootstrap.test.ts @@ -156,10 +156,13 @@ describe("threadBootstrap", () => { modelSelection: modelSelection("codex", "gpt-5"), runtimeMode: "full-access", interactionMode: "default", + envMode: undefined, + lastKnownPr: null, + }); + expect(createActiveDraftThreadSnapshot(makeDraftThread(), PROJECT_ID)).toEqual({ + ...makeDraftThread(), + lastKnownPr: null, }); - expect(createActiveDraftThreadSnapshot(makeDraftThread(), PROJECT_ID)).toEqual( - makeDraftThread(), - ); }); it("builds the fresh draft seed from creation inputs", () => { @@ -228,6 +231,7 @@ describe("threadBootstrap", () => { envMode: "worktree", branch: "feature/terminal-bootstrap", worktreePath: "/repo/.worktrees/terminal-bootstrap", + lastKnownPr: null, }); }); From ff386a09d12613bbe9af6bc4cf3ce8e3b88e66dd Mon Sep 17 00:00:00 2001 From: Emanuele Di Pietro Date: Sun, 19 Apr 2026 14:51:43 +0200 Subject: [PATCH 4/8] Refine desktop update checks and sidebar thread badges - Add timeout and foreground debounce for desktop update checks - Restore last thread routes and prune collapsed project state - Compact sidebar meta badges and simplify chat user bubble styling --- apps/desktop/src/main.ts | 66 +++- apps/desktop/src/updateState.test.ts | 16 + apps/desktop/src/updateState.ts | 9 +- apps/web/src/components/Sidebar.logic.test.ts | 59 +++ apps/web/src/components/Sidebar.logic.ts | 50 +++ apps/web/src/components/Sidebar.tsx | 361 +++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 2 +- 7 files changed, 405 insertions(+), 158 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3129572..a13947c5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -109,6 +109,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS = 5 * 60 * 1000; +const AUTO_UPDATE_FOREGROUND_RECHECK_MIN_BACKGROUND_MS = 30 * 1000; +const AUTO_UPDATE_CHECK_TIMEOUT_MS = 45 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; @@ -358,6 +360,7 @@ let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); let updateBackgroundedAtMs: number | null = null; let updateBackgroundBlurTimer: ReturnType | null = null; +let updateCheckTimeoutTimer: ReturnType | null = null; function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateDownloadInFlight) return "download"; @@ -928,15 +931,6 @@ function shouldEnableAutoUpdates(): boolean { ); } -function shouldTriggerForegroundUpdateCheck(foregroundedAtMs: number): boolean { - return shouldCheckForUpdatesOnForeground({ - checkedAt: updateState.checkedAt, - backgroundedAtMs: updateBackgroundedAtMs, - foregroundedAtMs, - minIntervalMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS, - }); -} - function clearUpdateBackgroundBlurTimer(): void { if (updateBackgroundBlurTimer) { clearTimeout(updateBackgroundBlurTimer); @@ -944,6 +938,34 @@ function clearUpdateBackgroundBlurTimer(): void { } } +// Fail closed if electron-updater never emits a terminal check outcome. +function clearUpdateCheckTimeoutTimer(): void { + if (updateCheckTimeoutTimer) { + clearTimeout(updateCheckTimeoutTimer); + updateCheckTimeoutTimer = null; + } +} + +function armUpdateCheckTimeout(reason: string): void { + clearUpdateCheckTimeoutTimer(); + updateCheckTimeoutTimer = setTimeout(() => { + updateCheckTimeoutTimer = null; + if (updateState.status !== "checking") { + return; + } + updateCheckInFlight = false; + setUpdateState( + reduceDesktopUpdateStateOnCheckFailure( + updateState, + "Timed out while checking for updates. Try again.", + new Date().toISOString(), + ), + ); + console.error(`[desktop-updater] Update check timed out (${reason}).`); + }, AUTO_UPDATE_CHECK_TIMEOUT_MS); + updateCheckTimeoutTimer.unref(); +} + function isDesktopAppForegrounded(): boolean { return BrowserWindow.getAllWindows().some( (window) => !window.isDestroyed() && window.isFocused(), @@ -965,16 +987,28 @@ function handleDesktopAppForegrounded(): void { clearUpdateBackgroundBlurTimer(); clearUnreadNotificationBadge(); const foregroundedAtMs = Date.now(); - if (!shouldTriggerForegroundUpdateCheck(foregroundedAtMs)) { + const backgroundedAtMs = updateBackgroundedAtMs; + updateBackgroundedAtMs = null; + const shouldCheck = shouldCheckForUpdatesOnForeground({ + checkedAt: updateState.checkedAt, + backgroundedAtMs, + foregroundedAtMs, + minBackgroundDurationMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_BACKGROUND_MS, + minIntervalMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS, + }); + if (!shouldCheck) { return; } - updateBackgroundedAtMs = null; void checkForUpdates("foreground"); } async function checkForUpdates(reason: string): Promise { if (isQuitting || !updaterConfigured || updateCheckInFlight) return; - if (updateState.status === "downloading" || updateState.status === "downloaded") { + if ( + updateState.status === "checking" || + updateState.status === "downloading" || + updateState.status === "downloaded" + ) { console.info( `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, ); @@ -982,11 +1016,13 @@ async function checkForUpdates(reason: string): Promise { } updateCheckInFlight = true; setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); + armUpdateCheckTimeout(reason); console.info(`[desktop-updater] Checking for updates (${reason})...`); try { await autoUpdater.checkForUpdates(); } catch (error: unknown) { + clearUpdateCheckTimeoutTimer(); const message = error instanceof Error ? error.message : String(error); setUpdateState( reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), @@ -1087,6 +1123,7 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + clearUpdateCheckTimeoutTimer(); setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -1098,11 +1135,13 @@ function configureAutoUpdater(): void { console.info(`[desktop-updater] Update available: ${info.version}`); }); autoUpdater.on("update-not-available", () => { + clearUpdateCheckTimeoutTimer(); setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available."); }); autoUpdater.on("error", (error) => { + clearUpdateCheckTimeoutTimer(); const message = formatErrorMessage(error); if (!updateCheckInFlight && !updateDownloadInFlight) { setUpdateState({ @@ -1763,6 +1802,7 @@ app.on("before-quit", () => { isQuitting = true; writeDesktopLogHeader("before-quit received"); clearUpdateBackgroundBlurTimer(); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); @@ -1814,6 +1854,7 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdateBackgroundBlurTimer(); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); @@ -1825,6 +1866,7 @@ if (process.platform !== "win32") { if (isQuitting) return; isQuitting = true; writeDesktopLogHeader("SIGTERM received"); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index a409e385..da0b206f 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -181,6 +181,7 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: "2026-03-04T00:00:00.000Z", backgroundedAtMs: null, foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(false); @@ -192,17 +193,31 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: null, backgroundedAtMs: Date.parse("2026-03-04T00:00:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(true); }); + it("returns false when the app was backgrounded too briefly", () => { + expect( + shouldCheckForUpdatesOnForeground({ + checkedAt: "2026-03-04T00:00:00.000Z", + backgroundedAtMs: Date.parse("2026-03-04T00:04:45.000Z"), + foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, + minIntervalMs: 5 * 60 * 1000, + }), + ).toBe(false); + }); + it("returns false when the last check is still within the foreground cooldown", () => { expect( shouldCheckForUpdatesOnForeground({ checkedAt: "2026-03-04T00:03:00.000Z", backgroundedAtMs: Date.parse("2026-03-04T00:04:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:06:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(false); @@ -214,6 +229,7 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: "2026-03-04T00:00:00.000Z", backgroundedAtMs: Date.parse("2026-03-04T00:04:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:06:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(true); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index adcf4785..329136b7 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -32,13 +32,20 @@ export function shouldCheckForUpdatesOnForeground(args: { checkedAt: string | null; backgroundedAtMs: number | null; foregroundedAtMs: number; + minBackgroundDurationMs: number; minIntervalMs: number; }): boolean { - const { checkedAt, backgroundedAtMs, foregroundedAtMs, minIntervalMs } = args; + const { checkedAt, backgroundedAtMs, foregroundedAtMs, minBackgroundDurationMs, minIntervalMs } = + args; if (backgroundedAtMs === null || foregroundedAtMs <= backgroundedAtMs) { return false; } + // Ignore fleeting blur/focus churn from window transitions and native dialogs. + if (foregroundedAtMs - backgroundedAtMs < minBackgroundDurationMs) { + return false; + } + if (checkedAt === null) { return true; } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5e62cb5a..4804ce97 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,8 +18,10 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isDuplicateProjectCreateError, + pruneExpandedProjectThreadListsForCollapsedProjects, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, + resolveSidebarRestorableThreadRoute, resolveThreadRowClassName, resolveThreadStatusPill, shouldPrunePinnedThreads, @@ -110,6 +112,63 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveSidebarRestorableThreadRoute", () => { + it("returns the last thread route when the thread still exists", () => { + expect( + resolveSidebarRestorableThreadRoute({ + lastThreadRoute: { + threadId: "thread-123", + splitViewId: "split-456", + }, + availableThreadIds: new Set(["thread-123", "thread-789"]), + }), + ).toEqual({ + threadId: "thread-123", + splitViewId: "split-456", + }); + }); + + it("returns null when the remembered thread no longer exists", () => { + expect( + resolveSidebarRestorableThreadRoute({ + lastThreadRoute: { + threadId: "thread-123", + }, + availableThreadIds: new Set(["thread-789"]), + }), + ).toBeNull(); + }); +}); + +describe("pruneExpandedProjectThreadListsForCollapsedProjects", () => { + it("clears remembered show-more state when a project is collapsed", () => { + const current = new Set(["/Users/tester/Code/one", "/Users/tester/Code/two"]); + + const next = pruneExpandedProjectThreadListsForCollapsedProjects({ + expandedProjectThreadListCwds: current, + projects: [ + { cwd: "/Users/tester/Code/one", expanded: false }, + { cwd: "/Users/tester/Code/two", expanded: true }, + ], + normalizeProjectCwd: (cwd) => cwd.replace(/\/+$/, ""), + }); + + expect([...next]).toEqual(["/Users/tester/Code/two"]); + }); + + it("preserves the existing set when no collapsed project needs pruning", () => { + const current = new Set(["/Users/tester/Code/one"]); + + const next = pruneExpandedProjectThreadListsForCollapsedProjects({ + expandedProjectThreadListCwds: current, + projects: [{ cwd: "/Users/tester/Code/one", expanded: true }], + normalizeProjectCwd: (cwd) => cwd.replace(/\/+$/, ""), + }); + + expect(next).toBe(current); + }); +}); + describe("add-project error helpers", () => { it("finds an existing project by workspace root", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 680c6a7c..e5f5d969 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,6 +23,10 @@ export { export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarLastThreadRoute = { + threadId: string; + splitViewId?: string | undefined; +}; type SidebarProject = { id: string; name: string; @@ -102,6 +106,52 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +// Reuses the last visited thread route when leaving special views like settings. +export function resolveSidebarRestorableThreadRoute(input: { + lastThreadRoute: SidebarLastThreadRoute | null; + availableThreadIds: ReadonlySet; +}): SidebarLastThreadRoute | null { + const { lastThreadRoute, availableThreadIds } = input; + if (!lastThreadRoute) { + return null; + } + + return availableThreadIds.has(lastThreadRoute.threadId) ? lastThreadRoute : null; +} + +// Drops remembered "show more" state for projects that are currently collapsed. +export function pruneExpandedProjectThreadListsForCollapsedProjects< + T extends Pick, +>(input: { + expandedProjectThreadListCwds: ReadonlySet; + projects: readonly T[]; + normalizeProjectCwd: (cwd: string) => string; +}): ReadonlySet { + const { expandedProjectThreadListCwds, normalizeProjectCwd, projects } = input; + const collapsedProjectCwds = new Set( + projects + .filter((project) => !project.expanded) + .map((project) => normalizeProjectCwd(project.cwd)) + .filter((cwd) => cwd.length > 0), + ); + + if (collapsedProjectCwds.size === 0) { + return expandedProjectThreadListCwds; + } + + let changed = false; + const nextExpandedProjectThreadListCwds = new Set(); + for (const cwd of expandedProjectThreadListCwds) { + if (collapsedProjectCwds.has(cwd)) { + changed = true; + continue; + } + nextExpandedProjectThreadListCwds.add(cwd); + } + + return changed ? nextExpandedProjectThreadListCwds : expandedProjectThreadListCwds; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 52bd0f0c..18251d95 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -168,8 +168,10 @@ import { getSidebarThreadIdsToPrewarm, getVisibleSidebarEntriesForPreview, getUnpinnedThreadsForSidebar, + pruneExpandedProjectThreadListsForCollapsedProjects, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, + resolveSidebarRestorableThreadRoute, resolveThreadRowClassName, resolveThreadStatusPill, isDuplicateProjectCreateError, @@ -310,25 +312,6 @@ function ProviderGlyph({ provider, className }: { provider: ProviderKind; classN return
+ } + /> + {tooltipText} + + ); +} - return null; +function ProviderAvatarWithTerminal({ + provider, + handoffSourceProvider, + handoffTooltip, + terminalStatus, + terminalCount, +}: { + provider: ProviderKind; + handoffSourceProvider?: ProviderKind | null; + handoffTooltip?: string | null; + terminalStatus: TerminalStatusIndicator | null; + terminalCount: number; +}) { + const showBadge = terminalCount > 1 || terminalStatus !== null; + const badgeTooltip = + terminalCount > 1 + ? `${terminalCount} terminal${terminalCount === 1 ? "" : "s"} open` + : (terminalStatus?.label ?? "Terminal open"); + const badgeColorClass = terminalStatus?.colorClass ?? "text-muted-foreground/55"; + + const hasHandoff = Boolean(handoffSourceProvider); + const containerClass = hasHandoff + ? "relative inline-flex h-3.5 w-5 shrink-0 items-center" + : "relative inline-flex size-3.5 shrink-0 items-center justify-center"; + + const avatarNode = hasHandoff ? ( + + + + + + + + + ) : ( + + + + ); + + const wrappedAvatar = + hasHandoff && handoffTooltip ? ( + + + {handoffTooltip} + + ) : ( + avatarNode + ); + + return ( + + {wrappedAvatar} + {showBadge ? ( + + + {terminalCount > 1 ? ( + + {terminalCount} + + ) : ( + + )} + + } + /> + {badgeTooltip} + + ) : null} + + ); } type SidebarSplitPreview = { @@ -1512,18 +1615,19 @@ export default function Sidebar() { return; } - if (lastThreadRoute) { - const restorableThread = sidebarThreadSummaryById[lastThreadRoute.threadId] ?? null; - if (restorableThread) { - void navigate({ - to: "/$threadId", - params: { threadId: ThreadId.makeUnsafe(lastThreadRoute.threadId) }, - search: () => ({ - splitViewId: lastThreadRoute.splitViewId, - }), - }); - return; - } + const restorableRoute = resolveSidebarRestorableThreadRoute({ + lastThreadRoute, + availableThreadIds: new Set(Object.keys(sidebarThreadSummaryById)), + }); + if (restorableRoute) { + void navigate({ + to: "/$threadId", + params: { threadId: ThreadId.makeUnsafe(restorableRoute.threadId) }, + search: () => ({ + splitViewId: restorableRoute.splitViewId, + }), + }); + return; } void handleNewChat({ fresh: true }); @@ -3230,6 +3334,17 @@ export default function Sidebar() { [standardProjects], ); + // Reset per-project preview expansion when a folder closes so reopening starts at five rows again. + useEffect(() => { + setExpandedThreadListsByProject((current) => + pruneExpandedProjectThreadListsForCollapsedProjects({ + expandedProjectThreadListCwds: current, + projects: standardProjects, + normalizeProjectCwd: normalizeSidebarProjectThreadListCwd, + }), + ); + }, [standardProjects]); + useEffect(() => { if (!shouldPrunePinnedThreads({ threadsHydrated })) { return; @@ -3559,14 +3674,17 @@ export default function Sidebar() { } function renderPinnedThreadRow(thread: SidebarThreadSummary) { - const threadEntryPoint = selectThreadTerminalState( - terminalStateByThreadId, - thread.id, - ).entryPoint; + const threadTerminalState = selectThreadTerminalState(terminalStateByThreadId, thread.id); + const threadEntryPoint = threadTerminalState.entryPoint; + const terminalStatus = terminalStatusFromThreadState({ + runningTerminalIds: threadTerminalState.runningTerminalIds, + terminalAttentionStatesById: threadTerminalState.terminalAttentionStatesById, + }); + const terminalCount = threadTerminalState.terminalIds.length; const isPendingArchiveConfirmation = pendingArchiveConfirmationThreadId === thread.id; const isActive = !activeSplitView && routeThreadId === thread.id; const projectLabel = resolvePinnedThreadProjectLabel(thread.projectId); - const rightMetaBadge = resolveThreadRowMetaBadge({ + const rightMetaChips = resolveThreadRowMetaChips({ thread, includeHandoffBadge: true, }); @@ -3578,6 +3696,7 @@ export default function Sidebar() { const isSubagentThread = Boolean(thread.parentThreadId); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const leadingPrStatus = isSubagentThread || thread.forkSourceThreadId ? null : prStatus; + const handoffBadgeLabel = resolveThreadHandoffBadgeLabel(thread); const threadJumpLabel = visibleThreadJumpLabelByThreadId.get(thread.id) ?? null; const threadJumpLabelParts = visibleThreadJumpLabelPartsByThreadId.get(thread.id) ?? EMPTY_SHORTCUT_PARTS; @@ -3592,7 +3711,7 @@ export default function Sidebar() { tabIndex={0} data-thread-item className={cn( - "grid h-8 w-full grid-cols-[auto_auto_minmax(0,1fr)_6rem_5.5rem] items-center gap-x-2 rounded-md px-2 text-left text-[length:var(--app-font-size-ui,12px)] transition-colors cursor-pointer", + "grid h-8 w-full grid-cols-[auto_auto_minmax(0,1fr)_3.5rem_3.5rem] items-center gap-x-2 rounded-md px-2 text-left text-[length:var(--app-font-size-ui,12px)] transition-colors cursor-pointer", isActive ? "bg-accent/62 text-foreground/90 dark:bg-accent/42" : "text-foreground/72 hover:bg-accent/40 hover:text-foreground/90", @@ -3634,9 +3753,12 @@ export default function Sidebar() { {threadEntryPoint === "terminal" ? (