Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,43 @@ async function openGitChanges(page: Page) {
return page.getByRole('region', { name: 'Git 变更' });
}

async function createSession(page: Page, prompt: string) {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(prompt);
await composer.press('Enter');
await expect(page.getByText(`Fake backend received: ${prompt}`)).toBeVisible();
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
const sidebar = page.getByRole('navigation', { name: '任务列表' });
const expandSidebar = page.getByRole('button', { name: '展开侧边栏' });
if (await expandSidebar.isVisible()) await expandSidebar.click();
const sessionId = await sidebar
.locator('[data-session-id]:has([aria-current="page"])')
.getAttribute('data-session-id');
expect(sessionId).toBeTruthy();
return { composer, sessionId: sessionId!, sidebar };
}

async function waitForCompanionForkId(page: Page, sourceSessionId: string) {
let forkId: string | undefined;
await expect
.poll(async () => {
forkId = (await page.evaluate(() => window.maka.sessions.list())).find(
(session) => session.id !== sourceSessionId,
)?.id;
return forkId;
})
.not.toBeUndefined();
return forkId!;
}

async function setRightWorkbarWidth(page: Page, width: number) {
const layoutOwner = page.locator('.maka-workbar-layout-vars');
const workbar = page.locator('.maka-session-workbar[data-placement="right"]');
await expect(layoutOwner).toHaveCount(1);
await expect(workbar).toBeVisible();
await workbar.evaluate((element, nextWidth) => {
await layoutOwner.evaluate((element, nextWidth) => {
(element as HTMLElement).style.setProperty(
'--maka-session-workbar-width',
`${nextWidth}px`,
Expand Down Expand Up @@ -85,6 +118,13 @@ test('narrow right workbar keeps launcher shortcuts and side-chat send button in
const companion = page.locator('.maka-quote-companion');
await expect(companion).toBeVisible();
await setRightWorkbarWidth(page, 320);
const sideChatPanel = page
.locator('.maka-session-workbar-panel[data-overlay][data-placement="right"]')
.filter({ has: companion });
await expect(sideChatPanel).toBeVisible();
await expect
.poll(async () => (await sideChatPanel.boundingBox())?.width)
.toBeCloseTo(320, 0);

const composerCard = companion.locator('.maka-composer-astryx');
// Keep ChatComposer's inner elevation visible.
Expand Down Expand Up @@ -241,3 +281,120 @@ test('Git changes re-read the workspace after the app regains focus', async ({

await expect(panel.getByText('新增 5 行')).toBeVisible();
});

test('Terminal ownership follows the active Session and stops the old resource', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
page,
'create terminal owner session',
);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await page
.getByRole('button', { name: /终端.*查看当前任务的终端运行和实时输出/ })
.click();

const terminal = page.getByRole('region', { name: '任务终端' });
await expect(terminal).toBeVisible();
const terminalRef = await terminal.getAttribute('data-terminal-ref');
expect(terminalRef).toBeTruthy();
await expect
.poll(async () =>
(await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId))
.find((update) => update.result.ref === terminalRef)
?.result.status,
)
.toBe('running');

await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(terminal).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate((id) => window.maka.shellRuns.list(id), sessionId))
.find((update) => update.result.ref === terminalRef)
?.result.status,
)
.not.toBe('running');

await composer.fill('create replacement session');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: create replacement session')).toBeVisible();
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
});

test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
page,
'create side chat source session',
);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
const openSideChat = page.getByRole('button', {
name: /侧边对话.*在不打断主任务的情况下追问和只读探索/,
});
await openSideChat.click();

const companion = page.locator('.maka-quote-companion');
await expect(companion).toBeVisible();
const firstForkId = await waitForCompanionForkId(page, sessionId);
await expect(sidebar.locator(`[data-session-id=${JSON.stringify(firstForkId)}]`)).toHaveCount(0);

await page.getByRole('button', { name: '收起任务工作栏' }).click();
await expect(companion).toBeAttached();
await expect(companion).not.toBeVisible();
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === firstForkId),
)
.toBe(true);
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(companion).toBeVisible();

const sideComposer = companion.locator(COMPOSER_INPUT);
await sideComposer.fill('inspect this source without changing it');
await sideComposer.press('Enter');
await expect(companion).toContainText(
'Fake backend received: inspect this source without changing it',
);

const workbarToolbar = page.getByRole('toolbar', { name: '任务工作栏标签' }).first();
const closeActiveSideChat = () =>
workbarToolbar
.getByRole('tab', { selected: true })
.locator('..')
.getByRole('button', { name: /^关闭/ });
await closeActiveSideChat().click();
const confirmation = page.getByRole('dialog');
await expect(confirmation).toContainText('这个临时侧边对话会被永久删除');
await confirmation.getByRole('button', { name: '取消' }).click();
await expect(companion).toBeVisible();

await closeActiveSideChat().click();
await confirmation.getByRole('button', { name: '关闭侧边对话' }).click();
await expect(companion).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === firstForkId),
)
.toBe(false);

await page.getByRole('button', { name: '展开任务工作栏' }).click();
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
await openSideChat.click();
await expect(companion).toBeVisible();
const secondForkId = await waitForCompanionForkId(page, sessionId);

await sidebar.getByRole('button', { name: '新任务', exact: true }).click();
await expect(companion).toHaveCount(0);
await expect
.poll(async () =>
(await page.evaluate(() => window.maka.sessions.list()))
.some((session) => session.id === secondForkId),
)
.toBe(false);
await expect(composer).toHaveText('');
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { appendPending, clearPending, removePending, removePendingItems, selectPending } from '../../renderer/app-shell-pending-attachments.js';
import { appendPending, clearPending, removePending, removePendingItems, selectPending } from '../../renderer/pending-items.js';

describe('pending attachments by draft key', () => {
test('selecting another key never leaks pending from a different session', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,8 @@ import {
createDesktopTranscriptRangeController,
DesktopTranscriptRangeStore,
} from '../../renderer/desktop-transcript-range-store.js';
import {
mergeSettledMessages,
readSettledMessages,
} from '../../renderer/session-message-settlement.js';
import { mergeSettledMessages } from '../../renderer/settled-message-merge.js';
import { readSettledMessages } from '../../renderer/session-message-settlement.js';
import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js';
import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js';

Expand Down
57 changes: 44 additions & 13 deletions apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ import { act, createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { LocaleProvider } from '@maka/ui';
import { NEW_TASK_PENDING_KEY } from '../../renderer/app-shell-pending-attachments.js';
import { useAppShellComposerAttachments } from '../../renderer/use-app-shell-composer-attachments.js';
import { NEW_TASK_PENDING_KEY } from '../../renderer/pending-items.js';
import {
useComposerAttachments,
type ComposerAttachmentService,
} from '../../renderer/use-composer-attachments.js';
import { useAppShellComposerQuotes } from '../../renderer/use-app-shell-composer-quotes.js';

/**
Expand Down Expand Up @@ -99,17 +102,33 @@ async function mountProbe<T>(useHook: (options: { draftKey: string }) => T): Pro
};
}

type PickedFile = {
approvalId: string;
name: string;
mimeType: string;
size: number;
};

const idleAttachmentService: ComposerAttachmentService = {
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'not used' }),
};

function stubFilePicker(): {
resolve(files: { approvalId: string; name: string; mimeType: string; size: number }[]): void;
service: ComposerAttachmentService;
resolve(files: PickedFile[]): void;
} {
let release: (files: never[]) => void = () => {};
const chosen = new Promise<{ ok: true; files: unknown[] }>((resolveChosen) => {
let release: (files: PickedFile[]) => void = () => {};
const chosen = new Promise<{ ok: true; files: PickedFile[] }>((resolveChosen) => {
release = (files) => resolveChosen({ ok: true, files });
});
Object.assign(globalThis.window as unknown as Record<string, unknown>, {
maka: { attachments: { pickFiles: () => chosen } },
});
return { resolve: (files) => release(files as never[]) };
return {
service: {
pickFiles: () => chosen,
previewApproval: async () => ({ ok: false, reason: 'not used' }),
},
resolve: release,
};
}

function textFile(name: string): File {
Expand Down Expand Up @@ -139,7 +158,11 @@ test('a Session keeps its own staged quotes, and the new-task bucket keeps its o

test('a completing send clears the attachments it submitted', async () => {
const probe = await mountProbe((options) =>
useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
useComposerAttachments({
...options,
toastApi: { error() {} },
service: idleAttachmentService,
}),
);

await probe.render(NEW_TASK_PENDING_KEY);
Expand All @@ -160,7 +183,11 @@ test('a completing send clears the attachments it submitted', async () => {

test('retracted queue attachments can be restored and submitted without re-ingest', async () => {
const probe = await mountProbe((options) =>
useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
useComposerAttachments({
...options,
toastApi: { error() {} },
service: idleAttachmentService,
}),
);

await probe.render('session-1');
Expand All @@ -184,11 +211,15 @@ test('retracted queue attachments can be restored and submitted without re-inges
});

test('files chosen in the native dialog land in the composer now on screen', async () => {
const picker = stubFilePicker();
const probe = await mountProbe((options) =>
useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
useComposerAttachments({
...options,
toastApi: { error() {} },
service: picker.service,
}),
);
await probe.render(NEW_TASK_PENDING_KEY);
const picker = stubFilePicker();

const picking = probe.latest().pickAttachments();
// The dialog is modal to its own window, not to the app: the surface behind
Expand Down
Loading