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 @@ -70,6 +70,7 @@ function createActionsDeps() {
sessionId: undefined,
navSection: 'sessions' as const,
}),
checkTaskSubmissionReadiness: async () => true,
clearPendingSessionAction: () => undefined,
isNewChatSendSurfaceActive: () => true,
isShellSurfaceOwnerActive: () => true,
Expand All @@ -95,6 +96,41 @@ function createActionsDeps() {
}

describe('composer first-send cleanup', () => {
it('cancels when the composer owner changes during the readiness check', async () => {
const readiness = deferred<boolean>();
const activeIdRef = { current: 'session-a' as string | undefined };
let sends = 0;
const restoreWindow = installWindow({
sessions: {
send: async () => {
sends += 1;
return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } };
},
},
});

try {
const actions = createAppShellChatActions({
...createActionsDeps(),
activeIdRef,
captureComposerImportOwner: () => ({
sessionId: activeIdRef.current,
navSection: 'sessions',
}),
checkTaskSubmissionReadiness: () => readiness.promise,
isShellSurfaceOwnerActive: (owner) => owner.sessionId === activeIdRef.current,
});
const sending = actions.send('hello');
activeIdRef.current = 'session-b';
readiness.resolve(true);

assert.equal(await sending, false);
assert.equal(sends, 0, 'readiness for session A must never authorize a send to session B');
} finally {
restoreWindow();
}
});

it('passes the effective offered model when creating the first session', async () => {
let createInput: unknown;
const restoreWindow = installWindow({
Expand Down Expand Up @@ -239,6 +275,14 @@ describe('composer first-send cleanup', () => {
});
});

function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((settle) => {
resolve = settle;
});
return { promise, resolve };
}

/**
* #1433 round 5: the failure feedback for a send is addressed to the surface
* that sent, and `showModelSetupToast` is not just a toast — it ends in
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/main/__tests__/task-readiness-notice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TaskSubmissionReadinessSnapshot } from '@maka/core';
import {
deriveTaskReadinessNotice,
isTaskSubmissionHardBlocked,
resolveTaskReadinessModelTarget,
} from '../../renderer/task-readiness-notice.js';

test('an unlocked stale session checks the send projection rebind target', () => {
assert.deepEqual(
resolveTaskReadinessModelTarget(
{ llmConnectionSlug: 'stale', model: 'removed-model' },
{ kind: 'rebind', connectionSlug: 'healthy', model: 'ready-model' },
undefined,
),
{ connectionSlug: 'healthy', model: 'ready-model' },
);
});

test('confirmed repair and unavailable states block, while loading uncertainty does not', () => {
assert.equal(isTaskSubmissionHardBlocked(snapshot('repair_required', 'model_target')), true);
assert.equal(isTaskSubmissionHardBlocked(snapshot('unavailable', 'runtime')), true);
assert.equal(
isTaskSubmissionHardBlocked(snapshot('repair_required', 'model_target'), {
ignoreModelTarget: true,
}),
false,
);
assert.equal(isTaskSubmissionHardBlocked(snapshot('unknown', 'runtime')), false);
assert.equal(isTaskSubmissionHardBlocked(undefined), false);
});

test('runtime and workspace blockers produce actionable localized notices', () => {
const runtime = deriveTaskReadinessNotice(snapshot('unavailable', 'runtime'), 'en');
assert.equal(runtime?.action, 'retry');
assert.match(runtime?.title ?? '', /runtime/i);

const workspace = deriveTaskReadinessNotice(snapshot('unavailable', 'workspace'), 'zh');
assert.equal(workspace?.action, 'workspace_picker');
assert.match(workspace?.title ?? '', /工作区/);
});

test('model blockers stay owned by existing connection recovery surfaces', () => {
assert.equal(
deriveTaskReadinessNotice(snapshot('repair_required', 'model_target'), 'zh'),
undefined,
);
});

function snapshot(
state: TaskSubmissionReadinessSnapshot['state'],
id: 'runtime' | 'model_target' | 'workspace',
): TaskSubmissionReadinessSnapshot {
const dimension = {
id,
state,
authority:
id === 'runtime'
? ('runtime_host' as const)
: id === 'workspace'
? ('workspace_execution' as const)
: ('connection_readiness' as const),
checkedAt: 1,
...(id === 'workspace' ? { repairTarget: { kind: 'workspace_picker' as const } } : {}),
};
return {
checkedAt: 1,
state,
dimensions: [dimension],
blockers: state === 'ready' ? [] : [dimension],
};
}
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/app-shell-chat-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export function createAppShellChatActions(deps: {
setPendingBySession: BooleanRecordUpdater,
) => boolean;
captureComposerImportOwner: () => ComposerImportOwner;
checkTaskSubmissionReadiness: () => Promise<boolean>;
clearPendingSessionAction: (
sessionId: string,
pendingRef: RefBox<Set<string>>,
Expand Down Expand Up @@ -178,6 +179,7 @@ export function createAppShellChatActions(deps: {
activeIdRef,
addPendingSessionAction,
captureComposerImportOwner,
checkTaskSubmissionReadiness,
clearPendingSessionAction,
isNewChatSendSurfaceActive,
isShellSurfaceOwnerActive,
Expand Down Expand Up @@ -305,6 +307,13 @@ export function createAppShellChatActions(deps: {
const initialSessionId = activeIdRef.current;
const sendOwner = captureComposerImportOwner();
const newChatOwner = initialSessionId ? null : sendOwner;
if (!(await checkTaskSubmissionReadiness())) return false;
if (
(initialSessionId && !isShellSurfaceOwnerActive(sendOwner)) ||
(newChatOwner && !isNewChatSendSurfaceActive(newChatOwner))
) {
return false;
}
let optimisticSessionId: string | undefined;
let optimisticTurnId: string | undefined;
// #1433: the composer creates the session BEFORE it sends, so a first
Expand Down
49 changes: 45 additions & 4 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ import { MessageCircleQuestion } from '@maka/ui/icons';
import { useKeyboardHelp } from './keyboard-help';
import { useCommandPalette } from './command-palette';
import { ChatMessageSurface } from './chat-message-surface';
import { useTaskSubmissionReadiness } from './use-task-submission-readiness';
import {
deriveTaskReadinessNotice,
isTaskSubmissionHardBlocked,
resolveTaskReadinessModelTarget,
} from './task-readiness-notice';
import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery';
import { LiveTurnReconciler } from './live-turn-reconciler';
import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads';
Expand Down Expand Up @@ -715,6 +721,9 @@ function AppShellContent({
// live in useShellChatModel (pure derivation of the snapshot + active session);
// openSettingsSection is injected so the notice can wrap the derived click
// target.
const activeSessionSendOutcome = activeSession
? onboarding.snapshot?.sessionSendOutcomes[activeSession.id]
: undefined;
const {
chatModelChoices,
activeConnection,
Expand All @@ -735,9 +744,7 @@ function AppShellContent({
uiLocale,
connections,
snapshotChoices: onboarding.snapshot?.chatModelChoices,
sessionSendOutcome: activeSession
? onboarding.snapshot?.sessionSendOutcomes[activeSession.id]
: undefined,
sessionSendOutcome: activeSessionSendOutcome,
defaultConnection,
activationCandidate: onboardingActivationCandidate,
activeSession,
Expand Down Expand Up @@ -1753,6 +1760,20 @@ function AppShellContent({
},
onSelectNoProject: selectNoProject,
};
const taskReadinessRequest = {
...resolveTaskReadinessModelTarget(activeSession, activeSessionSendOutcome, newChatModel),
cwd: activeSession?.cwd ?? projectInfo?.projectPath,
};
const taskReadiness = useTaskSubmissionReadiness(
taskReadinessRequest,
onboarding.snapshot,
);
const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale);
const ignoreTaskReadinessModelTarget =
activeSession !== undefined && activeSessionSendOutcome?.kind !== 'blocked';
const taskSubmissionHardBlocked = isTaskSubmissionHardBlocked(taskReadiness.snapshot, {
ignoreModelTarget: ignoreTaskReadinessModelTarget,
});
// The titlebar names the directory the ACTIVE session runs in, so it reads
// the same projected project state the picker does — `projectInfo` already
// resolves to the session's own cwd once a session owns it.
Expand Down Expand Up @@ -1833,6 +1854,7 @@ function AppShellContent({
activeIdRef,
addPendingSessionAction,
captureComposerImportOwner,
checkTaskSubmissionReadiness: taskSubmissionReadyAtSend,
clearPendingSessionAction,
isNewChatSendSurfaceActive,
isShellSurfaceOwnerActive,
Expand Down Expand Up @@ -1896,6 +1918,13 @@ function AppShellContent({
upsertSessionSummary,
});

async function taskSubmissionReadyAtSend(): Promise<boolean> {
const snapshot = await taskReadiness.checkNow();
return !isTaskSubmissionHardBlocked(snapshot, {
ignoreModelTarget: ignoreTaskReadinessModelTarget,
});
}

async function sendWithAttachments(
text: string,
metadata?: { workspaceFileReferences?: readonly WorkspaceFileReferencePosition[] },
Expand Down Expand Up @@ -2777,7 +2806,9 @@ function AppShellContent({
onOpenModelSettings={() => openSettingsSection('models')}
noModelConnection={connections.length === 0}
sendBlocked={
Boolean(workspaceReadinessRecovery) || sessionHealthNotice?.tone === 'destructive'
Boolean(workspaceReadinessRecovery) ||
sessionHealthNotice?.tone === 'destructive' ||
taskSubmissionHardBlocked
}
permissionMode={activePermissionMode}
permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false}
Expand Down Expand Up @@ -2958,6 +2989,16 @@ function AppShellContent({
}}
sessionHealthNotice={sessionHealthNotice}
workspaceReadinessRecovery={workspaceReadinessRecovery}
taskReadinessNotice={taskReadinessNotice}
onTaskReadinessAction={
taskReadinessNotice?.action === 'workspace_picker'
? activeSession
? openNewTaskSurface
: () => {
void addProject();
}
: taskReadiness.refresh
}
showOnboardingHero={showOnboardingHero}
onboardingState={onboardingState}
isOnboardingLoading={isOnboardingLoading}
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/chat-message-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { OnboardingHero } from './onboarding-hero';
import type { AppShellSessionUiState, AppShellSessionUiStateController } from './app-shell-session-ui-state';
import type { SessionHealthNoticeView } from './use-shell-chat-model';
import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery';
import type { TaskReadinessNotice } from './task-readiness-notice';
import { getShellCopy } from './locales/shell-copy';
import { selectLiveTurn } from './use-app-shell-session-ui-reads';
import { useAppShellSessionUiSelector } from './use-app-shell-session-ui-selector';
Expand Down Expand Up @@ -44,6 +45,8 @@ interface ChatMessageSurfaceProps extends Omit<
activeSessionId: string | undefined;
sessionHealthNotice?: SessionHealthNoticeView;
workspaceReadinessRecovery?: WorkspaceReadinessRecovery;
taskReadinessNotice?: TaskReadinessNotice;
onTaskReadinessAction: () => void;
showOnboardingHero: boolean;
onboardingState: OnboardingState | undefined;
isOnboardingLoading: boolean;
Expand All @@ -61,6 +64,8 @@ export function ChatMessageSurface({
activeSessionId,
sessionHealthNotice,
workspaceReadinessRecovery,
taskReadinessNotice,
onTaskReadinessAction,
showOnboardingHero,
onboardingState,
isOnboardingLoading,
Expand Down Expand Up @@ -147,6 +152,22 @@ export function ChatMessageSurface({
deepResearchRun={deepResearchRun}
emptyOverride={emptyOverride}
/>
{taskReadinessNotice && (
<div className="maka-workspace-readiness-notice">
<Banner
status={taskReadinessNotice.tone === 'destructive' ? 'error' : 'warning'}
className="maka-workspace-readiness-notice-alert"
role="status"
title={taskReadinessNotice.title}
description={taskReadinessNotice.description}
endContent={<Button
label={taskReadinessNotice.actionLabel}
variant="ghost"
size="sm"
onClick={onTaskReadinessAction}
/>} />
</div>
)}
{workspaceReadinessRecovery && (
<div className="maka-workspace-readiness-notice">
<Banner
Expand Down
Loading
Loading