Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ declare global {
regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise<void>;
branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise<SessionSummary>;
respondToPermission(sessionId: string, response: PermissionResponse): Promise<void>;
injectGuidance(sessionId: string, text: string): Promise<boolean>;
saveConversationToFile(input: {
markdown: string;
defaultName: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ async function importAppShellEffects(): Promise<AppShellEffectsModule> {
entryPoints: [resolve(REPO_ROOT, 'apps/desktop/src/renderer/app-shell-effects.ts')],
outfile,
bundle: true,
external: ['react'],
packages: 'external',
platform: 'node',
format: 'esm',
target: 'node20',
Expand Down

Large diffs are not rendered by default.

30 changes: 29 additions & 1 deletion apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ const dailyReviewArchiveStore = createDailyReviewArchiveStore(workspaceRoot);
const artifactStore = createArtifactStore(workspaceRoot);
const attachmentApprovals = createAttachmentApprovalRegistry();
const credentialStore = createFileCredentialStore(workspaceRoot);
// Per-session send serialiser. The runtime's AiSdkBackend holds single-
// turn mutable state, so two turns can't run concurrently on one
// session. Each `sessions:send` resolves its turnId immediately (so the
// renderer can react), but the actual `runtime.sendMessage` + stream
// pump is chained behind the previous turn's stream completing — this
// is what lets a user commit a follow-up (“立即”) while a turn is still
// running and have it answered after the current answer finishes,
// without interrupting it and without racing the backend state.
const sessionSendChain = new Map<string, Promise<unknown>>();
// PR-OAUTH-SUBSCRIPTION-0: Claude subscription OAuth service.
// Lives in main process only; renderer accesses via IPC. Tokens
// never cross the IPC boundary (xuan G-X3). Cloak path is dynamic-
Expand Down Expand Up @@ -1337,6 +1346,9 @@ function registerIpc(): void {
ipcMain.handle('sessions:respondToPermission', (_event, sessionId: string, response) =>
runtime.respondToPermission(sessionId, normalizePermissionResponse(response)),
);
ipcMain.handle('sessions:injectGuidance', (_event, sessionId: string, text: string) =>
runtime.injectGuidance(sessionId, String(text ?? '')),
);
ipcMain.handle('sessions:send', async (event, sessionId: string, command: unknown) => {
const sendCommand = normalizeSessionSendCommand(command);
if (!sendCommand) return;
Expand All @@ -1356,7 +1368,18 @@ function registerIpc(): void {
text: sendCommand.text,
...(attachments.length > 0 ? { attachments } : {}),
});
void streamEvents(sessionId, iterator, turnId);
// Chain behind any in-flight turn on this session so we never run two
// turns concurrently (see sessionSendChain above). The previous turn's
// stream pump resolves once its generator closes — which is after the
// runtime has finalised that run — so this turn's `sendMessage` only
// begins once the prior turn is fully done.
const prev = sessionSendChain.get(sessionId) ?? Promise.resolve();
const run = prev.catch(() => {}).then(() => streamEvents(sessionId, iterator, turnId));
sessionSendChain.set(sessionId, run);
// Don't let a stuck chain block future sends forever if the pump rejects.
run.catch(() => {}).finally(() => {
if (sessionSendChain.get(sessionId) === run) sessionSendChain.delete(sessionId);
});
return { turnId, attachments };
});
ipcMain.handle(
Expand Down Expand Up @@ -1760,6 +1783,11 @@ async function streamEvents(
turnError = turnError ?? turnFailureMessageFromSessionEvent(event);
safeSendToRenderer(`sessions:event:${sessionId}`, event);
openGateway.publishSessionEvent(sessionId, event);
if (event.type === 'guidance') {
// Guidance is persisted as a new user message mid-turn; bump the
// session list (lastMessageAt) and let the renderer refresh.
emitSessionsChanged('message-appended', sessionId);
}
if (isStatusChangingSessionEvent(event)) {
emitSessionsChanged('status-change', sessionId);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ contextBridge.exposeInMainWorld('maka', {
respondToPermission(sessionId: string, response: PermissionResponse): Promise<void> {
return ipcRenderer.invoke('sessions:respondToPermission', sessionId, response);
},
injectGuidance(sessionId: string, text: string): Promise<boolean> {
return ipcRenderer.invoke('sessions:injectGuidance', sessionId, text);
},
/**
* PR-CMD-PALETTE-SAVE-CONVERSATION-FILE-0: write the renderer-formatted
* conversation markdown to a user-chosen file. Renderer owns the
Expand Down
38 changes: 36 additions & 2 deletions apps/desktop/src/renderer/app-shell-chat-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ function toIngestItems(pending: readonly PendingAttachment[]): RendererIngestInp
);
}

/**
* When a send is queued behind a running turn by the main-process
* serialiser, its user message isn't persisted until that turn finishes.
* We show an optimistic copy meanwhile; this helper keeps such pending
* optimistic messages across a `readMessages`-driven `setMessages(next)`
* (which would otherwise replace the list and drop them) until the real
* message for the same turn lands. Dedup is by `turnId` on user messages.
*/
export function preservePendingOptimistic(
current: readonly StoredMessage[],
next: readonly StoredMessage[],
): StoredMessage[] {
const pending = current.filter(
(message) =>
message.type === 'user'
&& message.id.startsWith('optimistic-user-')
&& !next.some((realized) => realized.type === 'user' && realized.turnId === message.turnId),
);
return pending.length ? [...next, ...pending] : [...next];
}

export function createAppShellChatActions(deps: {
activeIdRef: RefBox<string | undefined>;
addPendingSessionAction: (
Expand All @@ -115,6 +136,7 @@ export function createAppShellChatActions(deps: {
markSessionRunningOptimistic: (sessionId: string) => (() => void) | undefined;
messageRetryPendingRef: RefBox<Set<string>>;
refreshSessions: () => Promise<SessionSummary[]>;
getActiveSessionStatus: () => string | undefined;
setActiveId: (sessionId: string | undefined) => void;
setMessageLoadErrorBySession: MessageLoadErrorUpdater;
setMessageRetryPendingBySession: BooleanRecordUpdater;
Expand All @@ -139,6 +161,7 @@ export function createAppShellChatActions(deps: {
markSessionRunningOptimistic,
messageRetryPendingRef,
refreshSessions,
getActiveSessionStatus,
setActiveId,
setMessageLoadErrorBySession,
setMessageRetryPendingBySession,
Expand Down Expand Up @@ -262,7 +285,18 @@ export function createAppShellChatActions(deps: {
const attachmentItems = pending && pending.length > 0 ? toIngestItems(pending) : undefined;
const sendResult = await window.maka.sessions.send(sessionId, { type: 'send', turnId, text, ...(attachmentItems ? { attachmentItems } : {}) });
showOptimisticUserMessage(sessionId, turnId, text, sendResult.attachments);
await refreshMessagesUntilTurn(sessionId, turnId);
// If a turn is already running on this session, the main-process
// serialiser queues this send behind it (the runtime can't run two
// turns concurrently on one session), so the real user message won't
// land until the prior turn finishes. Skip the bounded poll (it would
// just time out); the optimistic copy stays visible thanks to
// preservePendingOptimistic in the read paths, and is replaced by
// the real message when this turn actually starts.
if (getActiveSessionStatus() !== 'running') {
await refreshMessagesUntilTurn(sessionId, turnId);
} else {
await refreshSessions();
}
return true;
} catch (error) {
if (optimisticSessionId && optimisticTurnId) {
Expand Down Expand Up @@ -311,7 +345,7 @@ export function createAppShellChatActions(deps: {
const next = result.messages;
if (activeIdRef.current === sessionId) {
markSessionReadLocally(sessionId, next);
setMessages(next);
setMessages((current) => preservePendingOptimistic(current, next));
setMessageLoadErrorBySession((current) => {
if (!current[sessionId]) return current;
const updated = { ...current };
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/app-shell-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import { generalizedErrorMessageChinese } from '@maka/core';
import type { LiveTurnProjection, NavSelection, PermissionQueues } from '@maka/ui';
import { messageReadErrorMessage } from './app-shell-copy';
import { preservePendingOptimistic } from './app-shell-chat-actions';
import { applyTheme, applyThemePalette } from './theme';
import { safeLocalStorageSet } from './browser-storage';
import {
Expand Down Expand Up @@ -181,7 +182,7 @@ export function useAppShellBootstrapSubscriptions(options: {
refreshSessions: () => Promise<SessionSummary[]>;
rendererMountedRef: RefBox<boolean>;
setActiveId: (sessionId: string | undefined) => void;
setMessages: (messages: StoredMessage[]) => void;
setMessages: (messages: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) => void;
setNavSelection: (selection: NavSelection) => void;
setSessionEventHealthBySession: SessionEventHealthUpdater;
toastApi: ToastApi;
Expand Down Expand Up @@ -319,7 +320,7 @@ export function useActiveSessionEvents(options: {
updater: (current: Record<string, string>) => Record<string, string>,
) => void;
setMessageLoadPending: (pending: boolean) => void;
setMessages: (messages: StoredMessage[]) => void;
setMessages: (messages: StoredMessage[] | ((current: StoredMessage[]) => StoredMessage[])) => void;
setSessionEventHealthBySession: SessionEventHealthUpdater;
toastApi: Pick<ToastApi, 'error'>;
}) {
Expand All @@ -335,7 +336,7 @@ export function useActiveSessionEvents(options: {
// the optimistic copy shown to the user. length is enough only because
// sends are serialized (one optimistic per session); parallel sends
// would need a merge instead.
if (next.length > 0) options.setMessages(next);
if (next.length > 0) options.setMessages((current) => preservePendingOptimistic(current, next));
options.setMessageLoadPending(false);
}
});
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/app-shell-session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export function createAppShellSessionEventHandlers(options: {
void refreshMessages(sessionId, terminalRefreshOptions(before));
break;
}
case 'guidance':
// Mid-turn guidance was persisted as a user runtime event by the
// backend; re-read messages so it appears in the conversation.
void refreshMessages(sessionId);
break;
default:
break;
}
Expand Down
69 changes: 37 additions & 32 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export function AppShell({
const composerRef = useRef<ComposerHandle>(null);
const activeIdRef = useRef<string | undefined>(undefined);
const rendererMountedRef = useRef(true);
const activeSessionStatusRef = useRef<string | undefined>(undefined);
const projectPickerPendingRef = useRef(false);
const projectPickerRequestRef = useRef(0);
// Active autonomous goal for the current session drives the header
Expand Down Expand Up @@ -358,31 +359,32 @@ export function AppShell({
});
const activePermission = activePermissionFor(permissionBySession, activeId);
const activeSession = sessions.find((session) => session.id === activeId);
// #646: the two turn-wait cues. `turnPhase` (armed at send, no lag; promoted to
// 'streamed' on the first content event) separates the connect-to-first-token
// wait from the later step-to-step lulls; the `status === 'running'` gate
// self-heals a backgrounded session whose terminal event was missed while
// inactive (its arm can't clear without the event). The rising-edge delays
// (useDelayedFlag) suppress a flash on fast turns / quick step hops.
const activeTurnPhase = activeLiveTurn?.terminal ? undefined : activeLiveTurn?.phase;
const turnInFlight = activeTurnPhase !== undefined;
const modelWaitKind = deriveModelWait({
turnPhase: activeTurnPhase,
streamingText: activeStreaming,
thinkingText: activeThinking,
hasInFlightTools: hasInFlightLiveTools,
});
const sessionAwaitingModel = activeSession?.status === 'running';
// The prominent "正在处理…" first-token indicator (turn head only).
const showProcessingIndicator = useDelayedFlag(
sessionAwaitingModel && modelWaitKind === 'processing',
MODEL_PROCESSING_DELAY_MS,
);
// The calm "继续中…" hint for a mid-turn step-to-step lull (after content).
const showContinuingIndicator = useDelayedFlag(
sessionAwaitingModel && modelWaitKind === 'continuing',
MODEL_CONTINUING_DELAY_MS,
);
// #646: the two turn-wait cues. `turnPhase` (armed at send, no lag; promoted to
// 'streamed' on the first content event) separates the connect-to-first-token
// wait from the later step-to-step lulls; the `status === 'running'` gate
// self-heals a backgrounded session whose terminal event was missed while
// inactive (its arm can't clear without the event). The rising-edge delays
// (useDelayedFlag) suppress a flash on fast turns / quick step hops.
const activeTurnPhase = activeLiveTurn?.terminal ? undefined : activeLiveTurn?.phase;
const turnInFlight = activeTurnPhase !== undefined;
const modelWaitKind = deriveModelWait({
turnPhase: activeTurnPhase,
streamingText: activeStreaming,
thinkingText: activeThinking,
hasInFlightTools: hasInFlightLiveTools,
});
const sessionAwaitingModel = activeSession?.status === 'running';
// The prominent "正在处理…" first-token indicator (turn head only).
const showProcessingIndicator = useDelayedFlag(
sessionAwaitingModel && modelWaitKind === 'processing',
MODEL_PROCESSING_DELAY_MS,
);
// The calm "继续中…" hint for a mid-turn step-to-step lull (after content).
const showContinuingIndicator = useDelayedFlag(
sessionAwaitingModel && modelWaitKind === 'continuing',
MODEL_CONTINUING_DELAY_MS,
);
activeSessionStatusRef.current = activeSession?.status;
const activeConnection = activeSession
? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug)
: undefined;
Expand Down Expand Up @@ -971,6 +973,7 @@ export function AppShell({
toastApi,
upsertSessionSummary,
validPendingNewChatModel,
getActiveSessionStatus: () => activeSessionStatusRef.current,
pendingNewChatThinkingLevel: newChatThinkingLevel ?? null,
});

Expand Down Expand Up @@ -1028,19 +1031,16 @@ export function AppShell({

const { handleEvent, reconcilePersistedMessages, settleAssistantStreaming } = createAppShellSessionEventHandlers({
activeIdRef,
liveTurnBySessionRef,
liveTurnBySessionRef,
notifyRunEnded: (payload) => {
window.maka.notifications.runEnded(payload).catch(() => {});
},
refreshMessages,
refreshSessions,
setLiveTurnBySession,
setPermissionBySession,
showModelSetupToast,
toastApi,
notifyRunEnded: ({ kind, sessionId, body }) => {
const title = sessionsRef.current.find((session) => session.id === sessionId)?.name;
// Best-effort: swallow any main-side failure so a missed banner
// never surfaces as an unhandled promise rejection.
void window.maka.notifications.runEnded({ kind, title, body }).catch(() => {});
},
});

// Tool/thinking evidence may survive its event-triggered refresh, including
Expand Down Expand Up @@ -1767,6 +1767,11 @@ export function AppShell({
: undefined
}
onPermissionModeChange={(mode) => setPermissionMode(mode)}
onInjectGuidance={
activeId
? (text) => window.maka.sessions.injectGuidance(activeId, text)
: undefined
}
/>
</div>
{activeId && liveBrowserSessionIds.includes(activeId) && (
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/renderer/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,79 @@
cursor: not-allowed;
}

.maka-composer-queue {
width: min(var(--maka-chat-measure), 100%);
display: grid;
gap: var(--space-1);
margin: 0 auto var(--space-1);
}

.maka-composer-queue-item {
min-width: 0;
min-height: 38px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-1);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-control);
background: var(--foreground-5);
color: var(--foreground);
border: var(--border-width-hairline) solid oklch(from var(--foreground) l c h / 0.06);
}

.maka-composer-queue-grip {
color: var(--muted-foreground);
justify-self: center;
}

.maka-composer-queue-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-ui);
line-height: var(--leading-snug);
}

.maka-composer-queue-actions {
display: inline-flex;
align-items: center;
gap: var(--space-0-5);
}

.maka-composer-queue-now {
min-width: 76px;
height: 30px;
gap: var(--space-1);
border-radius: var(--radius-control);
background: var(--foreground-5);
color: var(--foreground);
}

.maka-composer-queue-now:hover:not(:disabled),
.maka-composer-queue-icon:hover:not(:disabled) {
background: var(--state-hover-bg);
}

.maka-composer-queue-icon {
width: 30px;
height: 30px;
color: var(--foreground-secondary);
border-radius: var(--radius-control);
}

@media (max-width: 620px) {
.maka-composer-queue-item {
grid-template-columns: 18px minmax(0, 1fr);
}

.maka-composer-queue-actions {
grid-column: 2;
justify-self: end;
}
}

.maka-composer-workspace-row {
/* PR-PARCHMENT-HOME-2: must share the new composer card measure
(620px) so the "选择工作目录 ▾" pill stays aligned with the card
Expand Down
Loading
Loading