Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
26086c9
perf(desktop): bound session-switch history and skip unrelated resour…
testikun Sep 25, 2026
3444235
chore(ui): refresh surface inventory after paging cleanup
testikun Sep 25, 2026
2d8f9d6
test(desktop): exercise scroll-driven history paging
testikun Sep 25, 2026
f105ea6
merge: refresh session-switch optimization on main
testikun Sep 28, 2026
8b7cc3d
test(desktop): tolerate formatted startup lifecycle source
testikun Sep 28, 2026
36edb69
Merge remote-tracking branch 'upstream/main' into codex/issue-5627-se…
testikun Sep 28, 2026
f8687f1
Merge remote-tracking branch 'upstream/main' into codex/issue-5627-se…
testikun Sep 28, 2026
997e0fd
fix(desktop): load complete transcript for full-history actions
testikun Sep 28, 2026
25bff09
fix(desktop): address bounded history review
testikun Sep 28, 2026
6f164b9
fix(desktop): preserve clipboard failure feedback
testikun Sep 28, 2026
e94f210
refactor(desktop): reuse shared toast contract
testikun Sep 28, 2026
5738cc9
docs(desktop): refresh Astryx surface inventory
testikun Sep 28, 2026
e5f27b7
test(desktop): retain history paging control
testikun Sep 28, 2026
5017c75
fix(desktop): reconcile WorkHub history errors
testikun Sep 28, 2026
8d73d4e
Merge remote-tracking branch 'upstream/main' into codex/issue-5627-se…
testikun Sep 30, 2026
79fd458
perf(desktop): seek transcript windows and defer collapsed process bo…
testikun Sep 30, 2026
929a300
Merge upstream/main into codex/issue-5627-session-switch
testikun Sep 30, 2026
0e7b46c
Merge upstream/main into codex/issue-5627-session-switch
testikun Sep 30, 2026
2a104db
test(desktop): align transcript stories with lazy mounting and native…
testikun Sep 30, 2026
d86fe4f
fix(desktop): preserve transcript continuity across window navigation
testikun Sep 30, 2026
8bf70da
Merge upstream/main into codex/issue-5627-session-switch
testikun Sep 30, 2026
36a1239
Merge upstream/main and resolve renderer architecture ledger conflict
testikun Oct 1, 2026
b21a0a6
Merge main and preserve bounded transcript ownership
testikun Oct 8, 2026
0f4161d
Merge upstream/main into codex/issue-5627-session-switch
testikun Oct 13, 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
18 changes: 11 additions & 7 deletions apps/desktop/e2e/partial-history-notice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,29 @@ async function wheelToTop(page: Page): Promise<void> {
await frames(page, 4);
}

test('a transcript over the history budget loads earlier Turns only on request', async ({
test('a transcript over the history budget loads earlier Turns on upward reading', async ({
partialHistoryWindow: page,
}) => {
test.setTimeout(90_000);
await page.setViewportSize({ width: 1_400, height: 800 });
const loadEarlier = page.getByRole('button', { name: '载入更早的记录' });
const pendingLoad = page.locator('button:disabled', { hasText: '载入更早的记录' });
const transcript = page.locator('.maka-chat-message-list');
const ticks = page.locator(TICK);
const loadedTurnCount = async (): Promise<number> =>
Number(await transcript.getAttribute('data-turn-source-count'));

await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible();
// The Host Turn index lists the whole Session before its history is loaded.
await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT);
await expect(loadEarlier).toHaveCount(1);
let loadedTurns = await loadedTurnCount();
expect(loadedTurns).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);
await frames(page, 4);
expect(await loadedTurnCount()).toBe(loadedTurns);

let loads = 0;
while ((await loadEarlier.count()) > 0) {
while (loadedTurns < PARTIAL_HISTORY_TURN_COUNT) {
await wheelToTop(page);
await loadEarlier.click();
await expect(pendingLoad).toHaveCount(0, { timeout: 30_000 });
await expect.poll(loadedTurnCount, { timeout: 30_000 }).toBeGreaterThan(loadedTurns);
loadedTurns = await loadedTurnCount();
loads += 1;
expect(loads).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 2158
"nonTriviaTokens": 2143
},
"src/renderer/app-shell-copy.ts": {
"importDeclarations": 1,
Expand Down Expand Up @@ -731,7 +731,7 @@
"react": 1
},
"importSpecifiers": 43,
"nonTriviaTokens": 7290
"nonTriviaTokens": 7285
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 3,
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop/src/main/__tests__/app-shell-command-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
buildAppShellCommandList,
resolveManualDiagnosticTarget,
type AppShellCommandListOptions,
} from '../../renderer/app-shell-command-actions.js';
import {
contextCompactionNotice,
Expand Down Expand Up @@ -69,6 +71,102 @@ test('targets manual diagnostics to the current task or new-task Host profile',
);
});

test('conversation copy and save wait for the complete transcript', async (t) => {
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
let clipboard = '';
let clipboardFailure = false;
let saved = '';
const errors: Array<[string, string]> = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { clipboard: { writeText: async (value: string) => {
if (clipboardFailure) throw new Error('clipboard denied');
clipboard = value;
} } },
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
maka: {
sessions: {
saveConversationToFile: async ({ markdown }: { markdown: string }) => {
saved = markdown;
return { ok: true };
},
},
},
},
});
t.after(() => {
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator);
else Reflect.deleteProperty(globalThis, 'navigator');
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});

const requested: string[] = [];
const options = {
uiLocale: 'en',
activeId: 'session-1',
activePermissionMode: undefined,
canSetPermissionMode: true,
clientPathsAccessible: false,
connections: [],
defaultConnection: null,
renderCompleteConversation: async (sessionId: string) => {
requested.push(sessionId);
return 'oldest question\nnewest answer';
},
paletteActions: {
saveConversationToFile: async ({ markdown }: { markdown: string }) => {
saved = markdown;
return { ok: true };
},
},
newTaskProfileId: undefined,
settingsOpen: false,
settingsProfileId: undefined,
sessionCatalog: {
getState: () => ({ sessions: [{ id: 'session-1', name: 'Long task' }] }),
},
themePref: 'auto',
hiddenSessionIds: new Set<string>(),
captureComposerImportOwner: () => ({ sessionId: 'session-1', navSection: 'sessions' }),
createSession() {},
openSideConversation() {},
openHelp() {},
openScheduledTaskCreate() {},
openProjectFolder: async () => {},
openSessionInChat() {},
openSettings() {},
openSettingsSection() {},
openWorkspaceFolder: async () => {},
refreshConnections: async () => {},
copyTodayDailyReview: async () => {},
pasteTodayDailyReview: async () => {},
saveTodayDailyReview: async () => {},
setNavSelection() {},
setPermissionMode: async () => true,
setThemePref() {},
toastApi: { success() {}, info() {}, error(title: string, description: string) { errors.push([title, description]); } },
} as unknown as AppShellCommandListOptions;
const commands = buildAppShellCommandList({ current: options });

await commands.find(({ id }) => id === 'diag:export-conversation')?.run();
await commands.find(({ id }) => id === 'diag:save-conversation-file')?.run();

assert.deepEqual(requested, ['session-1', 'session-1']);
assert.match(clipboard, /oldest question/);
assert.match(clipboard, /newest answer/);
assert.match(saved, /oldest question/);
assert.match(saved, /newest answer/);

clipboardFailure = true;
await commands.find(({ id }) => id === 'diag:export-conversation')?.run();
assert.deepEqual(errors, [['Copy failed', 'Clipboard unavailable']]);
});

test('presents every successful-frame context compaction outcome', () => {
assert.deepEqual(contextCompactionNotice({ kind: 'compacted', checkpointId: 'checkpoint-1' }, 'en'), {
level: 'success',
Expand Down
9 changes: 5 additions & 4 deletions apps/desktop/src/main/__tests__/app-shell-command-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function appShellCommandOptions(
clientPathsAccessible: false,
connections: [],
defaultConnection: null,
renderPublishedConversation: () => '',
renderCompleteConversation: async () => '',
newTaskProfileId: 'new-task-profile',
settingsOpen: false,
settingsProfileId: undefined,
Expand Down Expand Up @@ -71,10 +71,11 @@ export function appShellCommandOptions(
setPermissionMode: async () => true,
setThemePref() {},
toastApi: {
success: (title) => toasts.push(`success:${title}`),
info() {},
toast: () => '', warning: () => '', confirm: async () => false, dismiss() {},
success: (title) => String(toasts.push(`success:${title}`)),
info: () => '',
error: (title, description, _details, target) =>
toasts.push(`error:${title}:${description}:${JSON.stringify(target)}`),
String(toasts.push(`error:${title}:${description}:${JSON.stringify(target)}`)),
},
...overrides,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,26 @@ describe('composer first-send cleanup', () => {
assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true);
});

it('a parked reader settles only after durable proof without publishing the unseen tail', async () => {
const catalog = createSessionCatalogController();
const workspace = createConversationWorkspace(catalog, stubConversationServices().observation);
workspace.commands.setActiveId('session');
let durable = false;
const controller = {
ready: async () => {},
waitForDurableMessage: async () => durable,
store: {
snapshot: () => ({ sessionId: 'session', messages: [], hasNewer: true }),
hasDurableMessage: () => false,
},
} as unknown as DesktopTranscriptRangeController;
workspace.transcriptRangeRef.current = controller;
const actions = createTranscriptCommands(workspace, { current: { locale: 'en', toast: { error: () => '' } } });
assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), false);
durable = true;
assert.equal(await actions.refreshMessages('session', { requiredAssistantMessageId: 'answer' }), true);
});

it('an in-flight refresh reads publication that commits after the call began', async () => {
const catalog = createSessionCatalogController();
const workspace = createConversationWorkspace(catalog, stubConversationServices().observation);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ describe('command palette Desktop actions', () => {
const exports: unknown[][] = [];
const options = appShellCommandOptions(toasts, {
paletteActions: palette,
renderPublishedConversation: (sessionName, locale) => {
exports.push([sessionName, locale]);
renderCompleteConversation: async (sessionId, sessionName, locale) => {
exports.push([sessionId, sessionName, locale]);
return '# Long task export';
},
});
Expand All @@ -150,7 +150,7 @@ describe('command palette Desktop actions', () => {
assert.equal(name, 'saveConversationToFile');
assert.deepEqual(Object.keys(input as object), ['markdown', 'defaultName']);
assert.equal((input as { markdown: string }).markdown, '# Long task export', 'Save writes the Conversation-owned export');
assert.deepEqual(exports, Array(3).fill(['Long task', 'en']));
assert.deepEqual(exports, Array(3).fill(['session-1', 'Long task', 'en']));
assert.match((input as { defaultName: string }).defaultName, /^maka-Long-task-\d{4}-\d{2}-\d{2}\.md$/);
assert.equal(toasts.length, 2);
assert.equal(toasts[0], `success:${copy.conversationSavedTitle}`);
Expand Down
43 changes: 41 additions & 2 deletions apps/desktop/src/main/__tests__/composer-submission-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ interface RegionProps {
}

interface TranscriptProps {
hasLaterHistory?: boolean;
deriveTurnPresentation(turns: readonly TurnViewModel[]): TurnPresentation;
safeResumeAction?: { pending: boolean; detail: string | undefined; onResume(): void };
activeTurn: { turnId: string; awaitingInput: boolean; compacting: boolean } | undefined;
Expand Down Expand Up @@ -149,6 +150,8 @@ function harness(options: {
sharedSessionActive?: boolean;
ownerSessionId?: string;
listMessages?: ReturnType<typeof stubConversationServices>['listMessages'];
hasNewer?: boolean;
showLatest?: (sessionId: string) => Promise<void>;
resume?: ReturnType<typeof stubConversationServices>['resume'];
cancelMessage?: ReturnType<typeof stubConversationServices>['cancelMessage'];
releaseRecoveryAttachments?: ReturnType<typeof stubConversationServices>['releaseRecoveryAttachments'];
Expand All @@ -174,20 +177,28 @@ function harness(options: {
conversationServices.observation.openTranscript = (sessionId) => {
let messages: StoredMessage[] = [];
let ready = false;
let hasNewer = options.hasNewer ?? false;
const listeners = new Set<() => void>();
published.push((next) => { messages = next; ready = true; for (const listener of listeners) listener(); });
return {
store: {
range: () => ({ sessionId, hasOlder: false, ready, generation: 'range' }),
range: () => ({ sessionId, hasOlder: false, hasNewer, ready, generation: 'range' }),
snapshot: () => {
if (!ready) throw new Error('Desktop transcript range is not initialized');
return { sessionId, messages, ready };
return { sessionId, messages, hasNewer, ready };
},
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
hasDurableMessage: (id) => messages.some((message) => message.id === id),
},
ready: async () => {}, waitForDurableMessage: async () => true,
reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {},
loadNewer: async () => {}, seek: async () => {},
showLatest: async () => {
await options.showLatest?.(sessionId);
hasNewer = false;
for (const listener of listeners) listener();
},
readComplete: async () => messages,
close: async () => {},
};
};
Expand Down Expand Up @@ -598,6 +609,34 @@ describe('ComposerSubmissionProvider', () => {
assert.equal(h.transcript.safeResumeAction?.pending, false);
});

test('Composer Resume follows the latest window before admission without waiting for its read', async () => {
const calls: unknown[] = [];
const latest = deferred<void>();
const h = harness({
ownerSessionId: 'A',
hasNewer: true,
showLatest: (sessionId) => { calls.push(['latest', sessionId]); return latest.promise; },
resume: {
queryPlan: async () => ({ disposition: 'ready' }) as Awaited<ReturnType<ReturnType<typeof stubConversationServices>['resume']['queryPlan']>>,
start: async (sessionId) => {
calls.push(['start', sessionId]);
return { disposition: 'started', runId: 'run-1', turnId: 'turn-2' };
},
subscribeChanges: () => () => undefined,
},
});
await act(async () => h.target.setActiveId('A'));
await act(async () => h.published[0]!([userTurn('turn-1', 'older window')]));
assert.equal(h.transcript.hasLaterHistory, true);

await act(async () => h.region.resumeAction!.onResume());
assert.deepEqual(calls, [['latest', 'A'], ['start', 'A']]);
assert.equal(h.transcript.hasLaterHistory, true, 'resume admission does not wait for history navigation');

await act(async () => latest.resolve());
assert.equal(h.transcript.hasLaterHistory, false, 'the resumed Turn is visible at the session tail');
});

test('runs local delivery recovery for the published Session below the owner', async () => {
const listed: string[] = [];
const reconciled: unknown[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe('Conversation owner context compaction', () => {
},
ready: async () => {}, waitForDurableMessage: async () => true,
reload: async () => {}, holdsCachedTranscript: () => false, loadEarlier: async () => {}, observationChanged: () => {},
loadNewer: async () => {}, seek: async () => {}, showLatest: async () => {}, readComplete: async () => [],
close: async () => {},
});
services.observation.subscribeEvents = (_sessionId, onEvent, phase) => {
Expand Down
Loading