Skip to content
Open
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
19 changes: 12 additions & 7 deletions apps/desktop/e2e/code-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ test('a one-line Markdown code block exposes native and selection horizontal scr
longLine,
'```',
].join('\n'));
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeEnabled();
await composer.press('Enter');

const codeBlocks = page.locator('.maka-markdown-code[data-maka-code-layout="single-line"]');
Expand Down Expand Up @@ -101,18 +102,22 @@ test('a one-line Markdown code block exposes native and selection horizontal scr
window.getSelection()?.removeAllRanges();
});
const code = viewport.locator('code');
const codeBox = await code.boundingBox();
if (!codeBox) throw new Error('code line has no visible bounds');
const textY = codeBox.y + Math.min(codeBox.height / 2, 18);
await page.mouse.move(codeBox.x + 24, textY);
const lineBox = await code.locator('[data-line="1"]').boundingBox();
if (!lineBox) throw new Error('code line has no visible bounds');
const textY = lineBox.y + lineBox.height / 2;
const textStartX = lineBox.x + 4;
await page.mouse.move(textStartX, textY);
await page.mouse.down();
// Establish the selection on text before leaving the viewport. Starting on
// padding can still auto-scroll the container without anchoring a range.
await page.mouse.move(textStartX + 120, textY, { steps: 5 });
await expect.poll(
() => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0),
).toBeGreaterThan(10);
await page.mouse.move(metrics.rect.x + metrics.rect.width + 50, textY, { steps: 20 });
await expect.poll(
() => viewport.evaluate((element) => (element as HTMLElement).scrollLeft),
).toBeGreaterThan(0);
await expect.poll(
() => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0),
).toBeGreaterThan(10);
const afterSelectionDrag = await viewport.evaluate((element) => ({
scrollLeft: (element as HTMLElement).scrollLeft,
selection: window.getSelection()?.toString() ?? '',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e/transcript-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ test('history asked for at the very top of the scroller still lands above the re
const root = document.querySelector(selector);
if (!root) throw new Error('the chat scroll container is missing');
root.scrollTop = 0;
root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }));
}, SCROLLER);

await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore);
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 @@ -854,7 +854,7 @@
"useNewTaskChoice": 1,
"useOnboardingSnapshot": 1,
"usePlanModeState": 1,
"useRef": 25,
"useRef": 24,
"useSessionCollaborationDialog": 1,
"useSessionEventHealthPolling": 1,
"useSessionNavigationReads": 1,
Expand Down Expand Up @@ -985,7 +985,7 @@
"react": 1
},
"importSpecifiers": 186,
"nonTriviaTokens": 15725
"nonTriviaTokens": 15723
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 3,
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/src/main/__tests__/bootstrap-selection-lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ import {
writeNewTaskReloadDraft,
} from '../../renderer/new-task-reload-intent.js';

type Summary = { id: string; lastMessageAt?: number };
type Summary = { id: string; lastMessageAt?: number; isArchived: boolean };

function session(id: string, lastMessageAt?: number): Summary {
return { id, lastMessageAt };
function session(id: string, lastMessageAt?: number, isArchived = false): Summary {
return { id, lastMessageAt, isArchived };
}

function harness(activeId?: string) {
Expand Down Expand Up @@ -88,6 +88,18 @@ describe('bootstrap selection lease', () => {
assert.equal(state.activeId(), undefined);
});

for (const { name, initialActiveId, sessions, expected } of [
{ name: 'the freshest session is archived', initialActiveId: undefined, sessions: [session('archived', 2, true), session('active', 1)], expected: 'active' },
{ name: 'the bootstrap-owned selection is archived', initialActiveId: 'archived', sessions: [session('archived', 2, true), session('active', 1)], expected: 'active' },
{ name: 'every session is archived', initialActiveId: 'archived', sessions: [session('archived', 1, true)], expected: undefined },
] as const) {
it(`skips archived sessions when ${name}`, () => {
const state = harness(initialActiveId);
assert.equal(state.lease.reconcile(sessions), true);
assert.equal(state.activeId(), expected);
});
}

it('does not reconcile after release', () => {
const state = harness();
state.lease.release();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function ports(
return {
activeIdRef: { current: activeSessionId },
sessionsRef: { current: sessions },
pendingSessionRowActionsRef: { current: new Set<string>() },
acquireAutomaticQueryBlock: () => ({ release: () => undefined }),
activateSession: (sessionId) => calls.push(`activate:${sessionId ?? 'none'}`),
clearActiveMessages: () => calls.push('clear-messages'),
clearSessionRendererState: (sessionId) => calls.push(`clear:${sessionId}`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,22 @@ describe('revision-family session row actions', () => {
});
const branch = summary('branch', { parentSessionId: 'root', branchOfTurnId: 'turn-1' });
const activeIdRef = { current: 'root' as string | undefined };
const service = createService(calls);
const actions = createSessionNavigationRowActions({
uiLocale: 'en',
activeIdRef,
acquireAutomaticQueryBlock: (ids) => {
calls.push(`acquire:${ids.join(',')}`);
return { release: () => calls.push('release') };
},
clearActiveMessages: () => undefined,
clearSessionRendererState: (id) => { cleared.push(id); },
pendingSessionRowActionsRef: { current: new Set<string>() },
refreshSessions: async () => [root, version, branch],
service: createService(calls),
refreshSessions: async () => {
calls.push('refresh');
return [root, version, branch];
},
service,
sessionsRef: { current: [root, version, branch] },
setActiveId: (id) => { selections.push(id); activeIdRef.current = id; },
toastApi: {
Expand All @@ -115,17 +123,84 @@ describe('revision-family session row actions', () => {

assert.deepEqual(calls, [
'flag:version:true:true',
'refresh',
'rename:branch:Independent branch:true',
'refresh',
'acquire:root,version',
'archive:version:true',
'refresh',
'release',
// The delete asks the Host how many subtasks it would archive before the
// confirm, then removes.
'preview:root',
'acquire:root,version',
// `root` is not archived, so the delete states no archived premise —
// requiring one would refuse every delete from the rail.
'remove:root:true:false',
'refresh',
'release',
]);
assert.deepEqual(selections, [undefined, undefined]);
assert.deepEqual(cleared, ['root', 'version', 'root', 'version']);

service.archive = async () => { throw new Error('archive failed'); };
await actions.archiveSession('root');
assert.deepEqual(calls.slice(-2), ['acquire:root,version', 'release']);
});

it('holds one query block through a bulk archive refresh', async () => {
const calls: string[] = [];
const root = summary('root');
const version = summary('version', {
revisionRootSessionId: 'root',
revisionParentSessionId: 'root',
});
const other = summary('other');
let rejectRefresh = false;
const actions = createSessionNavigationRowActions({
uiLocale: 'en',
activeIdRef: { current: undefined },
acquireAutomaticQueryBlock: (ids) => {
calls.push(`acquire:${ids.join(',')}`);
return { release: () => calls.push('release') };
},
clearActiveMessages: () => undefined,
clearSessionRendererState: () => undefined,
pendingSessionRowActionsRef: { current: new Set<string>() },
refreshSessions: async () => {
calls.push('refresh');
if (rejectRefresh) throw new Error('refresh failed');
return [];
},
service: createService(calls),
sessionsRef: { current: [root, version, other] },
setActiveId: () => undefined,
toastApi: {
success: () => undefined,
error: () => undefined,
confirm: async () => true,
},
});

await actions.archiveSelected(['version', 'other']);

assert.deepEqual(calls, [
'acquire:root,version,other',
'archive:version:true',
'archive:other:true',
'refresh',
'release',
]);

calls.length = 0;
rejectRefresh = true;
await assert.rejects(actions.archiveSelected(['other']), /refresh failed/);
assert.deepEqual(calls, [
'acquire:other',
'archive:other:true',
'refresh',
'release',
]);
});
});

Expand All @@ -138,9 +213,11 @@ function deleteHarness(
const calls: string[] = [];
const confirms: Array<{ title: string; description: string }> = [];
const successes: Array<{ title: string; description?: string }> = [];
let leaseReleased = false;
const actions = createSessionNavigationRowActions({
uiLocale: 'en',
activeIdRef: { current: undefined },
acquireAutomaticQueryBlock: () => ({ release: () => { leaseReleased = true; } }),
clearActiveMessages: () => undefined,
clearSessionRendererState: () => undefined,
pendingSessionRowActionsRef: { current: new Set<string>() },
Expand All @@ -154,7 +231,7 @@ function deleteHarness(
confirm: async (options) => { confirms.push({ title: options.title, description: options.description }); return true; },
},
});
return { actions, calls, confirms, successes };
return { actions, calls, confirms, successes, wasLeaseReleased: () => leaseReleased };
}

describe('delete confirm warns off the Host preview, toast reports the Host count', () => {
Expand Down Expand Up @@ -219,7 +296,7 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun
});

it('stays silent on the toast when a concurrent restore calls the delete off', async () => {
const { actions, confirms, successes } = deleteHarness(
const { actions, confirms, successes, wasLeaseReleased } = deleteHarness(
[summary('parent', { name: 'hi' })],
'restored',
0,
Expand All @@ -232,5 +309,6 @@ describe('delete confirm warns off the Host preview, toast reports the Host coun
assert.match(confirms[0].description, /kept and moved to Archived/);
// But nothing was deleted, so nothing moved to the archive.
assert.deepEqual(successes, [{ title: 'hi was restored, so it was kept', description: undefined }]);
assert.equal(wasLeaseReleased(), true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function createActions(input: {
return createSessionNavigationRowActions({
uiLocale: 'en',
activeIdRef: input.activeIdRef,
acquireAutomaticQueryBlock: () => ({ release: () => undefined }),
clearActiveMessages: () => undefined,
clearSessionRendererState: (id) => {
input.harness.cleared.push(id);
Expand Down
131 changes: 131 additions & 0 deletions apps/desktop/src/main/__tests__/session-query-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* 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 { deferred } from '@maka/core/test-only/async-primitives';
import type { PlanSessionState } from '@maka/core/plan';
import type { SessionSummary } from '@maka/core/session';
import { LocaleProvider, ToastProvider } from '@maka/ui';
import { act, createElement } from 'react';
import type { DesktopSessionSummary } from '../../preload/bridge-contract.js';
import {
ComposerMentionsProvider,
useComposerMentionsContext,
} from '../../renderer/composer-mentions.js';
import { usePlanModeState } from '../../renderer/plan-mode-panel.js';
import { createSessionCatalogController } from '../../renderer/session-catalog-state.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';

afterEach(cleanupFakeDom);

test('query blocking pauses, resumes, and fences automatic Skills and Plan reads', async () => {
const { root } = installReactRenderer();
const session = { id: 'session', isArchived: false } as SessionSummary;
const catalog = createSessionCatalogController();
catalog.commitSessions([{ ...session, isArchived: true } as DesktopSessionSummary]);
const firstSkillQuery = deferred<never[]>();
const firstPlanQuery = deferred<PlanSessionState>();
const stalePlanState = {
schemaVersion: 1,
sessionId: session.id,
storeVersion: 1,
proposals: [],
executions: [],
} satisfies PlanSessionState;
const freshPlanState = { ...stalePlanState, storeVersion: 2 } satisfies PlanSessionState;
let skillQueries = 0;
let planQueries = 0;
let planState: PlanSessionState | undefined;
let skillsUnavailable = false;

(globalThis.window as unknown as { maka: unknown }).maka = {
skills: {
listInvocable: async () => {
skillQueries += 1;
return skillQueries === 1 ? firstSkillQuery.promise : [];
},
},
sessions: {
getPlanState: async () => {
planQueries += 1;
return planQueries === 1 ? firstPlanQuery.promise : freshPlanState;
},
subscribeChanges: () => () => undefined,
subscribeEvents: () => () => undefined,
subscribePlanChanges: () => () => undefined,
},
mcp: { subscribeChanges: () => () => undefined },
};

function QueryProbe() {
const plan = usePlanModeState(session, catalog);
planState = plan.state;
skillsUnavailable = useComposerMentionsContext()?.mentionSkillsUnavailable ?? false;
return null;
}

await act(async () => {
root.render(createElement(LocaleProvider, {
locale: 'en',
children: createElement(ToastProvider, {
children: createElement(ComposerMentionsProvider, {
skillCatalogRevision: 0,
sessionId: session.id,
automaticQueryGate: catalog,
children: createElement(QueryProbe),
}),
}),
}));
await Promise.resolve();
});

assert.deepEqual([skillQueries, planQueries], [0, 0]);

await act(async () => {
catalog.commitSessions([session as DesktopSessionSummary]);
await Promise.resolve();
});

let lease!: ReturnType<typeof catalog.acquireAutomaticQueryBlock>;
let overlappingLease!: ReturnType<typeof catalog.acquireAutomaticQueryBlock>;
await act(async () => {
lease = catalog.acquireAutomaticQueryBlock([session.id]);
overlappingLease = catalog.acquireAutomaticQueryBlock([session.id]);
firstSkillQuery.reject(new Error('session archived'));
firstPlanQuery.resolve(stalePlanState);
await Promise.resolve();
});

assert.equal(planState, undefined);
assert.equal(skillsUnavailable, false);

await act(async () => {
lease.release();
await Promise.resolve();
});
assert.deepEqual([skillQueries, planQueries], [1, 1]);

await act(async () => {
overlappingLease.release();
await Promise.resolve();
});
assert.deepEqual([skillQueries, planQueries], [2, 2]);
assert.deepEqual(planState, freshPlanState);
});
Loading