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

@@ -18,6 +18,7 @@ type HostConnection = {
async function disableCodingEventSource(page: Page): Promise<void> {
await page.addInitScript(() => {
const sources = new Set<LocalEventSource>();
class LocalEventSource extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
@@ -35,11 +36,13 @@ async function disableCodingEventSource(page: Page): Promise<void> {
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<void> {
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<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(
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,
}) => {