merge: integrate conversation attention badges
This commit is contained in:
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user