diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 63a6fe4ad693..56490383db78 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -724,6 +724,38 @@ describe("sortThreadsForSidebarV2", () => { expect(sorted.map((thread) => thread.id)).toEqual(["b", "a"]); }); + + it("lifts a chat by its worktree's recorded activity so a closed sibling doesn't sink the row", () => { + // `wt-old` is a surviving chat in a worktree whose newer sibling was just + // closed; `other` is a more recently active chat in a different worktree. + // Without the recorded close activity, `wt-old` would sort below `other`. + const wtOld = { + id: "wt-old", + environmentId: "env", + worktreePath: "/repo/wt", + createdAt: "2026-03-09T08:00:00.000Z", + updatedAt: "2026-03-09T08:00:00.000Z", + latestUserMessageAt: "2026-03-09T09:00:00.000Z", + }; + const other = { + id: "other", + environmentId: "env", + worktreePath: "/repo/other", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + latestUserMessageAt: "2026-03-09T11:00:00.000Z", + }; + + expect(sortThreadsForSidebarV2([wtOld, other]).map((thread) => thread.id)).toEqual([ + "other", + "wt-old", + ]); + expect( + sortThreadsForSidebarV2([wtOld, other], { + "env\0/repo/wt": "2026-03-09T12:00:00.000Z", + }).map((thread) => thread.id), + ).toEqual(["wt-old", "other"]); + }); }); describe("sortSettledThreadsForSidebarV2", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 12171d5881d3..1bf658ed793a 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -12,6 +12,7 @@ import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import { worktreeActivityKey } from "../uiStateStore"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -475,10 +476,45 @@ export function firstValidTimestamp( // Keep the chats users touched most recently at the top of each project. The // project grouping step preserves this incoming order within every section. -export function sortThreadsForSidebarV2( - threads: readonly T[], -): T[] { - return sortThreads(threads, "updated_at"); +// +// `worktreeLastActivityAtByKey` folds a locally-recorded worktree interaction +// (e.g. closing a chat) into each chat's effective sort time. A worktree row is +// positioned by its newest surviving chat — collapsing keeps the group at that +// chat's slot — so closing the newest chat would otherwise sink the row to an +// older sibling's timestamp even though closing is a recent interaction. Taking +// the max of the chat's own time and its worktree's recorded activity keeps the +// row in place. Callers that omit the map keep the plain activity sort. +export function sortThreadsForSidebarV2< + T extends { + readonly id: string; + readonly environmentId?: string; + readonly worktreePath?: string | null; + } & ThreadSortInput, +>(threads: readonly T[], worktreeLastActivityAtByKey?: Readonly>): T[] { + if (!worktreeLastActivityAtByKey) { + return sortThreads(threads, "updated_at"); + } + const effectiveTimestamp = (thread: T): number => { + const base = getThreadSortTimestamp(thread, "updated_at"); + const { environmentId, worktreePath } = thread; + if (environmentId == null || worktreePath == null) { + return base; + } + const activityAt = + worktreeLastActivityAtByKey[worktreeActivityKey(environmentId, worktreePath)]; + const activityMs = activityAt ? Date.parse(activityAt) : Number.NaN; + return Number.isFinite(activityMs) ? Math.max(base, activityMs) : base; + }; + // Match sortThreads' order: newest activity first, ties broken by descending + // id so the sequence stays stable. + return [...threads].sort((left, right) => { + const leftTimestamp = effectiveTimestamp(left); + const rightTimestamp = effectiveTimestamp(right); + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + return left.id < right.id ? 1 : left.id > right.id ? -1 : 0; + }); } type SettledTimestampInput = Pick< diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index bc61411249af..22b78066611a 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1666,6 +1666,10 @@ export default function SidebarV2() { // ever partitions live shells into the inbox (cards) and the snoozed shelf. // Parking a thread = archiving it, which removes it from `threads` here. const serverConfigs = useAtomValue(environmentServerConfigsAtom); + // Closing a chat is a worktree interaction the server timestamps don't record; + // fold it into the sort so the collapsed row keeps its place instead of + // sinking to an older sibling. See `sortThreadsForSidebarV2`. + const worktreeLastActivityAtByKey = useUiStateStore((state) => state.worktreeLastActivityAtByKey); const { activeThreads, snoozedThreads, snoozeNow, representativeKeyByThreadKey } = useMemo(() => { // Snooze wake times are second-precise, so classify against a real clock; // snoozeWakeTick re-runs this memo exactly at the next wake boundary. @@ -1678,6 +1682,7 @@ export default function SidebarV2() { (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ), + worktreeLastActivityAtByKey, ); // Chats sharing one worktree collapse to a single row (the earliest chat), // classified and shown by that representative; the rest live only in the @@ -1712,7 +1717,7 @@ export default function SidebarV2() { snoozeNow: preciseNow, representativeKeyByThreadKey, }; - }, [scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, worktreeLastActivityAtByKey]); // When the active route is a collapsed worktree sibling, its row is folded // into the earliest chat's; highlight and keep that representative visible. const effectiveRouteThreadKey = diff --git a/apps/web/src/components/chat/WorktreeThreadTabs.tsx b/apps/web/src/components/chat/WorktreeThreadTabs.tsx index 1f8675e4e62d..39cbdf56c06c 100644 --- a/apps/web/src/components/chat/WorktreeThreadTabs.tsx +++ b/apps/web/src/components/chat/WorktreeThreadTabs.tsx @@ -30,6 +30,7 @@ import { useClientSettings } from "~/hooks/useSettings"; import { readLocalApi } from "~/localApi"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { useUiStateStore, worktreeActivityKey } from "~/uiStateStore"; export interface WorktreeContentTabDescriptor { id: string; @@ -183,6 +184,18 @@ export const WorktreeThreadTabs = memo(function WorktreeThreadTabs({ if (!confirmed) return; } + // Closing a chat is a fresh interaction with its worktree. Record it so + // the collapsed sidebar row keeps its position instead of sinking to a + // surviving sibling's older timestamp. See `sortThreadsForSidebarV2`. + if (shell.worktreePath !== null) { + useUiStateStore + .getState() + .markWorktreeActive( + worktreeActivityKey(shell.environmentId, shell.worktreePath), + new Date().toISOString(), + ); + } + const fallback = shell.id === activeThreadId ? getWorktreeTabAfterClose(tabs, shell.id) : null; setClosingThreadId(shell.id); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index d325df38a46d..08798828cab2 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -5,6 +5,7 @@ import { legacyProjectCwdPreferenceKey, markThreadUnread, markThreadVisited, + markWorktreeActive, parsePersistedState, PERSISTED_STATE_KEY, type PersistedUiState, @@ -24,6 +25,7 @@ function makeUiState(overrides: Partial = {}): UiState { showHiddenProjects: false, projectOrder: [], threadLastVisitedAtById: {}, + worktreeLastActivityAtByKey: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, ...overrides, @@ -41,6 +43,23 @@ describe("uiStateStore pure functions", () => { expect(markThreadVisited(visited, threadId, "not-a-date")).toBe(visited); }); + it("records worktree activity without moving it backwards", () => { + const worktreeKey = "environment:/repo/wt"; + const initialState = makeUiState(); + const active = markWorktreeActive(initialState, worktreeKey, "2026-02-25T12:40:00.000Z"); + + expect(active.worktreeLastActivityAtByKey[worktreeKey]).toBe("2026-02-25T12:40:00.000Z"); + // An older interaction never lowers the recorded activity. + expect(markWorktreeActive(active, worktreeKey, "2026-02-25T12:39:00.000Z")).toBe(active); + expect(markWorktreeActive(active, worktreeKey, "not-a-date")).toBe(active); + expect(markWorktreeActive(active, "", "2026-02-25T12:41:00.000Z")).toBe(active); + // A newer interaction advances it. + expect( + markWorktreeActive(active, worktreeKey, "2026-02-25T12:41:00.000Z") + .worktreeLastActivityAtByKey[worktreeKey], + ).toBe("2026-02-25T12:41:00.000Z"); + }); + it("marks a completed thread unread using the server completion timestamp", () => { const threadId = ThreadId.make("thread-1"); const initialState = makeUiState({ @@ -160,6 +179,10 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", invalid: "not-a-date", }, + worktreeLastActivityAtByKey: { + "environment:/repo/wt": "2026-02-25T12:40:00.000Z", + invalid: "not-a-date", + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { @@ -180,6 +203,9 @@ describe("parsePersistedState", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + worktreeLastActivityAtByKey: { + "environment:/repo/wt": "2026-02-25T12:40:00.000Z", + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpandedById: { "environment:thread-1": { @@ -277,6 +303,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + worktreeLastActivityAtByKey: { + "environment:/repo/wt": "2026-02-25T12:40:00.000Z", + }, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -300,6 +329,9 @@ describe("uiStateStore persistence", () => { threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, + worktreeLastActivityAtByKey: { + "environment:/repo/wt": "2026-02-25T12:40:00.000Z", + }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 518a8d998d25..35f1b5d44285 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -22,6 +22,7 @@ export interface PersistedUiState { projectHiddenById?: Record; projectOrder?: string[]; threadLastVisitedAtById?: Record; + worktreeLastActivityAtByKey?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; @@ -44,6 +45,11 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + // Last time the user interacted with a worktree in a way the server-side + // thread timestamps don't capture — currently closing one of its chats. + // Keyed by `worktreeActivityKey`, it keeps the collapsed worktree row from + // sinking when its newest chat is closed. See `sortThreadsForSidebarV2`. + worktreeLastActivityAtByKey: Record; threadChangedFilesExpandedById: Record>; } @@ -59,10 +65,17 @@ const initialState: UiState = { showHiddenProjects: false, projectOrder: [], threadLastVisitedAtById: {}, + worktreeLastActivityAtByKey: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, }; +// Group key for a worktree's local activity, matching the key +// `collapseWorktreeSiblings` uses to fold a worktree's chats into one row. +export function worktreeActivityKey(environmentId: string, worktreePath: string): string { + return `${environmentId}\0${worktreePath}`; +} + const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; let legacyKeysCleanedUp = false; @@ -138,6 +151,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { showHiddenProjects: false, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), + worktreeLastActivityAtByKey: sanitizeTimestampRecord(parsed.worktreeLastActivityAtByKey), threadChangedFilesExpandedById: parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) @@ -217,6 +231,7 @@ export function persistState(state: UiState): void { projectHiddenById: state.projectHiddenById, projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, + worktreeLastActivityAtByKey: state.worktreeLastActivityAtByKey, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, @@ -258,6 +273,28 @@ export function markThreadVisited(state: UiState, threadId: string, visitedAt: s }; } +export function markWorktreeActive(state: UiState, worktreeKey: string, activeAt: string): UiState { + if (worktreeKey.length === 0) { + return state; + } + const activeAtMs = Date.parse(activeAt); + if (!Number.isFinite(activeAtMs)) { + return state; + } + const previousActiveAt = state.worktreeLastActivityAtByKey[worktreeKey]; + const previousActiveAtMs = previousActiveAt ? Date.parse(previousActiveAt) : NaN; + if (Number.isFinite(previousActiveAtMs) && previousActiveAtMs >= activeAtMs) { + return state; + } + return { + ...state, + worktreeLastActivityAtByKey: { + ...state.worktreeLastActivityAtByKey, + [worktreeKey]: activeAt, + }, + }; +} + export function markThreadUnread( state: UiState, threadId: string, @@ -439,6 +476,7 @@ export function reorderProjects( interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; + markWorktreeActive: (worktreeKey: string, activeAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; @@ -456,6 +494,8 @@ export const useUiStateStore = create((set) => ({ ...readPersistedState(), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), + markWorktreeActive: (worktreeKey, activeAt) => + set((state) => markWorktreeActive(state, worktreeKey, activeAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>