Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7d5d251
fix(web): stop sidebar reorder on thread switch
mindfn Apr 9, 2026
55fcf0c
fix(web): stop sidebar reorder on thread switch
mindfn Apr 9, 2026
3f67f8d
fix(web): use deliveredAt-aware time in snapshotActive
mindfn Apr 10, 2026
481ca83
style: fix biome formatting in test
mindfn Apr 10, 2026
f455759
style: fix pre-existing biome formatting in project-setup-card-ime test
mindfn Apr 10, 2026
4408e14
fix(web): stamp completion time in removeActiveInvocation
mindfn Apr 10, 2026
7c18ba6
fix(web): stamp lastActivity in clearAllActiveInvocations too
mindfn Apr 10, 2026
f96604c
fix(sidebar): stamp lastActivity in all invocation-clearing paths for…
mindfn Apr 10, 2026
6a2fc4a
fix(sidebar): revert background-thread lastActivity stamp in clearThr…
mindfn Apr 10, 2026
6338f7e
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 10, 2026
ab5ebd1
fix(sidebar): stamp lastActivity in setHasActiveInvocation(false) tra…
mindfn Apr 10, 2026
a2faedf
fix(sidebar): stamp missing-slot completion fallback
mindfn Apr 10, 2026
6822586
fix(sidebar): avoid hydration recency bump on active clear
mindfn Apr 10, 2026
37b7020
fix(sidebar): avoid reconnect cleanup recency bump
mindfn Apr 10, 2026
25453e7
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 10, 2026
13b6485
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 10, 2026
8e5f222
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 10, 2026
d3e984c
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 11, 2026
a1cb15c
Merge branch 'main' into fix/sidebar-sort-on-switch
mindfn Apr 11, 2026
8528b09
fix(ci): format observability coverage test [砚砚/GPT-5.4🐾]
mindfn Apr 11, 2026
5e7a127
refactor(sidebar): centralize completion stamp into stampThreadComple…
mindfn Apr 11, 2026
124042d
refactor(sidebar): migrate removeThreadActiveInvocation background to…
mindfn Apr 11, 2026
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
183 changes: 183 additions & 0 deletions packages/web/src/stores/__tests__/chatStore-multithread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,189 @@ describe('chatStore multi-thread state', () => {
expect(useChatStore.getState().messages.map((m) => m.id)).toEqual(['r3']);
});

describe('snapshotActive lastActivity (sidebar sort stability)', () => {
it('does not bump lastActivity to Date.now() when switching away from idle thread', () => {
const oldTs = Date.now() - 60_000; // message from 1 minute ago
const msg: ChatMessage = { id: 'old-msg', type: 'user', content: 'hi', timestamp: oldTs };
useChatStore.getState().addMessage(msg);

// No active invocation — thread is idle
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

const beforeSwitch = Date.now();
useChatStore.getState().setCurrentThread('thread-b');

// The saved snapshot for thread-a should NOT have lastActivity ≈ now
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeLessThan(beforeSwitch);
// It should reflect the message timestamp, not Date.now()
expect(saved.lastActivity).toBe(oldTs);
});

it('uses deliveredAt over timestamp when snapshotting idle thread', () => {
const oldTs = Date.now() - 120_000; // original message from 2 minutes ago
const deliveryTs = Date.now() - 5_000; // delivered 5 seconds ago
const msg: ChatMessage = {
id: 'queued-msg',
type: 'user',
content: 'queued',
timestamp: oldTs,
deliveredAt: deliveryTs,
};
useChatStore.getState().addMessage(msg);

expect(useChatStore.getState().hasActiveInvocation).toBe(false);
useChatStore.getState().setCurrentThread('thread-b');

const saved = useChatStore.getState().threadStates['thread-a']!;
// Should use deliveredAt (5s ago), not timestamp (2min ago)
expect(saved.lastActivity).toBe(deliveryTs);
});

it('preserves Date.now()-level lastActivity when switching away from streaming thread', () => {
// Simulate an active invocation (cat is streaming)
useChatStore.setState({ hasActiveInvocation: true });
useChatStore.getState().addMessage(makeMsg('stream-msg'));

const beforeSwitch = Date.now();
useChatStore.getState().setCurrentThread('thread-b');

// The saved snapshot for thread-a should have lastActivity ≈ now
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeSwitch);
});

it('stamps completion time when stream ends then user switches (post-stream idle)', () => {
// Simulate: stream active → stream ends → user switches
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'streamed', type: 'assistant', content: 'done', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);

// Start invocation
useChatStore.getState().addActiveInvocation('inv-1', 'opus', 'execute');
expect(useChatStore.getState().hasActiveInvocation).toBe(true);

// Stream ends — removeActiveInvocation stamps threadStates[currentThread].lastActivity
const beforeDone = Date.now();
useChatStore.getState().removeActiveInvocation('inv-1');
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

// User switches away — idle branch should pick up the stamped time, not oldMsgTs
useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeDone);
});

it('stamps completion time on clearAllActiveInvocations (stop/timeout path)', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'stopped', type: 'assistant', content: 'partial', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
useChatStore.getState().addActiveInvocation('inv-2', 'opus', 'execute');

const beforeClear = Date.now();
useChatStore.getState().clearAllActiveInvocations();
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeClear);
});

it('stamps completion time on clearAllThreadActiveInvocations for active thread', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'cancelled', type: 'assistant', content: 'partial', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
useChatStore.getState().addActiveInvocation('inv-3', 'opus', 'execute');

const beforeClear = Date.now();
useChatStore.getState().clearAllThreadActiveInvocations('thread-a');
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeClear);
});

it('does not stamp when clearThreadActiveInvocation reconciles stale active-thread state', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'done', type: 'assistant', content: 'ok', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
// Simulate stale restored processing state that hydration/reconnect will clear.
useChatStore.getState().addActiveInvocation('inv-4', 'opus', 'execute');

const beforeClear = Date.now();
useChatStore.getState().clearThreadActiveInvocation('thread-a');
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeLessThan(beforeClear);
expect(saved.lastActivity).toBe(oldMsgTs);
});

it('stamps completion time on resetThreadInvocationState for active thread', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'reset', type: 'assistant', content: 'ok', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
useChatStore.getState().addActiveInvocation('inv-5', 'opus', 'execute');

const beforeReset = Date.now();
useChatStore.getState().resetThreadInvocationState('thread-a');
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeReset);
});

it('stamps completion time on setHasActiveInvocation(false) fallback', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'fallback', type: 'assistant', content: 'ok', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
// Simulate active invocation via direct setter (useAgentMessages fallback path)
useChatStore.getState().setHasActiveInvocation(true);

const beforeClear = Date.now();
useChatStore.getState().setHasActiveInvocation(false);
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeClear);
});

it('stamps completion time when removeActiveInvocation misses an optimistic slot', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'missing-slot', type: 'assistant', content: 'ok', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
// Simulate optimistic send path: active flag flipped before any slot was registered.
useChatStore.getState().setHasActiveInvocation(true);

const beforeDone = Date.now();
useChatStore.getState().removeActiveInvocation('inv-missing');
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
expect(saved.lastActivity).toBeGreaterThanOrEqual(beforeDone);
});

it('does not stamp on redundant setHasActiveInvocation(false) when already false', () => {
const oldMsgTs = Date.now() - 30_000;
const msg: ChatMessage = { id: 'noop', type: 'assistant', content: 'ok', timestamp: oldMsgTs };
useChatStore.getState().addMessage(msg);
// hasActiveInvocation is already false (default)
expect(useChatStore.getState().hasActiveInvocation).toBe(false);

useChatStore.getState().setHasActiveInvocation(false);

useChatStore.getState().setCurrentThread('thread-b');
const saved = useChatStore.getState().threadStates['thread-a']!;
// Should NOT have a recent timestamp — no real transition occurred
expect(saved.lastActivity).toBeLessThanOrEqual(oldMsgTs);
});
});

describe('unread suppression (persistent badge fix)', () => {
beforeEach(() => {
vi.useFakeTimers();
Expand Down
121 changes: 111 additions & 10 deletions packages/web/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,16 @@ function snapshotActive(s: ChatState): ThreadState {
currentGame: s.currentGame,
unreadCount: 0, // active thread always 0
hasUserMention: false,
lastActivity: Date.now(),
// If the thread is actively streaming, Date.now() is correct — there IS real activity.
// Otherwise, preserve the real timestamp so a mere thread switch doesn't reorder the sidebar.
lastActivity: s.hasActiveInvocation
? Date.now()
: Math.max(
s.threadStates[s.currentThreadId]?.lastActivity ?? 0,
s.messages.length > 0
? (s.messages[s.messages.length - 1].deliveredAt ?? s.messages[s.messages.length - 1].timestamp)
: 0,
),
queue: s.queue,
queuePaused: s.queuePaused,
queuePauseReason: s.queuePauseReason,
Expand Down Expand Up @@ -1050,7 +1059,25 @@ export const useChatStore = create<ChatState>((set, get) => ({
})),

setLoading: (loading) => set({ isLoading: loading }),
setHasActiveInvocation: (v) => set({ hasActiveInvocation: v }),
setHasActiveInvocation: (v) =>
set((state) => {
// Stamp completion time when transitioning active → inactive on the current thread,
// so snapshotActive sees real completion time instead of stale message timestamps.
if (!v && state.hasActiveInvocation) {
const existingTs = state.threadStates[state.currentThreadId];
return {
hasActiveInvocation: false,
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
};
}
return { hasActiveInvocation: v };
}),
/** F108: Register a new active invocation slot */
addActiveInvocation: (invocationId, catId, mode, startedAt?) =>
set((state) => {
Expand All @@ -1064,13 +1091,60 @@ export const useChatStore = create<ChatState>((set, get) => ({
removeActiveInvocation: (invocationId) =>
set((state) => {
if (!(invocationId in state.activeInvocations)) {
return { hasActiveInvocation: Object.keys(state.activeInvocations).length > 0 };
const hasActive = Object.keys(state.activeInvocations).length > 0;
if (!hasActive && state.hasActiveInvocation) {
const existingTs = state.threadStates[state.currentThreadId];
return {
hasActiveInvocation: false,
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
};
}
return { hasActiveInvocation: hasActive };
}
const rest = Object.fromEntries(Object.entries(state.activeInvocations).filter(([k]) => k !== invocationId));
return { activeInvocations: rest, hasActiveInvocation: Object.keys(rest).length > 0 };
const hasActive = Object.keys(rest).length > 0;
// When the last invocation ends, stamp the completion time into threadStates
// so snapshotActive's idle branch picks up the real "just finished streaming" time.
const existingTs = state.threadStates[state.currentThreadId];
return {
activeInvocations: rest,
hasActiveInvocation: hasActive,
...(!hasActive
? {
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
}
: {}),
};
}),
/** F108: Clear all active invocations (timeout/error/stop recovery) */
clearAllActiveInvocations: () => set({ activeInvocations: {}, hasActiveInvocation: false }),
clearAllActiveInvocations: () =>
set((state) => {
const existingTs = state.threadStates[state.currentThreadId];
return {
activeInvocations: {},
hasActiveInvocation: false,
// Stamp completion time (same as removeActiveInvocation) for stop/timeout/reconnect paths
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
};
}),
setLoadingHistory: (loading) => set({ isLoadingHistory: loading }),
setIntentMode: (mode) => set({ intentMode: mode }),

Expand Down Expand Up @@ -1585,7 +1659,18 @@ export const useChatStore = create<ChatState>((set, get) => ({
clearAllThreadActiveInvocations: (threadId) =>
set((state) => {
if (threadId === state.currentThreadId) {
return { activeInvocations: {}, hasActiveInvocation: false };
const existingTs = state.threadStates[state.currentThreadId];
return {
activeInvocations: {},
hasActiveInvocation: false,
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
};
}
const existing = state.threadStates[threadId];
if (!existing) return state;
Expand Down Expand Up @@ -1871,9 +1956,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
/** Clear hasActiveInvocation for a specific thread (active or background) */
clearThreadActiveInvocation: (threadId) =>
set((state) => {
// Active thread — clear flat state
// Active-thread clear is used by hydration/reconciliation paths to drop stale slots.
// Do not stamp lastActivity here: that would turn routine state repair into fake recency.
// Real completion paths stamp via removeActiveInvocation / setHasActiveInvocation(false) /
// clearAllActiveInvocations / resetThreadInvocationState.
if (threadId === state.currentThreadId) {
return { hasActiveInvocation: false, activeInvocations: {} };
return {
hasActiveInvocation: false,
activeInvocations: {},
};
}
// Background thread — update in threadStates map (no-op if unknown)
const ts = state.threadStates[threadId];
Expand All @@ -1897,9 +1988,19 @@ export const useChatStore = create<ChatState>((set, get) => ({
catStatuses: {} as Record<string, CatStatusType>,
};

// Active thread — clear flat state
// Active thread — clear flat state + stamp completion time
if (threadId === state.currentThreadId) {
return resetPatch;
const existingTs = state.threadStates[state.currentThreadId];
return {
...resetPatch,
threadStates: {
...state.threadStates,
[state.currentThreadId]: {
...(existingTs ?? { ...DEFAULT_THREAD_STATE }),
lastActivity: Date.now(),
},
},
};
}

// Background thread — update in threadStates map (no-op if unknown)
Expand Down
Loading