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
68 changes: 16 additions & 52 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@
"src/renderer/use-app-shell-session-list.ts",
"src/renderer/use-app-shell-session-ui-reads.ts",
"src/renderer/use-app-shell-session-workspace.ts",
"src/renderer/use-onboarding-snapshot.ts",
"src/renderer/use-project-context.ts",
"src/renderer/use-shell-appearance.ts",
"src/renderer/use-shell-connections.ts",
Expand Down Expand Up @@ -614,38 +613,28 @@
},
"src/renderer/app-shell-effects.ts": {
"importDeclarations": 5,
"bridgePaths": {
"window.maka.app.info": 1,
"window.maka.appWindow.subscribeCommand": 1,
"window.maka.connections.subscribeEvents": 1,
"window.maka.runtimeHostProfiles.subscribeChanges": 1,
"window.maka.sessions.subscribeChanges": 1,
"window.maka.settings.subscribeClientChanged": 1,
"window.maka.settings.subscribeExternalChanged": 1
},
"bridgePaths": {},
"environmentCapabilities": {
"cancelAnimationFrame": 1,
"document.documentElement.setAttribute": 1,
"requestAnimationFrame": 1
},
"hookCalls": {
"useEffect": 7,
"useEffect": 6,
"useHotkeys": 1
},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./application/contracts/conversation-copy.js": 1,
"./application/contracts/session-catalog/session-change-effects.js": 1,
"./application/contracts/shell-lifecycle.js": 1,
"./browser-storage": 1,
"./theme": 1,
"./titlebar-modal-sync": 1,
"@astryxdesign/core/hooks": 1,
"react": 1
},
"importSpecifiers": 7,
"nonTriviaTokens": 1361
"nonTriviaTokens": 886
},
"src/renderer/app-shell-overlays.tsx": {
"importDeclarations": 5,
Expand Down Expand Up @@ -708,14 +697,8 @@
"nonTriviaTokens": 2157
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 43,
"bridgePaths": {
"window.maka.attachments.readBytes": 1,
"window.maka.onboarding.setMilestone": 1,
"window.maka.sessions.listTurnLandmarks": 1,
"window.maka.settings.getClient": 1,
"window.maka.settings.subscribeClientChanged": 1
},
"importDeclarations": 42,
"bridgePaths": {},
"environmentCapabilities": {
"window.requestAnimationFrame": 3
},
Expand All @@ -729,10 +712,9 @@
"useAppShellSessionUiReads": 1,
"useAppShellSessionWorkspace": 1,
"useAppShellTurnPresentation": 1,
"useEffect": 2,
"useEffect": 1,
"useLayoutEffect": 1,
"useOnboardingSnapshot": 1,
"useRef": 5,
"useRef": 4,
"useSessionNavigationReads": 1,
"useSessionSettingIntent": 1,
"useShellAppearance": 1,
Expand All @@ -742,7 +724,7 @@
"useShellMemoryPill": 1,
"useShellResume": 1,
"useStableActions": 1,
"useState": 6,
"useState": 5,
"useSystemUiLocale": 1,
"useToast": 1,
"useTurnActionRegistry": 1
Expand All @@ -759,7 +741,10 @@
"./app-shell-overlays": 1,
"./application/contracts/conversation-copy": 1,
"./application/contracts/desktop-slash-command.js": 1,
"./application/contracts/onboarding/onboarding-authority.js": 1,
"./application/contracts/shell-lifecycle.js": 1,
"./application/contracts/turn-presentation": 1,
"./application/contracts/workhub-workspace/workhub-enablement.js": 1,
"./chat-composer-region": 1,
"./chat-message-surface": 1,
"./composer-defaults": 1,
Expand Down Expand Up @@ -795,7 +780,6 @@
"./shell/frame-style": 1,
"./use-app-shell-session-ui-reads": 1,
"./use-app-shell-session-workspace": 1,
"./use-onboarding-snapshot": 1,
"./use-project-context": 1,
"./use-shell-appearance": 1,
"./use-shell-connections": 1,
Expand All @@ -814,14 +798,12 @@
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 63,
"nonTriviaTokens": 8221
"importSpecifiers": 60,
"nonTriviaTokens": 8037
},
"src/renderer/use-app-shell-session-list.ts": {
"importDeclarations": 4,
"bridgePaths": {
"window.maka.sessions.list": 1
},
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
"useExternalStoreSelector": 1,
Expand All @@ -844,7 +826,7 @@
"react": 1
},
"importSpecifiers": 5,
"nonTriviaTokens": 481
"nonTriviaTokens": 479
},
"src/renderer/use-app-shell-session-ui-reads.ts": {
"importDeclarations": 0,
Expand Down Expand Up @@ -3401,24 +3383,6 @@
"./theme": 1
}
},
"src/renderer/use-onboarding-snapshot.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
"useEffect": 1,
"useRef": 2,
"useState": 2,
"useUiLocale": 1
},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./platform/desktop/onboarding-snapshot-bridge.js": 2,
"@maka/ui": 1,
"react": 1
}
},
"src/renderer/use-project-context.ts": {
"bridgePaths": {
"window.maka.app.sessionProjectInfo": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
const emptyServices: ComposerStagingServices = {
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'unavailable' }),
readBytes: async () => ({ ok: false, reason: 'not_found' }),
};

/** Real owner with inert I/O, shared by renderer integration tests. */
Expand Down
60 changes: 58 additions & 2 deletions apps/desktop/src/main/__tests__/composer-staging-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,29 @@
*/

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { afterEach, test } from 'node:test';
import { act, createElement, createRef, Fragment, Profiler, StrictMode, useLayoutEffect } from 'react';
import { fileURLToPath } from 'node:url';
import { act, createElement, createRef, Fragment, Profiler, StrictMode, useLayoutEffect, type ComponentProps } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { ChatSurfaceLayout, LocaleProvider, type ComposerHandle } from '@maka/ui';
import type { AttachmentRef, DirectoryReference } from '@maka/core/events';
import type { SessionSummary, StoredMessage } from '@maka/core/session';
import {
createComposerStagingCommands,
StagedComposer, StagedQuoteChatView, PlanProvider, PlanServicesProvider,
type PlanServices, type ComposerStagingSubmission,
type PlanServices, type ComposerStagingServices, type ComposerStagingSubmission,
} from '../../renderer/features/conversation/index.js';
import {
createRevisionAwareOnSend, createStagedFollowUp, useComposerStaging, type RevisionSendPorts,
} from '../../renderer/features/conversation/testing.js';
import { createChatActions } from '../../renderer/features/conversation/testing.js';
import { createRevisionActions, type TurnRevisionDraft } from '../../renderer/features/conversation/testing.js';
import { createDesktopComposerStagingServices } from '../../renderer/platform/desktop/create-composer-staging-services.js';
import { createActionsDeps, createTransientState, EMPTY_SKILL_INVOCATION, windowSubmissionServices } from './app-shell-chat-actions-fixture.js';
import { ComposerStagingFixture } from './composer-staging-fixture.js';
import { renderTranscriptMarkup } from './transcript-test-dom.js';

const saved = Object.fromEntries([
'window', 'document', 'Element', 'HTMLBRElement', 'sessionStorage', 'HTMLElement', 'HTMLIFrameElement', 'Event', 'Node', 'CSS',
Expand Down Expand Up @@ -394,3 +399,54 @@ for (const context of ['attachment', 'directory'] as const) {
assert.equal(currentRevision()?.sourceSessionId, 'draft-a', 'cleanup releases the guard');
});
}

test('the transcript reads attachment bytes only through the injected attachment port', async () => {
const reads: string[] = [];
const callerReads: string[] = [];
const services: ComposerStagingServices = {
pickFiles: async () => ({ ok: false, reason: 'cancelled' }),
previewApproval: async () => ({ ok: false, reason: 'unavailable' }),
readBytes: async (sessionId, artifactId) => {
reads.push(`${sessionId}/${artifactId}`);
return { ok: true, base64: 'aW1n', mimeType: 'image/png' };
},
};
const activeSession = { id: 'session-1', name: 'Images', status: 'active', labels: [] } as unknown as SessionSummary;
const message: StoredMessage = {
type: 'user', id: 'ask', turnId: 'turn-1', ts: 1, text: 'show this',
attachments: [{
kind: 'image', name: 'preview.png', mimeType: 'image/png', bytes: 3,
ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'attachment-123' },
}],
};
// The contract omits the prop; a caller that forces one in is still overridden.
const transcript = {
activeSession, messages: [message], onNew: () => {}, scrollBehavior: 'auto',
onReadAttachmentBytes: async (sessionId: string, artifactId: string) => {
callerReads.push(`${sessionId}/${artifactId}`);
return { ok: false, reason: 'not_found' };
},
} as ComponentProps<typeof StagedQuoteChatView>;
const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'en', children:
createElement(ComposerStagingFixture, { draftKey: 'session-1', services, children:
createElement(PlanServicesProvider, { services: planServices, children:
createElement(PlanProvider, { session: undefined, children:
createElement(ChatSurfaceLayout, { scrollToBottomLabel: 'Scroll to bottom', composer: null, children:
createElement(StagedQuoteChatView, transcript),
}),
}),
}),
}),
}));
assert.deepEqual([...new Set(reads)], ['session-1/attachment-123']);
assert.deepEqual(callerReads, []);
assert.match(markup, /src="data:image\/png;base64,aW1n"/);
});

test('Desktop backs the attachment port with the bridge reader; AppShell no longer reaches it', () => {
const readBytes: ComposerStagingServices['readBytes'] = async () => ({ ok: false, reason: 'not_found' });
const bridge = { attachments: { readBytes } } as unknown as Parameters<typeof createDesktopComposerStagingServices>[0];
assert.equal(createDesktopComposerStagingServices(bridge).readBytes, readBytes);
const shell = readFileSync(fileURLToPath(new URL('../../../src/renderer/app-shell.tsx', import.meta.url)), 'utf8');
assert.deepEqual(shell.split('\n').filter((line) => /\battachments\s*\.\s*readBytes\b|\bonReadAttachmentBytes\b/.test(line)), []);
});
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ function harness(options: {
createElement(ConversationLifecycle, {
refreshSessions: async () => [], onExecutionBoundaryChanged() {},
showModelSetupToast() {}, onTurnCompleted() {},
searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }),
searchTarget: null, clearSearchTarget() {},
}),
createElement(ConversationComposerRegion<RegionProps>, {
surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe('Conversation owner context compaction', () => {
return createElement(Fragment, null, createElement(ConversationLifecycle, {
refreshSessions: async () => [], onExecutionBoundaryChanged() {},
showModelSetupToast() {}, onTurnCompleted() {},
searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: async () => ({ landmarks: [] }),
searchTarget: null, clearSearchTarget() {},
}));
}
act(() => root.render(createElement(LocaleProvider, { locale: 'en', children:
Expand Down
12 changes: 7 additions & 5 deletions apps/desktop/src/main/__tests__/conversation-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@

import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { act, createElement, Fragment, Profiler, useEffect, useState, type ComponentProps } from 'react';
import { act, createElement, Fragment, Profiler, useEffect, useState } from 'react';
import { LocaleProvider, ToastProvider, type TransientUserMessageProjection } from '@maka/ui';
import type { StoredMessage } from '@maka/core/session';
import type { UiLocale } from '@maka/core/ui-locale';
import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js';
import { createSessionCatalogController, SessionCatalogContext } from '../../renderer/application/contracts/session-catalog/session-catalog-state.js';
import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices } from '../../renderer/features/conversation/index.js';
import { ConversationProvider, ConversationServicesProvider, ConversationLifecycle, ConversationTranscriptRegion, ConversationComposerRegion, useAppShellSessionUiState, type ConversationObservationServices, type ConversationServices } from '../../renderer/features/conversation/index.js';
import { stubConversationServices, useConversationOwner } from '../../renderer/features/conversation/testing.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';
import { withComposerSubmission } from './composer-submission-fixture.js';
Expand All @@ -41,7 +41,7 @@ const message = (id: string): StoredMessage => ({ type: 'user', id, text: id, tu
function harness(options: {
locale?: UiLocale;
hasOlder?: boolean;
listTurnLandmarks?: ComponentProps<typeof ConversationLifecycle>['listTurnLandmarks'];
listTurnLandmarks?: ConversationServices['sessions']['listTurnLandmarks'];
} = {}) {
const { root } = installReactRenderer();
const catalog = createSessionCatalogController();
Expand All @@ -51,7 +51,9 @@ function harness(options: {
publish(messages: StoredMessage[]): void; error(error: unknown): void;
}> = [];
const observations: Array<{ sessionId: string; closed: boolean; phase: Parameters<ConversationObservationServices['subscribeEvents']>[2]; fail: () => void }> = [];
const services = stubConversationServices();
const services = stubConversationServices(
options.listTurnLandmarks ? { sessions: { listTurnLandmarks: options.listTurnLandmarks } } : {},
);
services.observation.openTranscript = (sessionId, error) => {
let messages: StoredMessage[] = [];
let ready = false;
Expand Down Expand Up @@ -109,7 +111,7 @@ function harness(options: {
createElement(Profiler, { id: 'conversation-lifecycle', onRender: () => { lifecycleCommits += 1; } }, createElement(ConversationLifecycle, {
refreshSessions: async () => [], onExecutionBoundaryChanged() {},
showModelSetupToast() {}, onTurnCompleted() {},
searchTarget: null, clearSearchTarget() {}, listTurnLandmarks: options.listTurnLandmarks ?? (async () => ({ landmarks: [] })),
searchTarget: null, clearSearchTarget() {},
})),
visible ? createElement(ConversationTranscriptRegion<Parameters<typeof Transcript>[0]>, { surface: Transcript }) : null,
createElement(ConversationComposerRegion<Parameters<typeof Composer>[0]>, { surface: Composer }),
Expand Down
Loading