Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
44 changes: 40 additions & 4 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T extends { readonly id: string } & ThreadSortInput>(
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<Record<string, string>>): 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<
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/chat/WorktreeThreadTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
legacyProjectCwdPreferenceKey,
markThreadUnread,
markThreadVisited,
markWorktreeActive,
parsePersistedState,
PERSISTED_STATE_KEY,
type PersistedUiState,
Expand All @@ -24,6 +25,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
showHiddenProjects: false,
projectOrder: [],
threadLastVisitedAtById: {},
worktreeLastActivityAtByKey: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
...overrides,
Expand All @@ -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({
Expand Down Expand Up @@ -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: {
Expand All @@ -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": {
Expand Down Expand Up @@ -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,
Expand All @@ -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: {
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface PersistedUiState {
projectHiddenById?: Record<string, boolean>;
projectOrder?: string[];
threadLastVisitedAtById?: Record<string, string>;
worktreeLastActivityAtByKey?: Record<string, string>;
collapsedProjectCwds?: string[];
expandedProjectCwds?: string[];
projectOrderCwds?: string[];
Expand All @@ -44,6 +45,11 @@ export interface UiProjectState {

export interface UiThreadState {
threadLastVisitedAtById: Record<string, string>;
// 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<string, string>;
threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -456,6 +494,8 @@ export const useUiStateStore = create<UiStateStore>((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) =>
Expand Down