Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
54 changes: 54 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,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TaskSubmissionReadinessSnapshot } from '@maka/core';
import {
deriveTaskReadinessNotice,
isTaskSubmissionHardBlocked,
} from '../../renderer/task-readiness-notice.js';

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('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, 'new_task');
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,
};
return {
checkedAt: 1,
state,
dimensions: [dimension],
blockers: state === 'ready' ? [] : [dimension],
};
}
26 changes: 25 additions & 1 deletion apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ 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,
} 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 @@ -1753,6 +1758,17 @@ function AppShellContent({
},
onSelectNoProject: selectNoProject,
};
const taskReadinessRequest = {
connectionSlug: activeSession?.llmConnectionSlug ?? newChatModel?.llmConnectionSlug,
model: activeSession?.model ?? newChatModel?.model,
cwd: activeSession?.cwd ?? projectInfo?.projectPath,
};
const taskReadiness = useTaskSubmissionReadiness(
taskReadinessRequest,
onboarding.snapshot,
);
const taskReadinessNotice = deriveTaskReadinessNotice(taskReadiness.snapshot, uiLocale);
const taskSubmissionHardBlocked = isTaskSubmissionHardBlocked(taskReadiness.snapshot);
// 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 @@ -2777,7 +2793,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 +2976,12 @@ function AppShellContent({
}}
sessionHealthNotice={sessionHealthNotice}
workspaceReadinessRecovery={workspaceReadinessRecovery}
taskReadinessNotice={taskReadinessNotice}
onTaskReadinessAction={
taskReadinessNotice?.action === 'new_task'
? openNewTaskSurface
: 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
72 changes: 72 additions & 0 deletions apps/desktop/src/renderer/task-readiness-notice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type {
TaskSubmissionReadinessDimension,
TaskSubmissionReadinessSnapshot,
UiLocale,
} from '@maka/core';

export interface TaskReadinessNotice {
tone: 'warning' | 'destructive';
title: string;
description: string;
actionLabel: string;
action: 'retry' | 'new_task';
}

export function isTaskSubmissionHardBlocked(
snapshot: TaskSubmissionReadinessSnapshot | undefined,
): boolean {
return snapshot?.state === 'repair_required' || snapshot?.state === 'unavailable';
}

/** Model blockers already have connection-specific recovery surfaces. */
export function deriveTaskReadinessNotice(
snapshot: TaskSubmissionReadinessSnapshot | undefined,
locale: UiLocale,
): TaskReadinessNotice | undefined {
if (!snapshot) return undefined;
const blocker = snapshot.blockers.find(
(candidate) =>
candidate.state !== 'unknown' &&
(candidate.id === 'runtime' || candidate.id === 'workspace'),
);
if (!blocker) return undefined;
return noticeForBlocker(blocker, locale);
}

function noticeForBlocker(
blocker: TaskSubmissionReadinessDimension,
locale: UiLocale,
): TaskReadinessNotice {
if (blocker.id === 'runtime') {
return locale === 'zh'
? {
tone: 'destructive',
title: 'Maka 运行服务暂时不可用。',
description: '任务尚未提交。重新检测运行服务后再试。',
actionLabel: '重新检测',
action: 'retry',
}
: {
tone: 'destructive',
title: 'The Maka runtime is unavailable.',
description: 'The task was not submitted. Check the runtime again before retrying.',
actionLabel: 'Check again',
action: 'retry',
};
}
return locale === 'zh'
? {
tone: 'destructive',
title: '当前任务的工作区不可用。',
description: '原目录可能已移动、删除或无法访问。请新建任务并选择可用工作区。',
actionLabel: '新建任务',
action: 'new_task',
}
: {
tone: 'destructive',
title: 'This task workspace is unavailable.',
description: 'The folder may have moved, been deleted, or become inaccessible. Start a new task with an available workspace.',
actionLabel: 'New task',
action: 'new_task',
};
}
30 changes: 30 additions & 0 deletions apps/desktop/src/renderer/use-task-submission-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react';
import type { TaskSubmissionReadinessSnapshot } from '@maka/core';
import type { DesktopTaskSubmissionReadinessRequest } from '../preload/bridge-contract.js';

export function useTaskSubmissionReadiness(
request: DesktopTaskSubmissionReadinessRequest,
refreshKey: unknown,
) {
const [snapshot, setSnapshot] = useState<TaskSubmissionReadinessSnapshot>();
const [revision, setRevision] = useState(0);
const refresh = useCallback(() => setRevision((value) => value + 1), []);

useEffect(() => {
let current = true;
setSnapshot(undefined);
void window.maka.taskReadiness
.getSnapshot(request)
.then((next) => {
if (current) setSnapshot(next);
})
.catch(() => {
if (current) setSnapshot(undefined);
});
return () => {
current = false;
};
}, [request.connectionSlug, request.model, request.cwd, refreshKey, revision]);

return { snapshot, refresh };
}
Loading