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
34 changes: 34 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,40 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

xterm.js dependency patch

Source: https://www.npmjs.com/package/@xterm/xterm/v/6.0.0
Repository: https://github.com/xtermjs/xterm.js
Version: 6.0.0
Dependency patch: patches/@xterm+xterm+6.0.0.patch
License: MIT

Maka redistributes a patch to the TypeScript source and both shipped JavaScript
bundles that defers selection rendering while the terminal is hidden. The
following upstream license applies to that material:

Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

Pi TUI dependency patch

Source: https://www.npmjs.com/package/@earendil-works/pi-tui/v/0.84.4
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
},
"session-workbar.spec.ts": {
"tests": 5,
"electron": "Git changes re-read on native window focus; terminal PTY ownership changes across Sessions; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload."
"electron": "Git changes re-read on native window focus; native PTY ownership survives Session navigation and renderer replacement, while Stop/exit cross preload/main into the terminal view; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload."
},
"settings.spec.ts": {
"tests": 2,
Expand Down
38 changes: 36 additions & 2 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ 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 ({
// Exercises the real Electron preload/main controller lease across renderer
// replacement and native PTY Stop/exit delivery to the mounted xterm. Node
// controller tests cover ordering; they do not mount the production bridge.
test('Terminal survives navigation and reload, then stops on explicit close', async ({
window: page,
}) => {
const { composer, sessionId, sidebar } = await createSession(
Expand Down Expand Up @@ -204,14 +207,45 @@ test('Terminal ownership follows the active Session and stops the old resource',
.find((update) => update.result.ref === terminalRef)
?.result.status,
)
.not.toBe('running');
.toBe('running');

await composer.fill('create replacement session');
await awaitSendReady(page);
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();
await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(terminal).toBeVisible();
await expect(terminal).toHaveAttribute('data-terminal-ref', terminalRef!);
await page.reload();
await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(terminal).toBeVisible();
await expect(terminal).toHaveAttribute('data-terminal-ref', terminalRef!);
await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click();
await page.getByRole('menu').getByRole('menuitem', { name: /终端/ }).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');

// Natural exit ends live controls while the local picture remains. Reload
// does not promise to recover a completed terminal's contents or its tab.
await page.getByRole('button', { name: '展开任务工作栏' }).click();
await page.getByRole('button', { name: /终端.*查看当前任务的终端运行和实时输出/ }).click();
await expect(terminal).toBeVisible();
const completedRef = await terminal.getAttribute('data-terminal-ref');
await page.evaluate(async ({ sessionId, ref }) => {
await window.maka.shellRuns.write({ sessionId, ref: ref!, input: 'exit 0\r' });
}, { sessionId, ref: completedRef });
await expect.poll(async () => page.evaluate(async ({ sessionId, ref }) =>
(await window.maka.shellRuns.list(sessionId)).find((update) => update.result.ref === ref)?.result,
{ sessionId, ref: completedRef })).toMatchObject({ status: 'completed', exitCode: 0 });
await expect(terminal).toBeVisible();
await page.reload();
await sidebar.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click();
await expect(terminal).toHaveCount(0);
});

test('Side Chat survives collapse, confirms close, and cleans up on source switch', async ({
Expand Down
22 changes: 10 additions & 12 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@
"@maka/ui": 1
},
"importSpecifiers": 10,
"nonTriviaTokens": 3646
"nonTriviaTokens": 3636
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 4,
Expand Down Expand Up @@ -442,7 +442,7 @@
"nonTriviaTokens": 642
},
"src/renderer/app-shell-effects.ts": {
"importDeclarations": 11,
"importDeclarations": 10,
"bridgePaths": {
"window.maka.app.info": 1,
"window.maka.appWindow.subscribeCommand": 1,
Expand Down Expand Up @@ -478,7 +478,6 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"../shared/runtime-host-identity.js": 1,
"./app-shell-copy": 1,
"./browser-storage": 1,
"./locales/conversation-copy.js": 1,
Expand All @@ -491,7 +490,7 @@
"@maka/core/session-event-health": 1,
"react": 1
},
"importSpecifiers": 19,
"importSpecifiers": 18,
"nonTriviaTokens": 3718
},
"src/renderer/app-shell-overlays.tsx": {
Expand Down Expand Up @@ -577,7 +576,7 @@
"@maka/core/session": 1
},
"importSpecifiers": 8,
"nonTriviaTokens": 2156
"nonTriviaTokens": 2155
},
"src/renderer/app-shell-session-events.ts": {
"importDeclarations": 2,
Expand Down Expand Up @@ -676,7 +675,7 @@
"./session-workspace-errors.js": 1
},
"importSpecifiers": 3,
"nonTriviaTokens": 650
"nonTriviaTokens": 620
},
"src/renderer/app-shell-turn-view-model.ts": {
"importDeclarations": 6,
Expand Down Expand Up @@ -860,7 +859,7 @@
"react": 1
},
"importSpecifiers": 104,
"nonTriviaTokens": 13459
"nonTriviaTokens": 13402
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
Expand Down Expand Up @@ -932,9 +931,8 @@
"useAppShellSessionList": 1,
"useAppShellSessionUiState": 1,
"useExternalStoreSelector": 1,
"useRef": 5,
"useSessionCatalogController": 1,
"useState": 2
"useRef": 4,
"useSessionCatalogController": 1
},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
Expand All @@ -949,8 +947,8 @@
"./use-external-store-selector.js": 1,
"react": 1
},
"importSpecifiers": 9,
"nonTriviaTokens": 464
"importSpecifiers": 8,
"nonTriviaTokens": 461
}
},
"closure": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,8 @@ describe('busy-raced send settlement', () => {
activated.push(sessionId);
activeIdRef.current = sessionId;
},
setActiveId: (sessionId: string | undefined) => {
activeIdRef.current = sessionId;
retireSession: (sessionId: string) => {
if (activeIdRef.current === sessionId) activeIdRef.current = undefined;
},
...transientState.deps,
});
Expand Down Expand Up @@ -460,8 +460,8 @@ describe('busy-raced send settlement', () => {
activateSessionForFirstSend: async (sessionId) => {
activeIdRef.current = sessionId;
},
setActiveId: (sessionId: string | undefined) => {
activeIdRef.current = sessionId;
retireSession: (sessionId: string) => {
if (activeIdRef.current === sessionId) activeIdRef.current = undefined;
},
...transientState.deps,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function createActionsDeps() {
activateSessionForFirstSend: async (sessionId: string) => {
activeIdRef.current = sessionId;
},
setActiveId: () => undefined,
retireSession: (_sessionId: string) => undefined,
setMessageLoadErrorBySession: () => undefined,
addTransientMessage: () => undefined,
updateTransientMessage: () => undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,8 @@ describe('composer first-send cleanup', () => {
activeIdRef.current = sessionId;
throw new Error('Timed out while preparing the new Session event stream');
},
setActiveId: (sessionId) => {
activeIdRef.current = sessionId;
retireSession: (sessionId) => {
if (activeIdRef.current === sessionId) activeIdRef.current = undefined;
},
isNewChatSendSurfaceActive: () => activeIdRef.current === undefined,
isShellSurfaceOwnerActive: (owner) =>
Expand Down Expand Up @@ -591,7 +591,10 @@ describe('composer first-send cleanup', () => {
const { root } = installReactRenderer();
let publication!: ReturnType<typeof useAppShellSessionUiState>['publication'];
function Probe(): null {
publication = useAppShellSessionUiState(deps.activeIdRef, () => {}).publication;
publication = useAppShellSessionUiState(
[], undefined, deps.activeIdRef,
(_sessionId, _messages, _controller: DesktopTranscriptRangeController) => true,
).publication;
return null;
}
try {
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/main/__tests__/app-shell-turn-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,26 @@ import { createAppShellTurnActions } from '../../renderer/app-shell-turn-actions
test('preserves a Branch copy identity after an ambiguous failure and completes it on success', async () => {
const calls: Array<{ sourceTurnId: string; copyId?: string }> = [];
let loseFirstResponse = true;
let selectionRevision = 0;
let navigateDuringBranch = false;
const restoreWindow = installWindow(async (_sessionId, input) => {
calls.push(input);
if (loseFirstResponse) {
loseFirstResponse = false;
throw new Error('Committed response was lost');
}
if (navigateDuringBranch) selectionRevision += 1;
return session(input.copyId ?? 'missing-copy-id');
});
const pending = new Set<string>();
const opened: string[] = [];
const actions = createAppShellTurnActions({
uiLocale: 'en',
activeIdRef: { current: 'branch-action-source' },
captureSelection: () => {
const revision = selectionRevision;
return () => revision === selectionRevision;
},
turnActionRegistry: {
addKey: (key) => {
if (pending.has(key)) return false;
Expand All @@ -52,9 +59,7 @@ test('preserves a Branch copy identity after an ambiguous failure and completes
openSessionInChat: (sessionId) => {
opened.push(sessionId);
},
refreshMessages: async () => true,
refreshSessions: async () => [],
setMessages: () => undefined,
toastApi: { info() {}, success() {}, error() {} },
});

Expand All @@ -65,8 +70,11 @@ test('preserves a Branch copy identity after an ambiguous failure and completes
assert.equal(calls[0]?.copyId, calls[1]?.copyId);
assert.deepEqual(opened, [calls[0]?.copyId]);

// The display may still be the source while a newer navigation is loading.
navigateDuringBranch = true;
await actions.handleTurnFooterAction('branch-action-turn', 'branch');
assert.equal(calls.length, 3);
assert.equal(opened.length, 1, 'late Branch must not replace the newer selection');
assert.notEqual(calls[2]?.copyId, calls[1]?.copyId);
} finally {
restoreWindow();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,8 @@ test('drives bounded Session domain projections through real UDS framing', async
const client = new DesktopRuntimeHostClient(connected.connection);
const ipc = ipcHarness();
registerRuntimeHostSessionDomainsIpc(
{ client, emitModeChanged() {}, sessionObserver: unusedSessionObserver() },
{ client, emitModeChanged() {}, sessionObserver: unusedSessionObserver(),
terminalCloses: new (await import('../terminal-close-intents.js')).TerminalCloseIntents() },
ipc,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@

import assert from 'node:assert/strict';
import test from 'node:test';
import { deferred } from '@maka/core/test-only/async-primitives';
import { TerminalCloseIntents } from '../terminal-close-intents.js';
import type { TerminalCloseChange, TerminalRecovery } from '../../shared/runtime-host-identity.js';
import type { IpcMain } from 'electron';
import { projectDeepResearchClientProgress } from '@maka/core/deep-research-client-progress';
import { type DeepResearchRun } from '@maka/core/deep-research-run';
Expand Down Expand Up @@ -571,6 +574,49 @@ test('adapts bounded Agent Graph epoch reads without changing graph identity', a
]);
});

test('keeps a failed Close across connection replacement and acknowledges Stop without a post-read', async () => {
const changes: TerminalCloseChange[] = [];
const closes = new TerminalCloseIntents((change) => changes.push(change));
const firstStop = deferred<Awaited<ReturnType<DomainClient['stopRuntimeResource']>>>();
const identity = { sessionId: 'session-1', ref: 'terminal' };
let attempts = 0;
const first = ipcHarness();
const old = registerDomainsIpc({
terminalCloses: closes, emitModeChanged() {},
client: domainClient({ stopRuntimeResource: () => { attempts += 1; return firstStop.promise; } }),
}, first);
const stopping = first.invoke('shell-runs:stop', identity);
const rejected = assert.rejects(stopping, /disconnected/);
await old.close();

const second = ipcHarness();
registerDomainsIpc({
terminalCloses: closes, emitModeChanged() {},
client: domainClient({
listRuntimeResources: async () => [],
getRuntimeResource: async () => { throw new Error('must not reread after Stop'); },
stopRuntimeResource: async () => { attempts += 1; return { resource: shellRunUpdate().result as never }; },
}),
}, second);
const recovering = await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery;
assert.deepEqual(recovering.closes, [{ ...identity, status: 'pending' }]);
firstStop.reject(new Error('disconnected after old view closed'));
await rejected;
const unknown = await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery;
assert.deepEqual(unknown.closes, [{ ...identity, status: 'unknown' }]);
await second.invoke('shell-runs:stop', identity);
assert.equal(attempts, 2);
assert.deepEqual(changes.map((change) => change.status), ['pending', 'unknown', 'pending', 'closed']);
assert.deepEqual((await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery).closes, []);
const retiringStop = deferred<void>();
const lateFailure = assert.rejects(closes.stop(identity, () => retiringStop.promise), /late/);
closes.retireSession(identity.sessionId);
retiringStop.reject(new Error('late response after successful owner retirement'));
await lateFailure;
assert.deepEqual((await second.invoke('shell-runs:recover', identity.sessionId) as TerminalRecovery).closes, []);
assert.equal(changes.at(-1)?.status, 'closed');
});

test('adapts interactive terminal ownership to one Host controller lease', async () => {
const calls: Array<{ operation: string; input: unknown }> = [];
const update = shellRunUpdate({
Expand Down Expand Up @@ -1387,13 +1433,14 @@ function reconciledIpcHarness() {
}

function registerDomainsIpc(
deps: Omit<RuntimeHostSessionDomainsIpcDeps, 'sessionObserver'> &
Partial<Pick<RuntimeHostSessionDomainsIpcDeps, 'sessionObserver'>>,
deps: Omit<RuntimeHostSessionDomainsIpcDeps, 'sessionObserver' | 'terminalCloses'> &
Partial<Pick<RuntimeHostSessionDomainsIpcDeps, 'sessionObserver' | 'terminalCloses'>>,
ipcMain: ReconnectableReadIpcMain,
) {
return registerRuntimeHostSessionDomainsIpc(
{
...deps,
terminalCloses: deps.terminalCloses ?? new TerminalCloseIntents(),
sessionObserver: deps.sessionObserver ?? {
async observe() {},
async unobserve() {},
Expand Down
Loading