merge: integrate conversation attention badges

This commit is contained in:
inman
2026-09-03 10:46:24 +08:00
5 changed files with 436 additions and 5 deletions

View File

@@ -245,11 +245,150 @@ describe('coding Conversation store', () => {
expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes)
.toHaveLength(1);
expect(store.getState().summariesByConversationId['conversation-a'].unread).toBe(false);
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true);
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false);
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(store.getState().connectionState).toBe('live');
});
it('marks a hidden Conversation unread only when it needs interaction or its task settles', async () => {
const source = new FakeEventSource();
const store = createCodingConversationStore({
getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)),
openEvents: vi.fn(async () => source as unknown as EventSource),
submitPrompt: vi.fn(),
createId: ids(),
});
await store.getState().selectConversation('conversation-a');
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 1, {
op: 'run.state',
run: { status: 'running', runId: 'run-b', mode: 'prompt', startedAt: 1_001 },
}));
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 2, {
op: 'message.upsert',
node: {
kind: 'message',
id: 'assistant-b',
role: 'assistant',
status: 'streaming',
blocks: [{ kind: 'thinking', id: 'thinking-b', text: 'Checking', status: 'streaming' }],
},
}));
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 3, {
op: 'tool.upsert',
node: {
kind: 'tool',
id: 'tool-b',
toolCallId: 'tool-call-b',
toolName: 'bash',
title: 'Run command',
inputText: 'false',
status: 'error',
output: [{ kind: 'text', id: 'tool-output-b', text: 'Command failed', status: 'complete' }],
},
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false);
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 4, {
op: 'interaction.upsert',
interaction: {
id: 'interaction-b',
conversationId: 'conversation-b',
runId: 'run-b',
kind: 'confirm',
title: 'Allow this action?',
status: 'pending',
},
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true);
store.getState().markUnread('conversation-b', false);
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 5, {
op: 'interaction.remove',
interactionId: 'interaction-b',
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false);
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 6, {
op: 'run.state',
run: {
status: 'idle',
runId: 'run-b',
mode: 'prompt',
startedAt: 1_001,
settledAt: 1_006,
terminalReason: 'completed',
},
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true);
store.getState().markUnread('conversation-b', false);
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 7, {
op: 'context.replace',
context: { usedTokens: 12, contextWindow: 1_024, compaction: 'idle' },
}));
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 8, {
op: 'run.state',
run: {
status: 'idle',
runId: 'run-b',
mode: 'prompt',
startedAt: 1_001,
settledAt: 1_008,
terminalReason: 'completed',
},
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(false);
});
it.each([
{
label: 'failed',
run: {
status: 'error' as const,
runId: 'run-b',
settledAt: 1_002,
terminalReason: 'failed' as const,
error: {
code: 'CODING_RUNTIME_PROTOCOL_ERROR' as const,
message: 'Task failed',
recoverable: true,
},
},
},
{
label: 'aborted',
run: {
status: 'idle' as const,
runId: 'run-b',
settledAt: 1_002,
terminalReason: 'aborted' as const,
},
},
])('marks a hidden Conversation unread when its task ends as $label', ({ run }) => {
const store = createCodingConversationStore({
getSnapshot: vi.fn(),
openEvents: vi.fn(),
submitPrompt: vi.fn(),
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent({
...snapshot('conversation-b', 1, 1),
run: { status: 'running', runId: 'run-b', mode: 'prompt', startedAt: 1_001 },
}));
store.getState().applyPatchBatchEvent(patchEvent('conversation-b', 2, {
op: 'run.state',
run,
}));
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true);
});
it('refreshes only the gapped target and drops an older worker generation', async () => {
const recoveredA = snapshot('conversation-a', 1, 2);
const getSnapshot = vi.fn(async (conversationId: string) => {