diff --git a/.project-docs/30-worklog/tasks/20260902-conversation-badge-a7c49e.md b/.project-docs/30-worklog/tasks/20260902-conversation-badge-a7c49e.md new file mode 100644 index 0000000..cb51662 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-conversation-badge-a7c49e.md @@ -0,0 +1,90 @@ +# Task: Limit conversation unread badge triggers + +## Identity + +- Task ID: 20260902-conversation-badge-a7c49e +- Mode: Feature +- Branch: codex/20260902-conversation-badge-a7c49e-conversation-badge-a7c49e +- Worktree: /Users/inmanx/Documents/makelore-conversation-badge-a7c49e +- Base commit: 301c1496b0a3a1af3b9443a68a67de8dfea45749 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Limit the Makelore Code Conversation-list red unread marker to hidden + Conversations that newly require user interaction or whose current task has + reached a terminal result. +- Keep assistant streaming content, thinking, tool progress, and individual tool + failures visible in the timeline without treating them as new-message badge + triggers. +- Add focused Renderer-store coverage and an Electron production-seam regression, + and synchronize the current behavior in `README.md`. + +## Intent And Constraints + +- Preserve Conversation-local Snapshot/Patch reduction, generation/sequence + recovery, selected-Conversation read clearing, and lightweight hidden summaries. +- Do not change Electron Main, public contracts, runtime error semantics, + Conversation metadata storage, or the sidebar presentation itself. +- A new pending select/confirm/input/editor interaction must set the marker for a + hidden Conversation. A new completed, failed, or aborted run terminal must also + set it. +- Repeated updates to an already-pending interaction or an already-terminal run + must not recreate a marker the user has already cleared. +- Work in the isolated worktree because local `main` is owned by the completed + unsigned-macOS-package task; that task has no semantic or file overlap. + +## Outcome + +- Replaced the prior `assistant message.upsert` unread trigger with a transition + predicate over the target Conversation's previous and reduced Snapshots. +- The predicate recognizes only a newly pending interaction or a newly terminal + run. Terminal identity is compared by run id and terminal reason so unrelated + late patches do not recreate a cleared marker. +- Assistant stream start/update, thinking, tool lifecycle, and `tool.upsert` error + patches no longer mark a hidden Conversation unread. If the whole run later + settles, its completed/failed/aborted transition still marks it. +- Extended the controllable Electron EventSource fixture and added a real sidebar + assertion covering process failure, pending confirmation, read clearing, and + final task settlement. +- Updated `README.md` with the user-visible badge rule. No Main/runtime/API/storage + behavior changed. + +## Verification + +- Red phase: the focused store suite failed four assertions against the prior + assistant-message trigger, covering the unwanted process marker and the missing + interaction/failed/aborted terminal markers. +- `pnpm exec vitest run tests/unit/coding-conversations-store.test.tsx --maxWorkers=1`: + passed, 1 file / 28 tests. +- `pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx --maxWorkers=1`: + passed, 1 file / 17 tests. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with 0 errors and 5 pre-existing warnings outside + the changed files; scoped ESLint on all changed TypeScript files passed cleanly. +- `pnpm test`: passed; functional stage 212 files / 1,735 passed / 3 skipped, + isolated pressure stage 1 file / 1 passed. +- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility; + only existing Browserslist, dynamic-import, and chunk-size warnings remained. +- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, 3/3. +- All project commands used the pinned pnpm 10.33.4. `git diff --check` passed. + +## Follow-ups + +- None. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md` + Proposal: record that a hidden Code Conversation's red unread marker now signals + only newly pending user interaction or a newly terminal completed/failed/aborted + task, while assistant/tool process updates and individual tool failures remain + non-notifying. + Evidence: `src/stores/coding-conversations.ts`, focused 28-test store suite, and + the 3/3 Electron `pi-coding-first-chat` production-seam run. + Future impact: future patch types and sidebar changes should preserve the + distinction between process visibility and user-attention notification. + Semantic conflicts: none; ADR-006's Snapshot/Patch and per-Conversation isolation + boundaries are unchanged. + Human confirmation required: no. diff --git a/README.md b/README.md index a0a9d29..c7ce050 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证同一 Agent Server 内多个 Conversation 逻辑线程的重叠执行、状态隔离、凭证引用隔离、单线程关闭和 Server 崩溃后重启,以及独立子 Agent 进程的隔离与回收;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 - 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。 +- 隐藏 Conversation 的红色新消息标记只在出现新的待回答交互,或当前任务进入 completed、failed、aborted 终态时设置;助手流式文字、思考、工具过程和单个工具失败不会提前触发红点。 - 核心聊天页标题栏左侧只显示 Conversation 名称,必要的中止/恢复入口保留在右侧;不再展示 Pi 空闲/运行副标题。对话列表栏只显示项目名称和项目设置入口,不显示“Pi 本地对话”副标题;全局品牌 Logo 贴齐窗口最右边缘。归档、未读、整段 Conversation 分支和编程工具抽屉均不占用标题栏。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 ## 运行内核与项目数据 diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index bfc18ca..5547030 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -364,6 +364,33 @@ function completesOrdinaryPrompt( return false; } +function taskHasSettled(snapshot: ConversationSnapshot): boolean { + return snapshot.run.status === 'error' + || (snapshot.run.status === 'idle' && snapshot.run.terminalReason !== undefined); +} + +function newlyNeedsUserAttention( + current: ConversationSnapshot | null, + next: ConversationSnapshot | null, +): boolean { + if (!next) return false; + const currentPendingInteractions = new Set( + current?.pendingInteractions + .filter((interaction) => interaction.status === 'pending') + .map((interaction) => interaction.id) ?? [], + ); + if (next.pendingInteractions.some((interaction) => ( + interaction.status === 'pending' && !currentPendingInteractions.has(interaction.id) + ))) { + return true; + } + if (!taskHasSettled(next)) return false; + return !current + || !taskHasSettled(current) + || current.run.runId !== next.run.runId + || current.run.terminalReason !== next.run.terminalReason; +} + export function createCodingConversationStore( dependencies: Partial = {}, ): StoreApi { @@ -979,11 +1006,8 @@ export function createCodingConversationStore( } if (reducer === current.reducer) return state; applied = true; - const incomingMessages = applicableEvent.items.flatMap((item) => ( - item.patch.op === 'message.upsert' ? [item.patch.node] : [] - )); const unread = state.selectedConversationId !== applicableEvent.conversationId - && incomingMessages.some((message) => message.role === 'assistant') + && newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot) ? true : current.unread; const entry: CodingConversationEntry = { diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index ebfb565..8ed3a96 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -18,6 +18,7 @@ type HostConnection = { async function disableCodingEventSource(page: Page): Promise { await page.addInitScript(() => { + const sources = new Set(); class LocalEventSource extends EventTarget { static readonly CONNECTING = 0; static readonly OPEN = 1; @@ -35,11 +36,13 @@ async function disableCodingEventSource(page: Page): Promise { constructor(url: string) { super(); this.url = url; + sources.add(this); queueMicrotask(() => this.onopen?.(new Event('open'))); } close(): void { this.readyState = LocalEventSource.CLOSED; + sources.delete(this); } } @@ -48,9 +51,26 @@ async function disableCodingEventSource(page: Page): Promise { writable: true, value: LocalEventSource, }); + Object.defineProperty(window, '__makeloreEmitCodingEvent', { + configurable: true, + value(type: string, payload: unknown) { + for (const source of sources) { + source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) })); + } + }, + }); }); } +async function emitCodingEvent(page: Page, type: string, payload: unknown): Promise { + await page.evaluate(({ eventType, eventPayload }) => { + const testWindow = window as typeof window & { + __makeloreEmitCodingEvent?: (type: string, payload: unknown) => void; + }; + testWindow.__makeloreEmitCodingEvent?.(eventType, eventPayload); + }, { eventType: type, eventPayload: payload }); +} + async function installCodingFirstChatHost( electronApp: ElectronApplication, hostConnection: HostConnection, @@ -726,6 +746,163 @@ test('first PI Conversation is editable under 500 ms and submits before runtime } }); +test('hidden Conversation badge ignores process failures until interaction or task settlement', async ({ + launchElectronApp, +}) => { + const electronApp = await launchElectronApp({ skipSetup: true }); + let page = await getStableWindow(electronApp); + const hostConnection = await page.evaluate(async () => ({ + token: await window.electron.ipcRenderer.invoke('hostapi:token') as string, + baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string, + })); + await installCodingFirstChatHost(electronApp, hostConnection, true); + await disableCodingEventSource(page); + + try { + await page.reload(); + page = await getStableWindow(electronApp); + await page.getByTestId('ai-module-option-programming').click(); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.evaluate(() => { window.location.hash = '/chat'; }); + + const conversations = page.getByRole('group', { name: 'Builder 的对话' }); + const firstConversation = conversations.getByRole('button', { name: '新对话', exact: true }); + const secondConversation = conversations.getByRole('button', { name: 'Second Conversation' }); + const secondUnreadBadge = secondConversation.locator('[aria-label="未读"]'); + await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话'); + await expect(secondUnreadBadge).toHaveCount(0); + + await emitCodingEvent(page, 'patch-batch', { + type: 'patch-batch', + conversationId: 'conversation-pi-second', + workerGeneration: 0, + fromSeq: 1, + toSeq: 3, + items: [ + { + seq: 1, + at: 1_001, + runId: 'run-process-e2e', + patch: { + op: 'run.state', + run: { status: 'running', runId: 'run-process-e2e', mode: 'prompt', startedAt: 1_001 }, + }, + }, + { + seq: 2, + at: 1_002, + runId: 'run-process-e2e', + patch: { + op: 'message.upsert', + node: { + kind: 'message', + id: 'message-process-e2e', + role: 'assistant', + status: 'streaming', + blocks: [{ kind: 'thinking', id: 'thinking-process-e2e', text: 'Checking', status: 'streaming' }], + }, + }, + }, + { + seq: 3, + at: 1_003, + runId: 'run-process-e2e', + patch: { + op: 'tool.upsert', + node: { + kind: 'tool', + id: 'tool-process-e2e', + toolCallId: 'tool-call-process-e2e', + toolName: 'bash', + title: 'Run command', + inputText: 'false', + status: 'error', + output: [{ kind: 'text', id: 'tool-output-process-e2e', text: 'Command failed', status: 'complete' }], + }, + }, + }, + ], + }); + await expect(secondUnreadBadge).toHaveCount(0); + + await emitCodingEvent(page, 'patch-batch', { + type: 'patch-batch', + conversationId: 'conversation-pi-second', + workerGeneration: 0, + fromSeq: 4, + toSeq: 4, + items: [{ + seq: 4, + at: 1_004, + runId: 'run-process-e2e', + patch: { + op: 'interaction.upsert', + interaction: { + id: 'interaction-process-e2e', + conversationId: 'conversation-pi-second', + runId: 'run-process-e2e', + kind: 'confirm', + title: 'Allow this action?', + status: 'pending', + }, + }, + }], + }); + await expect(secondUnreadBadge).toHaveCount(1); + + await secondConversation.click(); + await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation'); + await expect(secondUnreadBadge).toHaveCount(0); + await firstConversation.click(); + await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话'); + + await emitCodingEvent(page, 'patch-batch', { + type: 'patch-batch', + conversationId: 'conversation-pi-second', + workerGeneration: 0, + fromSeq: 5, + toSeq: 5, + items: [{ + seq: 5, + at: 1_005, + runId: 'run-process-e2e', + patch: { + op: 'interaction.remove', + interactionId: 'interaction-process-e2e', + }, + }], + }); + await expect(secondUnreadBadge).toHaveCount(0); + + await emitCodingEvent(page, 'patch-batch', { + type: 'patch-batch', + conversationId: 'conversation-pi-second', + workerGeneration: 0, + fromSeq: 6, + toSeq: 6, + items: [{ + seq: 6, + at: 1_006, + runId: 'run-process-e2e', + patch: { + op: 'run.state', + run: { + status: 'idle', + runId: 'run-process-e2e', + mode: 'prompt', + startedAt: 1_001, + settledAt: 1_006, + terminalReason: 'completed', + }, + }, + }], + }); + await expect(secondUnreadBadge).toHaveCount(1); + } finally { + await releaseSnapshot(electronApp); + } +}); + test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({ launchElectronApp, }) => { diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index fb5537d..b83a4f2 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -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) => {