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
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,35 @@ test('Desktop conversation adapter keeps snapshot reads and catalog access on th
services.runtimeHosts.subscribeChanges(() => undefined);
assert.deepEqual(calls, ['snapshot:source', 'host-changes']);
});


test('prompt suggestion preferences observe cross-renderer changes and unsubscribe', () => {
const events = new EventTarget();
const savedWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const savedStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
Object.defineProperty(globalThis, 'window', { configurable: true, value: events });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
} });
try {
const port = createDesktopConversationServices({} as MakaBridge).promptSuggestions!;
let observed = false;
let updates = 0;
const unsubscribe = port.subscribeEnabled!(() => { observed = port.readEnabled(); updates++; });
const notify = (key: string | null) => {
const event = new Event('storage'); Object.defineProperty(event, 'key', { value: key }); events.dispatchEvent(event);
};
port.writeEnabled(true); notify('maka.promptSuggestions.enabled');
assert.equal(observed, true);
port.writeEnabled(false); notify('maka.promptSuggestions.enabled');
assert.equal(observed, false);
notify('unrelated'); assert.equal(updates, 2);
notify(null); assert.equal(updates, 3);
unsubscribe(); notify(null); assert.equal(updates, 3);
} finally {
if (savedWindow) Object.defineProperty(globalThis, 'window', savedWindow); else Reflect.deleteProperty(globalThis, 'window');
if (savedStorage) Object.defineProperty(globalThis, 'localStorage', savedStorage); else Reflect.deleteProperty(globalThis, 'localStorage');
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -2306,6 +2306,7 @@ function executionClient(overrides: Partial<ExecutionClient>): ExecutionClient {
compactContext: unavailable,
copySession: unavailable,
getSession: unavailable,
generatePromptSuggestion: unavailable,
ingestAttachment: unavailable,
interruptTurn: unavailable,
listSessionTurns: unavailable,
Expand Down Expand Up @@ -2571,3 +2572,18 @@ test('late transcript acknowledgement after renderer teardown is a no-op', async
});
await registry.close();
});


test('prompt suggestion IPC forwards only a validated Session and does not reconnect/retry paid effects', async () => {
const ipc = ipcHarness();
const calls: string[] = [];
registerExecutionIpc({ client: executionClient({ generatePromptSuggestion: async (id) => {
calls.push(id); return { kind: 'generated', turnId: 'turn-1', terminalEventId: 'terminal-1', text: '补上测试' };
} }) }, ipc);
assert.deepEqual(await ipc.invoke('sessions:generatePromptSuggestion', 'session-1'), {
kind: 'generated', turnId: 'turn-1', terminalEventId: 'terminal-1', text: '补上测试',
});
await assert.rejects(ipc.invoke('sessions:generatePromptSuggestion', ''), /Invalid/);
assert.deepEqual(calls, ['session-1']);
assert.equal(ipc.reconnectableChannels.has('sessions:generatePromptSuggestion'), false);
});
4 changes: 4 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,10 @@ export class DesktopRuntimeHostClient {
);
}

generatePromptSuggestion(sessionId: string) {
return this.request('session.prompt-suggestion.generate', { sessionId }, 7000);
}

request<K extends DirectRequestOperationKey>(
operation: K,
input: OperationInput<K>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ type RuntimeHostSessionExecutionClient = Pick<
| "compactContext"
| "copySession"
| "getSession"
| "generatePromptSuggestion"
| "ingestAttachment"
| "interruptTurn"
| "openSession"
Expand Down Expand Up @@ -390,6 +391,10 @@ export function registerRuntimeHostSessionExecutionIpc(
},
);

ipcMain.handle('sessions:generatePromptSuggestion', async (_event, sessionId: unknown) =>
deps.client.generatePromptSuggestion(requiredId(sessionId, 'Session')),
);

handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) =>
deps.client.listSessionTurns(requiredId(sessionId, 'Session')),
);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,8 @@ export interface MakaBridge {
}) => void,
): () => void;
listTurns(sessionId: string): Promise<TurnRecord[]>;
/** Request a bounded next-prompt prediction for this Session. */
generatePromptSuggestion(sessionId: string): Promise<import('@maka/runtime-host/protocol').PromptSuggestionResult>;
/** Read a bounded, redacted tail from another same-Host Session. */
readSnapshot(sessionId: string, options?: { maxChars?: number }): Promise<SessionSnapshot>;
/** Sampled prompt-rail landmarks, or where the one Turn `turnId` sits. */
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2438,6 +2438,10 @@ const makaBridge = {
) as TurnRecord[];
return turns.map((turn) => projectDesktopTurnRecord(session.scope, turn));
},
async generatePromptSuggestion(sessionId: string): Promise<import('@maka/runtime-host/protocol').PromptSuggestionResult> {
const session = await runtimeHostSessionRef(sessionId);
return invokeWhenReady('sessions:generatePromptSuggestion', session.scope, session.sessionId);
},
async readSnapshot(
sessionId: string,
options?: { maxChars?: number },
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/features/conversation/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface ConversationServices extends Pick<
DesktopSessionLocalBridge,
'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges'
> {
readonly promptSuggestions?: {
generate(sessionId: string): Promise<string | undefined>;
readEnabled(): boolean;
subscribeEnabled?(handler: () => void): () => void;
writeEnabled(enabled: boolean): void;
};
readonly sessions: {
getExecutorState?(sessionId: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
setExecutorModelConfiguration?(sessionId: string, config: import('@maka/core/executor-catalog').ExecutorConfiguration): Promise<import('../../../shared/desktop-session-projection.js').DesktopSessionUpdateResult<DesktopSessionSummary>>;
Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/src/renderer/features/conversation/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,27 @@
* under the License.
*/

import { createElement, useMemo, useState, useEffect, type ReactNode } from 'react';
import { ComposerPromptSuggestionProvider } from '@maka/ui';
import { createServicesContext } from '../../application/contracts/feature-services.js';
import type { ConversationServices } from './ports.js';

const context = createServicesContext<ConversationServices>('ConversationServicesProvider');
export const ConversationServicesProvider = context.Provider;
export function ConversationServicesProvider(props: { services: ConversationServices; children: ReactNode }) {
const port = props.services.promptSuggestions;
const [enabled, setEnabled] = useState(() => port?.readEnabled() ?? false);
useEffect(() => {
const refresh = () => setEnabled(port?.readEnabled() ?? false);
const unsubscribe = port?.subscribeEnabled?.(refresh);
refresh();
return unsubscribe;
}, [port]);
const service = useMemo(() => port ? {
enabled,
setEnabled: (next: boolean) => { port.writeEnabled(next); setEnabled(next); },
generate: (sessionId: string) => port.generate(sessionId),
} : undefined, [port, enabled]);
return createElement(context.Provider, { services: props.services },
createElement(ComposerPromptSuggestionProvider, { service, children: props.children }));
}
export const useConversationServices = context.useServices;
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ export function WorkHubRoot() {
onReorderQueuedEntries={controller.reorderQueuedEntries}
placeholder={progress ? t.progressInput : t.welcome}
ref={composer}
hidden={Boolean(controller.activeQuestion || controller.activeForm)}
sessionId={controller.sessionId}
streaming={busy}
sendBlocked={!controller.sessionId || controller.sending || !session?.model}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/locales/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ const ZH_CALL_KIND: CallKindCopy = {
goal_evaluation: '目标评估',
session_title: '生成任务标题',
session_recap: '任务回顾',
prompt_suggestion: '下一步输入建议',
daily_review: '每日回顾',
workhub_intent: 'WorkHub 意图识别',
workhub_recall: 'WorkHub 任务召回',
Expand All @@ -284,6 +285,7 @@ const EN_CALL_KIND: CallKindCopy = {
goal_evaluation: 'Goal evaluation',
session_title: 'Task title',
session_recap: 'Task recap',
prompt_suggestion: 'Prompt suggestion',
daily_review: 'Daily review',
workhub_intent: 'WorkHub intent',
workhub_recall: 'WorkHub recall',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
import type { MakaBridge } from '../../../preload/bridge-contract.js';
import type { ConversationServices } from '../../features/conversation/index.js';


const PROMPT_SUGGESTIONS_KEY = 'maka.promptSuggestions.enabled';

export function createDesktopConversationServices(
bridge: Pick<
MakaBridge,
Expand All @@ -28,6 +31,29 @@ export function createDesktopConversationServices(
): ConversationServices {
return {
...bridge.sessionLocal,
promptSuggestions: {
generate: async (sessionId) => {
const result = await bridge.sessions.generatePromptSuggestion(sessionId);
return result.kind === 'generated' ? result.text : undefined;
},
// Browser storage belongs to this adapter; legacy renderer helpers cannot
// be imported across the platform boundary.
readEnabled: () => {
try { return localStorage.getItem(PROMPT_SUGGESTIONS_KEY) === 'true'; }
catch { return false; }
},
writeEnabled: (enabled) => {
try { localStorage.setItem(PROMPT_SUGGESTIONS_KEY, String(enabled)); }
catch { /* Storage can be unavailable in restricted renderers. */ }
},
subscribeEnabled: (handler) => {
const onStorage = (event: StorageEvent) => {
if (event.key === null || event.key === PROMPT_SUGGESTIONS_KEY) handler();
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
},
},
sessions: bridge.sessions,
runtimeHosts: {
subscribeChanges: (handler) => bridge.runtimeHostProfiles.subscribeChanges(handler),
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/renderer/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -679,3 +679,26 @@
font-size: 11px;
color: var(--muted-foreground);
}

/* Share a grid cell so a wrapping suggestion reserves exactly the same height
as the accepted draft, without measuring layout or mutating the editor. */
.maka-composer-input-suggestion-wrap { display: grid; min-width: 0; }
.maka-composer-input-suggestion-wrap > .maka-composer-editor,
.maka-composer-next-prompt { grid-area: 1 / 1; min-width: 0; }
.maka-composer-next-prompt {
position: relative; z-index: 1; align-self: start; overflow: hidden;
display: block; width: 100%; height: auto; margin: 0; border: 0;
/* Match ChatComposerInput's editable and placeholder typography tokens. */
padding: var(--spacing-1);
font-family: var(--font-family-body); font-size: var(--text-body-size);
font-weight: inherit; font-style: inherit; letter-spacing: inherit; line-height: 22px;
white-space: pre-wrap; word-break: break-word; text-align: start;
background: transparent; color: var(--color-text-secondary); pointer-events: none;
}
@media (pointer: coarse) {
.maka-composer-next-prompt { font-size: max(1rem, var(--text-body-size)); }
}
.maka-composer-next-prompt-key {
color: var(--color-text-secondary); font: inherit; font-size: var(--text-xs);
}
.maka-composer-next-prompt:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/styles/workhub.css
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
.workHubLive[data-conversation-expanded='false'] .maka-chat-layout > div:last-child > div:has(> div > .workHubComposerSurface) { padding: 0; }
.workHubLive[data-conversation-expanded='false'] .maka-composer { --shadow-low: none; --shadow-med: none; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor { padding-right: 24px; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-next-prompt { width: calc(100% - 24px); padding-block: 6px; overflow-y: auto; scrollbar-gutter: stable; scrollbar-width: thin; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor [contenteditable='true'] { padding-block: 6px; overscroll-behavior-y: contain; scrollbar-gutter: stable; scrollbar-width: thin; scroll-padding-block: 6px; }
.workHubLive[data-conversation-expanded='false'] .workHubComposerSurface { position: relative; -webkit-app-region: drag; }
.workHubLive[data-conversation-expanded='false'] .workHubComposerSurface :is(button, input, textarea, [contenteditable='true'], [role='listbox'], [role='option'], dialog, a) { -webkit-app-region: no-drag; }
Expand Down
Loading