merge: integrate conversation attention badges
This commit is contained in:
@@ -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.
|
||||||
@@ -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。
|
- Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证同一 Agent Server 内多个 Conversation 逻辑线程的重叠执行、状态隔离、凭证引用隔离、单线程关闭和 Server 崩溃后重启,以及独立子 Agent 进程的隔离与回收;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。
|
||||||
- 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
|
- 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。
|
||||||
- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列正常在 `agent_settled` 后释放,用户可中止当前运行。若 Pi 已终结但该握手丢失,Main 会在 30 秒宽限后仅探测目标线程:权威 idle 状态会补齐持久化最终回复并 exactly-once 释放 ownership,矛盾状态则显式失败该目标,且不会重放已接受请求。select/confirm/input/editor 交互在输入区上方回答,select 除预设选项外支持自由输入;交互提交或取消后立即从输入区移除,失效请求会就地提示并允许重试或取消。
|
- 运行中的 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 不属于该产品界面。
|
- 核心聊天页标题栏左侧只显示 Conversation 名称,必要的中止/恢复入口保留在右侧;不再展示 Pi 空闲/运行副标题。对话列表栏只显示项目名称和项目设置入口,不显示“Pi 本地对话”副标题;全局品牌 Logo 贴齐窗口最右边缘。归档、未读、整段 Conversation 分支和编程工具抽屉均不占用标题栏。从已持久化的 user 消息仍可“从这里创建新对话分支”,assistant 消息和未持久化消息不提供该动作;分支只创建新的 Conversation 历史,不表示文件回滚。Pi 的工具调用与浏览器附件继续在对应的对话过程节点中按需展开;分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。
|
||||||
|
|
||||||
## 运行内核与项目数据
|
## 运行内核与项目数据
|
||||||
|
|||||||
@@ -364,6 +364,33 @@ function completesOrdinaryPrompt(
|
|||||||
return false;
|
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(
|
export function createCodingConversationStore(
|
||||||
dependencies: Partial<CodingConversationStoreDependencies> = {},
|
dependencies: Partial<CodingConversationStoreDependencies> = {},
|
||||||
): StoreApi<CodingConversationStoreState> {
|
): StoreApi<CodingConversationStoreState> {
|
||||||
@@ -979,11 +1006,8 @@ export function createCodingConversationStore(
|
|||||||
}
|
}
|
||||||
if (reducer === current.reducer) return state;
|
if (reducer === current.reducer) return state;
|
||||||
applied = true;
|
applied = true;
|
||||||
const incomingMessages = applicableEvent.items.flatMap((item) => (
|
|
||||||
item.patch.op === 'message.upsert' ? [item.patch.node] : []
|
|
||||||
));
|
|
||||||
const unread = state.selectedConversationId !== applicableEvent.conversationId
|
const unread = state.selectedConversationId !== applicableEvent.conversationId
|
||||||
&& incomingMessages.some((message) => message.role === 'assistant')
|
&& newlyNeedsUserAttention(current.reducer.snapshot, reducer.snapshot)
|
||||||
? true
|
? true
|
||||||
: current.unread;
|
: current.unread;
|
||||||
const entry: CodingConversationEntry = {
|
const entry: CodingConversationEntry = {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type HostConnection = {
|
|||||||
|
|
||||||
async function disableCodingEventSource(page: Page): Promise<void> {
|
async function disableCodingEventSource(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
const sources = new Set<LocalEventSource>();
|
||||||
class LocalEventSource extends EventTarget {
|
class LocalEventSource extends EventTarget {
|
||||||
static readonly CONNECTING = 0;
|
static readonly CONNECTING = 0;
|
||||||
static readonly OPEN = 1;
|
static readonly OPEN = 1;
|
||||||
@@ -35,11 +36,13 @@ async function disableCodingEventSource(page: Page): Promise<void> {
|
|||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
super();
|
super();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
sources.add(this);
|
||||||
queueMicrotask(() => this.onopen?.(new Event('open')));
|
queueMicrotask(() => this.onopen?.(new Event('open')));
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.readyState = LocalEventSource.CLOSED;
|
this.readyState = LocalEventSource.CLOSED;
|
||||||
|
sources.delete(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +51,26 @@ async function disableCodingEventSource(page: Page): Promise<void> {
|
|||||||
writable: true,
|
writable: true,
|
||||||
value: LocalEventSource,
|
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<void> {
|
||||||
|
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(
|
async function installCodingFirstChatHost(
|
||||||
electronApp: ElectronApplication,
|
electronApp: ElectronApplication,
|
||||||
hostConnection: HostConnection,
|
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 ({
|
test('PI feature UI isolates Conversations and exposes queue, interaction, model, and subagent state', async ({
|
||||||
launchElectronApp,
|
launchElectronApp,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -245,11 +245,150 @@ describe('coding Conversation store', () => {
|
|||||||
expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes)
|
expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes)
|
||||||
.toHaveLength(1);
|
.toHaveLength(1);
|
||||||
expect(store.getState().summariesByConversationId['conversation-a'].unread).toBe(false);
|
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(getSnapshot).toHaveBeenCalledTimes(1);
|
||||||
expect(store.getState().connectionState).toBe('live');
|
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 () => {
|
it('refreshes only the gapped target and drops an older worker generation', async () => {
|
||||||
const recoveredA = snapshot('conversation-a', 1, 2);
|
const recoveredA = snapshot('conversation-a', 1, 2);
|
||||||
const getSnapshot = vi.fn(async (conversationId: string) => {
|
const getSnapshot = vi.fn(async (conversationId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user