Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4949c7f
feat(desktop): add host-bound folder references
sunrioa Aug 28, 2026
43a4795
test: fix Windows directory reference regression checks
sunrioa Aug 28, 2026
f42a4a7
test(runtime-host): close linked-child fixture storage
sunrioa Aug 28, 2026
f6f9c04
fix(runtime): contain directory reference preparation failures
sunrioa Aug 28, 2026
aa342d5
chore: merge main into directory reference branch
sunrioa Aug 28, 2026
9082423
refactor: keep directory references path-only
sunrioa Aug 29, 2026
2d3fe4c
test(runtime-host): close directory reference fixture stores
sunrioa Aug 29, 2026
522c543
Merge origin/main into codex/feat-composer-directory-references
sunrioa Aug 29, 2026
c79ef5e
fix(desktop): scope directory drafts to composer
sunrioa Aug 29, 2026
1b63bb5
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 29, 2026
82b1600
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 29, 2026
82d148a
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 29, 2026
deb0093
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 30, 2026
a63b4dd
refactor(desktop): align directory references with renderer ownership
sunrioa Aug 30, 2026
87d6329
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 30, 2026
c244334
fix(ui): block editing folder-reference messages
sunrioa Aug 30, 2026
7ec0c15
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 30, 2026
06c7991
Merge main and simplify directory reference tests
sunrioa Aug 31, 2026
1d81a6a
Fix composer attachment menu test selector
sunrioa Aug 31, 2026
ae7aeff
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
6ca92e5
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
0a6388a
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
2133027
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
1ac4ad8
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
41da9e8
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
afa9022
fix(desktop): keep composer hook debt flat
sunrioa Aug 31, 2026
70427c2
Merge origin/main into codex/feat-composer-directory-references
sunrioa Aug 31, 2026
d49eded
Merge origin/main into codex/feat-composer-directory-references
sunrioa Aug 31, 2026
b40a85c
Merge origin/main into codex/feat-composer-directory-references
sunrioa Aug 31, 2026
45ed6f6
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
11dd3d6
Merge remote-tracking branch 'origin/main' into codex/feat-composer-d…
sunrioa Aug 31, 2026
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
77 changes: 77 additions & 0 deletions apps/desktop/e2e/composer-directory-reference.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { COMPOSER_INPUT, expect, test } from './fixtures';

test('a folder reference is removable, survives send/reload, and leaves project selection unchanged', async ({
directoryReferenceWindow: { page, folder },
}, testInfo) => {
const composer = page.locator(COMPOSER_INPUT);
const project = page.locator('button.maka-workspace-picker');
// The composer can mount before TaskEntry loads the initial project selection.
// Compare the settled selection, not the generic label shown during loading.
const originalProject = '选择项目:无项目';
await expect(project).toHaveAttribute('aria-label', originalProject);
const pick = async (keyboard = false) => {
const trigger = page.locator('.maka-composer-plus-menu button').first();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
if (keyboard) {
// Exercise keyboard reopening as well. Astryx intentionally ignores pointer
// reopening within 50ms of dismiss; the native chooser mock returns instantly.
await trigger.press('ArrowDown');
} else {
await trigger.click();
}
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await page.getByRole('menuitem', { name: '引用文件夹', exact: true }).click();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
};

await pick();
const chip = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token');
await expect(chip).toContainText('referenced-source');
await chip.getByRole('button').click();
await expect(chip).toHaveCount(0);
await pick(true);
await expect(chip).toContainText('referenced-source');
await expect(project).toHaveAttribute('aria-label', originalProject);
await composer.fill('请检查引用目录');
await page.screenshot({ path: testInfo.outputPath('directory-reference-staged.png') });
await composer.press('Enter');

const user = page.getByLabel('你发送的消息').first();
await expect(user).toContainText('请检查引用目录');
await expect(user).toContainText('referenced-source');
await expect(user).not.toContainText('README.md');
const transcript = page.getByRole('log');
await expect(transcript).not.toContainText('README.md');
await expect(transcript).not.toContainText('"status":"listed"');
await expect(transcript).not.toContainText('DO_NOT_READ_FILE_CONTENTS');
await expect(transcript).not.toContainText('deep.txt');
await expect(chip).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

const sessions = await page.evaluate(() => window.maka.sessions.list());
expect(sessions).toHaveLength(1);
expect(sessions[0]!.cwd).not.toBe(folder);
await page.reload();
await expect(page.getByLabel('你发送的消息').first()).toContainText('referenced-source');
await expect(page.getByRole('log')).not.toContainText('README.md');
await page.screenshot({ path: testInfo.outputPath('directory-reference-sent.png') });
});
22 changes: 20 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ async function withE2eWindow(
railRenderSessions?: boolean;
newTaskProject?: boolean;
},
use: (page: Page, context: { userDataDir: string }) => Promise<void>,
use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
// Lives inside the throwaway userData dir so the existing teardown removes
Expand Down Expand Up @@ -477,7 +477,7 @@ async function withE2eWindow(
const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
}
await use(page, { userDataDir });
await use(page, { userDataDir, app });
} finally {
try {
if (app) await closeElectronApplication(app, 5_000);
Expand All @@ -501,8 +501,26 @@ export const test = base.extend<{
promptRailMotionWindow: Page;
requestHeaderRowWindow: Page;
newTaskTargetWindow: Page;
directoryReferenceWindow: { page: Page; folder: string };
accessibilityNarrativeWindow: Page;
}>({
directoryReferenceWindow: async ({}, use) => {
await withE2eWindow(
{ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', showWindow: true },
async (page, { userDataDir, app }) => {
const folder = path.join(userDataDir, 'referenced-source');
await mkdir(path.join(folder, 'nested'), { recursive: true });
await writeFile(path.join(folder, 'README.md'), 'DO_NOT_READ_FILE_CONTENTS');
await writeFile(path.join(folder, 'nested', 'deep.txt'), 'DO_NOT_DESCEND');
// Replace only the OS chooser. IPC, Host admission, message delivery,
// event persistence and rendering still run through the real stack.
await app.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] });
}, folder);
await use({ page, folder });
},
);
},
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
window: async ({}, use) => {
await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use);
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@
"@maka/ui": 1
},
"importSpecifiers": 39,
"nonTriviaTokens": 4376
"nonTriviaTokens": 4278
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 5,
Expand Down Expand Up @@ -711,7 +711,7 @@
"@maka/ui": 1
},
"importSpecifiers": 23,
"nonTriviaTokens": 3042
"nonTriviaTokens": 3041
},
"src/renderer/app-shell-session-settings-actions.ts": {
"importDeclarations": 9,
Expand Down Expand Up @@ -1061,7 +1061,7 @@
"react": 1
},
"importSpecifiers": 187,
"nonTriviaTokens": 15905
"nonTriviaTokens": 15882
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ async function mountRegion(): Promise<{
children: createElement(AstryxLocaleProvider, {
children: createElement(ChatComposerRegion, {
composerRef: composer,
directoryComposerProps: {},
directoryPickerEnabled: false,
active: true,
onboardingComposerHidden: false,
activeInteraction: undefined,
Expand Down
145 changes: 145 additions & 0 deletions apps/desktop/src/main/__tests__/composer-directories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { afterEach, test } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import { normalizeSessionSendCommand } from '../permission-response-guard.js';
import {
useComposerAttachments,
type ComposerAttachmentService,
} from '../../renderer/use-composer-attachments.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';

afterEach(cleanupFakeDom);

type Picker = NonNullable<ComposerAttachmentService['pickDirectory']>;
type Options = { draftKey: string; hostId?: string; pick: Picker };
type State = ReturnType<typeof useComposerAttachments>;
const reference = { hostId: 'host-a', path: '/workspace/source' };

async function mount(initial: Partial<Options> = {}) {
const { root } = installReactRenderer();
let state!: State;
const errors: string[] = [];
let options: Options = {
draftKey: 'draft-a',
hostId: 'host-a',
pick: async () => ({ ok: true, reference }),
...initial,
};
function Probe() {
state = useComposerAttachments({
draftKey: options.draftKey,
directoryHostId: options.hostId,
service: {
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'not used' }),
pickDirectory: options.pick,
},
toastApi: { error: (title, description) => errors.push(description ?? title) },
});
return null;
}
const render = async (patch: Partial<Options> = {}) => {
options = { ...options, ...patch };
await act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) })));
};
await render();
return { state: () => state, render, errors };
}

test('directory picker cancellation, duplicates and removal leave the draft consistent', async () => {
const probe = await mount({ pick: async () => ({ ok: false, reason: 'cancelled' }) });
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
assert.deepEqual(probe.state().pendingDirectories, []);
await probe.render({ pick: async () => ({ ok: true, reference }) });
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
assert.deepEqual(probe.state().pendingDirectories, [reference]);
await act(() => probe.state().directoryComposerProps.onRemoveDirectory(0));
assert.deepEqual(probe.state().pendingDirectories, []);
assert.deepEqual(probe.errors, []);
});

test('discards a picker reply after its draft or Host changes', async () => {
for (const patch of [{ draftKey: 'draft-b' }, { hostId: 'host-b' }]) {
let resolve!: (result: Awaited<ReturnType<Picker>>) => void;
const pending = new Promise<Awaited<ReturnType<Picker>>>((settle) => { resolve = settle; });
const probe = await mount({ pick: () => pending });
let picked!: Promise<void>;
await act(() => { picked = probe.state().directoryComposerProps.onPickDirectory!(); });
await probe.render(patch);
await act(async () => { resolve({ ok: true, reference }); await picked; });
assert.deepEqual(probe.state().pendingDirectories, []);
}
});

test('rejects a foreign Host picker result and does not pick without a local Host', async () => {
let picks = 0;
const probe = await mount({ hostId: undefined, pick: async () => {
picks += 1;
return { ok: true, reference: { ...reference, hostId: 'host-b' } };
} });
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
assert.equal(picks, 0);
await probe.render({ hostId: 'host-a' });
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
assert.equal(probe.errors.length, 1);
assert.deepEqual(probe.state().pendingDirectories, []);
});

test('caps concurrent picker results and clearing a submitted draft keeps newer references', async () => {
let sequence = 0;
const probe = await mount({ pick: async () => ({
ok: true, reference: { ...reference, path: '/workspace/' + ++sequence },
}) });
const pick = probe.state().directoryComposerProps.onPickDirectory!;
await act(() => Promise.all(Array.from({ length: 6 }, pick)).then(() => undefined));
assert.equal(probe.state().pendingDirectories.length, 4);
assert.equal(probe.state().directoryComposerProps.onPickDirectory, undefined);
const clearSubmitted = probe.state().clearSubmittedContext;
await act(() => probe.state().directoryComposerProps.onRemoveDirectory(0));
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
await probe.render({ draftKey: 'draft-b' });
await act(() => probe.state().directoryComposerProps.onPickDirectory!());
await act(() => clearSubmitted());
assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a different draft');
await probe.render({ draftKey: 'draft-a' });
assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a reference added after send');
});

test('IPC validates directory references without turning them into attachments or permissions', () => {
const normalized = normalizeSessionSendCommand({
type: 'send', text: 'inspect', directoryReferences: [reference],
});
assert.deepEqual(normalized?.directoryReferences, [reference]);
assert.equal(normalized?.attachmentItems, undefined);
assert.notEqual(normalized?.directoryReferences?.[0], reference);
for (const references of [
[{ ...reference, path: '../outside' }],
[{ ...reference, grant: 'read' }],
Array.from({ length: 5 }, () => reference),
]) {
assert.throws(() => normalizeSessionSendCommand({
type: 'send', text: 'inspect', directoryReferences: references,
}), /Invalid directory references/);
}
});
23 changes: 22 additions & 1 deletion apps/desktop/src/main/permission-response-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import type {
ReviseBeforeTurnInput,
TurnOrchestration,
} from '@maka/core/runtime-inputs';
import type { QuoteRef } from '@maka/core/events';
import {
isDirectoryReference,
DIRECTORY_REFERENCE_MAX_COUNT,
type DirectoryReference,
type QuoteRef,
} from '@maka/core/events';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
Expand Down Expand Up @@ -60,6 +65,7 @@ interface NormalizedSendSessionCommand {
attachmentItems?: unknown;
retainedAttachments?: AttachmentRef[];
turnOrchestration?: TurnOrchestration;
directoryReferences?: DirectoryReference[];
quotes?: QuoteRef[];
workspaceFileReferences?: WorkspaceFileReferencePosition[];
}
Expand Down Expand Up @@ -189,6 +195,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
...(value.turnOrchestration !== undefined
? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) }
: {}),
...normalizeOptionalDirectoryReferences(value.directoryReferences),
...normalizeOptionalQuotes(value.quotes),
...normalizeOptionalWorkspaceFileReferences(
value.workspaceFileReferences,
Expand Down Expand Up @@ -387,3 +394,17 @@ function normalizeOptionalSendTurnId(input: unknown): { turnId?: string } {
turnId: normalizeRequiredString(input, 'Invalid send turnId', MAX_TURN_ID_LENGTH),
};
}

function normalizeOptionalDirectoryReferences(
input: unknown,
): { directoryReferences?: DirectoryReference[] } {
if (input === undefined) return {};
if (
!Array.isArray(input) ||
input.length > DIRECTORY_REFERENCE_MAX_COUNT ||
!input.every(isDirectoryReference)
) {
throw new Error('Invalid directory references');
}
return input.length ? { directoryReferences: input.map((ref) => ({ ...ref })) } : {};
}
13 changes: 13 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,19 @@ function registerPersistentClientIpc(): void {
}),
);
registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics });
ipcMain.handle('directories:pick', async () => {
const local = runtimeHostManager?.entries().find(
(state) => state.target.profile.kind === 'local',
);
if (!local || local.readiness !== 'ready') throw new Error('Local Runtime Host is unavailable');
const hostId = local.candidate.client.hostId;
const result = await mainWindowController.showOpenDialog({
title: 'Reference folder',
properties: ['openDirectory'],
});
if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' };
return { ok: true, reference: { hostId, path: result.filePaths[0] } };
});
ipcMain.handle("attachments:pickFiles", async (event) => {
const result = await mainWindowController.showOpenDialog({
title: "Add attachments",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export function registerRuntimeHostSessionExecutionIpc(
? { displayText: command.displayText }
: {}),
...(attachments.length > 0 ? { attachments } : {}),
...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}),
...(command.quotes ? { quotes: command.quotes } : {}),
inlineReferences,
},
Expand Down Expand Up @@ -474,6 +475,7 @@ export function registerRuntimeHostSessionExecutionIpc(
? { displayText: command.displayText }
: {}),
...(attachments.length > 0 ? { attachments } : {}),
...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}),
...(command.quotes ? { quotes: command.quotes } : {}),
inlineReferences,
},
Expand Down
Loading