From 7492206411fa09f1cdef51f9a1fd9d1a989943da Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:46:21 +0800 Subject: [PATCH 01/17] feat(desktop): add Work Board Phase 3 start-task spike --- .../app-shell-first-send-cleanup.test.ts | 70 +++++++++++- .../__tests__/work-board-ipc-main.test.ts | 48 ++++++++- .../main/__tests__/work-board-panel.test.ts | 4 +- .../main/__tests__/work-board-target.test.ts | 53 +++++++++ apps/desktop/src/main/runtime-host-boot.ts | 13 +++ apps/desktop/src/main/work-board-ipc-main.ts | 25 +++++ apps/desktop/src/preload/bridge-contract.d.ts | 12 ++- apps/desktop/src/preload/preload.ts | 3 + .../src/renderer/app-shell-chat-actions.ts | 36 ++++++- apps/desktop/src/renderer/app-shell.tsx | 102 ++++++++++++++++++ .../controller/use-task-entry-controller.ts | 27 +++++ .../src/renderer/features/task-entry/index.ts | 1 + .../task-entry/model/work-board-target.ts | 58 ++++++++++ .../renderer/features/task-entry/testing.ts | 1 + .../controller/use-workbar-controller.ts | 9 ++ .../features/workbar/ui/workbar-host.tsx | 9 ++ .../features/workbar/ui/workbar-surface.tsx | 9 ++ .../src/renderer/locales/conversation-copy.ts | 6 ++ .../src/renderer/styles/work-board.css | 48 ++++++++- .../desktop/src/renderer/work-board-panel.tsx | 51 +++++++-- docs/work-board-contract.md | 20 ++-- docs/work-board-phase1.md | 4 +- docs/work-board-phase3.md | 28 +++++ .../core/src/__tests__/work-board.test.ts | 16 +-- packages/core/src/work-board.ts | 75 ++++++++++++- .../src/__tests__/work-board-store.test.ts | 24 +++++ packages/storage/src/work-board-store.ts | 48 +++++++++ 27 files changed, 758 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/work-board-target.test.ts create mode 100644 apps/desktop/src/renderer/features/task-entry/model/work-board-target.ts create mode 100644 docs/work-board-phase3.md diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 552a34bc37..81cc647309 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -47,6 +47,64 @@ import { } from './app-shell-chat-actions-fixture.js'; describe('composer first-send cleanup', () => { + it('releases one-shot owners when a first send is refused', async () => { + let released = 0; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + submitMessage: async () => ({ + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: { loaded: [], failed: [], receipts: [] }, + }), + remove: async () => undefined, + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + onNewTaskSessionNotProjected: () => { + released += 1; + }, + }); + assert.equal(await actions.send('hello'), false); + } finally { + restoreWindow(); + } + + assert.equal(released, 1); + }); + + it('does not link a Work Board task when the first send outcome is unknown', async () => { + let released = 0; + let linked = 0; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }), + }, + }); + + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + onNewTaskSessionResolved: () => { + linked += 1; + }, + onNewTaskSessionNotProjected: () => { + released += 1; + }, + }); + assert.equal(await actions.send('hello'), true); + } finally { + restoreWindow(); + } + + assert.equal(linked, 0); + assert.equal(released, 1); + }); + it('cancels when the composer owner changes during the readiness check', async () => { const readiness = deferred(); const activeIdRef = { current: 'session-a' as string | undefined }; @@ -256,6 +314,7 @@ describe('composer first-send cleanup', () => { it('removes the just-created session when the first send REJECTS', async () => { const removed: string[] = []; + let released = 0; const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { @@ -268,12 +327,21 @@ describe('composer first-send cleanup', () => { }); try { - assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), false); + assert.equal( + await createAppShellChatActions({ + ...createActionsDeps(), + onNewTaskSessionNotProjected: () => { + released += 1; + }, + }).send('hello'), + false, + ); } finally { restoreWindow(); } assert.deepEqual(removed, ['session-1']); + assert.equal(released, 1); }); it('keeps the session once the first send lands', async () => { diff --git a/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts index aaabba2f51..45d98e56a5 100644 --- a/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts @@ -96,6 +96,7 @@ describe('Work Board IPC', () => { ipcMain: ipc as unknown as Pick, workspaceRoot: root, mainWindowController: window, + validateLinkedSession: async () => true, }); try { const created = await ipc.invoke>( @@ -128,6 +129,7 @@ describe('Work Board IPC', () => { 'workBoard:archive', 'workBoard:unarchive', 'workBoard:remove', + 'workBoard:linkSession', ]); } finally { registration.close(); @@ -143,6 +145,7 @@ describe('Work Board IPC', () => { ipcMain: ipc as unknown as Pick, workspaceRoot: root, mainWindowController: window, + validateLinkedSession: async () => true, }); try { const created = await ipc.invoke>( @@ -152,11 +155,19 @@ describe('Work Board IPC', () => { assert.ok(created.ok); const id = created.ok ? created.value.id : ''; - const renamed = await ipc.invoke< + const renamed = await ipc.invoke< WorkBoardIpcResult<{ title: string; revision: number; state: string }> >('workBoard:update', id, { title: 'Review auth v2' }); assert.ok(renamed.ok); - assert.equal(renamed.ok && renamed.value.revision, 2); + assert.equal(renamed.ok && renamed.value.revision, 2); + + const linked = await ipc.invoke>( + 'workBoard:linkSession', + id, + { profileId: 'profile-1', hostId: 'host-1', sessionId: 'session-1', linkedAt: 103 }, + ); + assert.equal(linked.ok, true); + assert.equal(linked.ok && linked.value.linkedSessions.length, 1); const staleRename = await ipc.invoke>( 'workBoard:update', @@ -214,11 +225,40 @@ describe('Work Board IPC', () => { assert.ok(page.ok); assert.equal(page.ok && page.value.items.length, 0); - // create, update, archive, unarchive, archive, remove = 6 mutations + // create, update, link, archive, unarchive, archive, remove = 7 mutations const changed = window.events.filter( (event) => event.channel === 'workBoard:changed', ); - assert.equal(changed.length, 6); + assert.equal(changed.length, 7); + } finally { + registration.close(); + } + }); + }); + + test('rejects a linked Session that the Host validator cannot prove', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + validateLinkedSession: async () => false, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + itemInput(), + ); + assert.ok(created.ok); + const linked = await ipc.invoke>( + 'workBoard:linkSession', + created.ok ? created.value.id : '', + { profileId: 'profile-1', hostId: 'host-1', sessionId: 'missing', linkedAt: 1 }, + ); + assert.equal(linked.ok, false); + if (!linked.ok) assert.equal(linked.code, 'invalid_input'); } finally { registration.close(); } diff --git a/apps/desktop/src/main/__tests__/work-board-panel.test.ts b/apps/desktop/src/main/__tests__/work-board-panel.test.ts index 5cb28993f9..a335b5a53f 100644 --- a/apps/desktop/src/main/__tests__/work-board-panel.test.ts +++ b/apps/desktop/src/main/__tests__/work-board-panel.test.ts @@ -119,13 +119,13 @@ test('prevents a second Work Board create while the first request is pending', a createCalls += 1; return createResult.promise; }); - const input = harness.container.querySelector('input'); + const input = harness.container.querySelector('textarea'); assert.ok(input); input.value = 'Later'; const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); assert.ok(propsKey, 'missing React props on input'); const props = (input as unknown as Record)[propsKey] as { - onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + onChange?: (event: { target: HTMLTextAreaElement; defaultPrevented: boolean }) => void; }; assert.ok(props.onChange, 'missing React change handler'); await act(async () => { diff --git a/apps/desktop/src/main/__tests__/work-board-target.test.ts b/apps/desktop/src/main/__tests__/work-board-target.test.ts new file mode 100644 index 0000000000..939c1e7f0d --- /dev/null +++ b/apps/desktop/src/main/__tests__/work-board-target.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { resolveWorkBoardStartTarget } from '../../renderer/features/task-entry/testing.js'; +import type { TaskEntryCatalog } from '../../renderer/features/task-entry/testing.js'; +import type { ProjectRecord } from '@maka/core/project'; +import type { WorkBoardItem } from '@maka/core/work-board'; + +const item = (scope: WorkBoardItem['scope']): WorkBoardItem => ({ + schemaVersion: 1, + id: 'item-1', + revision: 1, + scope, + title: 'Review auth', + state: 'todo', + archived: false, + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + linkedSessions: [], + createdAt: 1, + updatedAt: 1, +}); + +const catalog = (projects: readonly ProjectRecord[]): TaskEntryCatalog => ({ + defaultProfileId: 'profile-1', + hosts: [{ + profile: { id: 'profile-1', name: 'Local', kind: 'local' }, + hostId: 'host-1', + readiness: 'ready', + state: 'available', + projects, + capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true }, + selectedProjectId: null, + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' }, + }], +}); + +describe('Work Board Start task target resolution', () => { + test('resolves an available project alias to a canonical Host target', () => { + const result = resolveWorkBoardStartTarget( + item({ kind: 'project', projectId: 'old-project-id' }), + catalog([{ id: 'canonical-project', aliases: ['old-project-id'], name: 'Project', locations: [], available: true }]), + ); + assert.equal(result.ok, true); + if (result.ok) assert.deepEqual(result.target, { profileId: 'profile-1', hostId: 'host-1', projectId: 'canonical-project' }); + }); + + test('rejects Inbox and unavailable projects', () => { + const inbox = resolveWorkBoardStartTarget(item({ kind: 'inbox' }), catalog([])); + const missing = resolveWorkBoardStartTarget(item({ kind: 'project', projectId: 'missing' }), catalog([])); + assert.equal(inbox.ok ? 'unexpected' : inbox.reason, 'inbox'); + assert.equal(missing.ok ? 'unexpected' : missing.reason, 'unavailable'); + }); +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 608ebfd96c..b79512d486 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -69,6 +69,7 @@ import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { runtimeHostProfileUsesHostWorkspace } from "@maka/runtime-host/profile-kind"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; import { createWorkBoardStore } from "@maka/storage/work-board-store"; +import { normalizeWorkBoardLinkedSession } from "@maka/core/work-board"; import { createFileCredentialStore } from "@maka/storage/credential-store"; import { createMcpConfigStore } from "@maka/storage/mcp-config-store"; import { createSettingsStore } from "@maka/storage/settings-store"; @@ -958,6 +959,18 @@ const workBoardIpc = registerWorkBoardIpc({ workspaceRoot, mainWindowController, store: workBoardStore, + validateLinkedSession: async (value) => { + const normalized = normalizeWorkBoardLinkedSession(value); + if (!normalized.ok) return false; + try { + const current = runtimeHostManager?.current(normalized.value.profileId); + if (!current?.candidate || current.hostId !== normalized.value.hostId) return false; + const sessions = await current.candidate.client.listSessions(); + return sessions.some((session) => session.id === normalized.value.sessionId); + } catch { + return false; + } + }, }); const browserIpc = registerBrowserIpc({ mainWindowController, diff --git a/apps/desktop/src/main/work-board-ipc-main.ts b/apps/desktop/src/main/work-board-ipc-main.ts index 780c35e196..abc1d998cc 100644 --- a/apps/desktop/src/main/work-board-ipc-main.ts +++ b/apps/desktop/src/main/work-board-ipc-main.ts @@ -47,6 +47,8 @@ export function registerWorkBoardIpc(input: { readonly workspaceRoot: string; readonly mainWindowController: MainWindowController; readonly store?: WorkBoardStore; + /** Proves that a linked Session belongs to the live Host target. */ + readonly validateLinkedSession: (link: unknown) => Promise; readonly now?: () => number; }): WorkBoardIpcRegistration { const store = input.store ?? createWorkBoardStore(input.workspaceRoot); @@ -153,6 +155,29 @@ export function registerWorkBoardIpc(input: { }, ); + input.ipcMain.handle( + 'workBoard:linkSession', + async (_event, id: unknown, link: unknown, options?: unknown): Promise> => { + try { + if (!(await input.validateLinkedSession(link))) { + throw new WorkBoardStoreError( + 'invalid_input', + 'Work Board linked Session does not belong to an available Runtime Host', + ); + } + const linked = await store.linkSession( + requireWorkBoardId(id), + link, + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: linked }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + return { close: () => store.close(), }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 117a5f6827..c82461dfc6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -105,7 +105,12 @@ import type { DesktopTranscriptHandle, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; -import type { WorkBoardItem, WorkBoardListQuery, WorkBoardPage } from '@maka/core/work-board'; +import type { + WorkBoardItem, + WorkBoardLinkedSession, + WorkBoardListQuery, + WorkBoardPage, +} from '@maka/core/work-board'; import type { WorkBoardMutationOptions } from '@maka/storage/work-board-store'; import type { OperationInput, @@ -1004,6 +1009,11 @@ export interface MakaBridge { options?: WorkBoardMutationOptions, ): Promise>; remove(id: string, options?: WorkBoardMutationOptions): Promise>; + linkSession( + id: string, + link: WorkBoardLinkedSession, + options?: WorkBoardMutationOptions, + ): Promise>; subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 83c8b6645f..e827342afd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1893,6 +1893,9 @@ const makaBridge = { remove(id, options) { return ipcRenderer.invoke('workBoard:remove', id, options); }, + linkSession(id, link, options) { + return ipcRenderer.invoke('workBoard:linkSession', id, link, options); + }, subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void { const listener = (_event: Electron.IpcRendererEvent, payload: WorkBoardChangedEvent) => handler(payload); diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 6bf0d678de..278b4c5c33 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -208,6 +208,9 @@ export function createAppShellChatActions(deps: { newChatCollaborationMode: CollaborationMode; newChatOrchestrationMode: OrchestrationMode; newTaskTarget: DesktopNewTaskTarget | undefined; + onNewTaskSessionResolved?: (sessionId: string) => void; + /** Clears one-shot owners when a first send is not projected successfully. */ + onNewTaskSessionNotProjected?: () => void; }): AppShellChatActions { const { uiLocale, @@ -240,6 +243,8 @@ export function createAppShellChatActions(deps: { newChatCollaborationMode, newChatOrchestrationMode, newTaskTarget, + onNewTaskSessionResolved, + onNewTaskSessionNotProjected, } = deps; const copy = getShellCopy(uiLocale).chatActions; @@ -428,12 +433,20 @@ export function createAppShellChatActions(deps: { const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); const newChatOwner = initialSessionId ? null : sendOwner; - if (!initialSessionId && !initialNewTaskTarget) return false; - if (!(await checkTaskSubmissionReadiness())) return false; + const isFirstSend = !initialSessionId; + if (!initialSessionId && !initialNewTaskTarget) { + onNewTaskSessionNotProjected?.(); + return false; + } + if (!(await checkTaskSubmissionReadiness())) { + if (isFirstSend) onNewTaskSessionNotProjected?.(); + return false; + } if ( (initialSessionId && !isShellSurfaceOwnerActive(sendOwner)) || (newChatOwner && !isNewChatSendSurfaceActive(newChatOwner)) ) { + if (isFirstSend) onNewTaskSessionNotProjected?.(); return false; } let optimisticSessionId: string | undefined; @@ -542,17 +555,31 @@ export function createAppShellChatActions(deps: { if (activeIdRef.current !== session.id) { removeOptimisticUserMessage(session.id, messageId); await discardUnsentSession(); + onNewTaskSessionNotProjected?.(); return false; } const submitted = await submitIntoSession(session.id, messageId); if (submitted.kind === 'refused') { + onNewTaskSessionNotProjected?.(); await discardUnsentSession(); return false; } + if (submitted.kind === 'unreconciled') { + // The Host may have admitted the Message, but this client cannot + // prove the outcome yet. Keep the Session, but do not create a + // durable Work Board link from an unknown result. + onNewTaskSessionNotProjected?.(); + unsentSessionId = undefined; + await refreshSessions(); + return true; + } unsentSessionId = undefined; - // The callback fires only when this send's first message projected; + // The callbacks fire only when this send's first message projected; // an unreconciled first message stays unreported. - if (submitted.kind === 'projected') options.onSessionResolved?.(session.id); + if (submitted.kind === 'projected') { + options.onSessionResolved?.(session.id); + onNewTaskSessionResolved?.(session.id); + } await refreshSessions(); return true; } @@ -609,6 +636,7 @@ export function createAppShellChatActions(deps: { })) || (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner)); await discardUnsentSession(); + if (isFirstSend) onNewTaskSessionNotProjected?.(); if (optimisticSessionId && optimisticMessageId) { removeOptimisticUserMessage(optimisticSessionId, optimisticMessageId); } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index e650ee81e6..ed5702da78 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,6 +35,9 @@ import type { InlineReference, QuoteRef, } from '@maka/core/events'; +import type { SessionSummary } from '@maka/core/session'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; +import { desktopSessionKey, parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; @@ -212,6 +215,8 @@ import { useShellConnections } from './use-shell-connections'; import { useShellChatModel } from './use-shell-chat-model'; import { useShellLiveTurn } from './use-shell-live-turn'; import { useShellResume } from './use-shell-resume'; +import { taskEntryDraftKey } from './features/task-entry/index.js'; +import { markNewTaskReloadIntent, writeNewTaskReloadDraft } from './new-task-reload-intent.js'; import { useSettingsModal } from './use-settings-modal'; import { useSystemUiLocale } from './use-system-ui-locale'; @@ -233,6 +238,7 @@ type ComposerImportOwner = { */ const SETTLE_FALLBACK_GRACE_MS = 1000; const FIRST_SEND_OBSERVATION_TIMEOUT_MS = 30_000; +const WORK_BOARD_START_TASK_EXPERIMENT_ENABLED = import.meta.env.VITE_MAKA_WORK_BOARD_START_TASK === '1'; const { useSessionCollaborationDialog } = SessionCollaboration; type FirstSendObservationWaiter = { promise: Promise; @@ -1498,6 +1504,7 @@ function AppShellContent({ toastApi, }); const openNewTaskSurface = useCallback(() => { + pendingWorkBoardStartRef.current = undefined; imageNoticeLifecycle.reset(NEW_TASK_PENDING_KEY); startNewSession(); // Only Plan resets: a new task starts out of Plan, in whatever @@ -1510,6 +1517,93 @@ function AppShellContent({ window.requestAnimationFrame(() => composerRef.current?.focus()); }, [imageNoticeLifecycle, setNavSelection, setSearchScrollTarget, startNewSession]); + const pendingWorkBoardStartRef = useRef<{ + itemId: string; + target: { profileId: string; hostId: string; projectId: string }; + } | undefined>(undefined); + const resolveWorkBoardStartTask = useCallback( + (item: WorkBoardItem) => { + const result = taskEntry.commands.resolveWorkBoardTarget(item); + return result.ok ? { ok: true } : { ok: false, message: result.message }; + }, + [taskEntry.commands], + ); + const startWorkBoardTask = useCallback( + (item: WorkBoardItem) => { + if (pendingWorkBoardStartRef.current) { + toastApi.info( + getDesktopConversationCopy(uiLocale).workBoardPanel.actionFailed, + 'Finish sending the current Work Board task before starting another one.', + ); + return; + } + const result = taskEntry.commands.resolveWorkBoardTarget(item); + if (!result.ok) { + toastApi.info(getDesktopConversationCopy(uiLocale).workBoardPanel.actionFailed, result.message); + return; + } + if (!taskEntry.commands.selectTarget(result.target)) { + toastApi.info(getDesktopConversationCopy(uiLocale).workBoardPanel.actionFailed, 'The selected Runtime Host target is no longer available.'); + return; + } + const draft = [item.title, item.notes?.trim()].filter(Boolean).join('\n\n'); + markNewTaskReloadIntent(); + writeNewTaskReloadDraft(taskEntryDraftKey(result.target), draft); + openNewTaskSurface(); + pendingWorkBoardStartRef.current = { itemId: item.id, target: result.target }; + window.requestAnimationFrame(() => { + composerRef.current?.setDraft(taskEntryDraftKey(result.target), draft); + composerRef.current?.focus(); + }); + }, + [composerRef, openNewTaskSurface, taskEntry.commands, toastApi, uiLocale], + ); + const openWorkBoardSession = useCallback( + (link: WorkBoardLinkedSession) => { + openSessionInChatRef.current(desktopSessionKey({ hostId: link.hostId, sessionId: link.sessionId })); + }, + [], + ); + const linkWorkBoardSession = useCallback( + (sessionId: string) => { + const pending = pendingWorkBoardStartRef.current; + if (!pending) return; + // Consume the claim before crossing the asynchronous IPC boundary. This + // lets a new Work Board task be prepared while the previous link writes, + // without allowing the previous completion to claim the new task. + pendingWorkBoardStartRef.current = undefined; + void window.maka.workBoard + .linkSession(pending.itemId, { + profileId: pending.target.profileId, + hostId: pending.target.hostId, + sessionId: (() => { + try { + return parseDesktopSessionKey(sessionId).sessionId; + } catch { + return sessionId; + } + })(), + linkedAt: Date.now(), + }) + .then((result) => { + if (!result.ok) { + toastApi.error( + getDesktopConversationCopy(uiLocale).workBoardPanel.actionFailed, + result.message, + ); + return; + } + }) + .catch((error) => { + toastApi.error( + getDesktopConversationCopy(uiLocale).workBoardPanel.actionFailed, + error instanceof Error ? error.message : String(error), + ); + }); + }, + [toastApi, uiLocale], + ); + const createSession = useCallback(async () => { openNewTaskSurface(); }, [openNewTaskSurface]); @@ -1609,6 +1703,10 @@ function AppShellContent({ shellObscured, modelChoices: chatModelChoices, reportError: reportWorkbarError, + onStartWorkBoardTask: startWorkBoardTask, + resolveWorkBoardStartTask: resolveWorkBoardStartTask, + onOpenWorkBoardSession: openWorkBoardSession, + workBoardStartTaskEnabled: WORK_BOARD_START_TASK_EXPERIMENT_ENABLED, }); const exitWorkHub = useCallback(() => setWorkHubActive(false), []); @@ -1780,6 +1878,10 @@ function AppShellContent({ newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', newChatOrchestrationMode: newChatOrchestrationMode, newTaskTarget: taskEntry.selectors.target, + onNewTaskSessionResolved: linkWorkBoardSession, + onNewTaskSessionNotProjected: () => { + pendingWorkBoardStartRef.current = undefined; + }, }); const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { diff --git a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts index 820797be1b..3d9708b923 100644 --- a/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts +++ b/apps/desktop/src/renderer/features/task-entry/controller/use-task-entry-controller.ts @@ -25,6 +25,7 @@ import { useState, } from 'react'; import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; +import type { WorkBoardItem } from '@maka/core/work-board'; import { runtimeHostProfileUsesHostWorkspace, type RuntimeHostProfileKind, @@ -45,6 +46,7 @@ import { taskEntryDraftKey, type ReadyTaskEntryHost, } from '../model/task-entry-selection.js'; +import { resolveWorkBoardStartTarget, type WorkBoardStartTargetResult } from '../model/work-board-target.js'; import type { TaskEntryCatalog, TaskEntryHostRef, @@ -88,6 +90,8 @@ export interface TaskEntryControllerCommands { selectLocalProject(projectId: string): boolean; addProject(): void; chooseProjectForProfile(profileId: string): Promise; + resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult; + selectTarget(target: TaskEntryTarget): boolean; } export interface TaskEntryController { @@ -493,6 +497,25 @@ export function useTaskEntryController( selectProject(localHost, projectId); return true; }, [localHost, selectProject]); + const resolveWorkBoardTarget = useCallback( + (item: WorkBoardItem): WorkBoardStartTargetResult => + resolveWorkBoardStartTarget(item, catalog), + [catalog], + ); + const selectTarget = useCallback((target: TaskEntryTarget): boolean => { + const host = catalog.hosts.find( + (candidate): candidate is ReadyTaskEntryHost => + candidate.profile.id === target.profileId && + isReadyTaskEntryHost(candidate) && + candidate.hostId === target.hostId, + ); + if (!host || target.projectId === null) return false; + const project = findProjectByIdentity(host.projects, target.projectId); + if (!project?.available || project.archivedAt !== undefined) return false; + setSelectedProfileId(host.profile.id); + setProjectSelections((current) => new Map(current).set(host.profile.id, project.id)); + return true; + }, [catalog.hosts]); const addSelectedProject = useCallback(() => { if (selectedHost) void addProjectForHost(selectedHost); }, [addProjectForHost, selectedHost]); @@ -533,6 +556,8 @@ export function useTaskEntryController( selectLocalProject, addProject: addSelectedProject, chooseProjectForProfile, + resolveWorkBoardTarget, + selectTarget, }, selectors: { ...(target ? { target } : {}), @@ -561,6 +586,8 @@ export function useTaskEntryController( projectPath, refreshCatalog, selectLocalProject, + resolveWorkBoardTarget, + selectTarget, selectedHost, selectedHostProjection, selectedProfileId, diff --git a/apps/desktop/src/renderer/features/task-entry/index.ts b/apps/desktop/src/renderer/features/task-entry/index.ts index b26b903df0..56c0c55d84 100644 --- a/apps/desktop/src/renderer/features/task-entry/index.ts +++ b/apps/desktop/src/renderer/features/task-entry/index.ts @@ -20,4 +20,5 @@ export { TaskEntryHost } from './ui/task-entry-host.js'; export { TaskEntryServicesProvider } from './services-context.js'; export { useTaskEntryController } from './controller/use-task-entry-controller.js'; +export { taskEntryDraftKey } from './model/task-entry-selection.js'; export type { TaskEntryServices } from './ports.js'; diff --git a/apps/desktop/src/renderer/features/task-entry/model/work-board-target.ts b/apps/desktop/src/renderer/features/task-entry/model/work-board-target.ts new file mode 100644 index 0000000000..e015e44a4a --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/model/work-board-target.ts @@ -0,0 +1,58 @@ +import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project'; +import type { WorkBoardItem } from '@maka/core/work-board'; +import { isReadyTaskEntryHost, type ReadyTaskEntryHost } from './task-entry-selection.js'; +import type { TaskEntryCatalog, TaskEntryTarget } from '../ports.js'; +type ProjectTaskEntryTarget = TaskEntryTarget & { readonly projectId: string }; + +export type WorkBoardStartTargetResult = + | { readonly ok: true; readonly target: ProjectTaskEntryTarget; readonly project: ProjectRecord } + | { + readonly ok: false; + readonly reason: 'inbox' | 'unavailable' | 'ambiguous'; + readonly message: string; + }; + +/** Resolve a board project's identity to one explicit, available Host target. */ +export function resolveWorkBoardStartTarget( + item: WorkBoardItem, + catalog: TaskEntryCatalog, + preferredHost?: { readonly profileId: string; readonly hostId: string }, +): WorkBoardStartTargetResult { + if (item.scope.kind !== 'project') { + return { ok: false, reason: 'inbox', message: 'Inbox items need a project target before they can start a task.' }; + } + const projectIdentity = item.scope.projectId; + const matches = catalog.hosts + .filter((host): host is ReadyTaskEntryHost => isReadyTaskEntryHost(host) && typeof host.hostId === 'string') + .map((host: ReadyTaskEntryHost) => { + const project = findProjectByIdentity(host.projects, projectIdentity); + if (!project || !project.available || project.archivedAt !== undefined) return undefined; + return { + target: { profileId: host.profile.id, hostId: host.hostId, projectId: project.id }, + project, + }; + }) + .filter((value): value is { target: ProjectTaskEntryTarget; project: ProjectRecord } => value !== undefined); + if (matches.length === 0) { + return { + ok: false, + reason: 'unavailable', + message: 'The project is not available on a connected Runtime Host.', + }; + } + if (preferredHost) { + const preferred = matches.find( + (match) => match.target.profileId === preferredHost.profileId && match.target.hostId === preferredHost.hostId, + ); + if (preferred) return { ok: true, ...preferred }; + } + if (matches.length !== 1) { + return { + ok: false, + reason: 'ambiguous', + message: 'This project is available on more than one Runtime Host; choose a Host explicitly.', + }; + } + const only = matches[0]; + return { ok: true, target: only.target, project: only.project }; +} diff --git a/apps/desktop/src/renderer/features/task-entry/testing.ts b/apps/desktop/src/renderer/features/task-entry/testing.ts index cda19f74ee..d89368d614 100644 --- a/apps/desktop/src/renderer/features/task-entry/testing.ts +++ b/apps/desktop/src/renderer/features/task-entry/testing.ts @@ -29,6 +29,7 @@ export { selectAvailableProfile, taskEntryDraftKey, } from './model/task-entry-selection.js'; +export { resolveWorkBoardStartTarget } from './model/work-board-target.js'; export type { TaskEntryCatalog, TaskEntryHost, diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 59662743ab..c2f592e77e 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -30,6 +30,7 @@ import type { ClientCapabilityResponse } from '@maka/core/client-capability-gran import type { QuoteRef } from '@maka/core/events'; import type { InteractionFormResponse } from '@maka/core/interaction'; import type { SessionSummary } from '@maka/core/session'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; import { Composer, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; @@ -96,6 +97,10 @@ export interface UseWorkbarControllerInput { shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; reportError(title: string, description: string, sessionId: string): void; + onStartWorkBoardTask?: (item: WorkBoardItem) => void; + resolveWorkBoardStartTask?: (item: WorkBoardItem) => { ok: boolean; message?: string }; + onOpenWorkBoardSession?: (link: WorkBoardLinkedSession) => void; + workBoardStartTaskEnabled?: boolean; } export interface WorkbarController { @@ -751,6 +756,10 @@ export function useWorkbarController( onActivityStateChange: sideConversations.setActive, sourceSession: input.activeSession, modelChoices: input.modelChoices, + onStartWorkBoardTask: input.onStartWorkBoardTask, + resolveWorkBoardStartTask: input.resolveWorkBoardStartTask, + onOpenWorkBoardSession: input.onOpenWorkBoardSession, + workBoardStartTaskEnabled: input.workBoardStartTaskEnabled, closeConfirmation: { key: pendingSideChatClose.map(({ tab }) => tab.id).join(':') || 'closed', diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index d0cf65016e..bd30fc2cca 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -24,6 +24,7 @@ import { Spinner } from '@astryxdesign/core/Spinner'; import { Composer, useToast, useUiLocale } from '@maka/ui'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { SessionSummary } from '@maka/core/session'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; import { confirmBypassPermission, getShellCopy } from '../../../locales/shell-copy'; import type { SessionWorkbarPanelsState, @@ -125,6 +126,10 @@ export interface WorkbarHostModel { activeSideChatPanelIds?: ReadonlySet; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + onStartWorkBoardTask?: (item: WorkBoardItem) => void; + resolveWorkBoardStartTask?: (item: WorkBoardItem) => { ok: boolean; message?: string }; + onOpenWorkBoardSession?: (link: WorkBoardLinkedSession) => void; + workBoardStartTaskEnabled?: boolean; closeConfirmation: { key: string; open: boolean; @@ -208,6 +213,10 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) { activeSideChatPanelIds={props.activeSideChatPanelIds} sourceSession={props.sourceSession} modelChoices={props.modelChoices} + onStartWorkBoardTask={props.onStartWorkBoardTask} + resolveWorkBoardStartTask={props.resolveWorkBoardStartTask} + onOpenWorkBoardSession={props.onOpenWorkBoardSession} + workBoardStartTaskEnabled={props.workBoardStartTaskEnabled} confirmBypass={() => confirmBypassPermission(toast, locale)} /> diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx index 43ee14343f..d4ca5b98ad 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx @@ -75,6 +75,7 @@ import { Section } from '@astryxdesign/core/Section'; import { Spinner } from '@astryxdesign/core/Spinner'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import type { SessionSummary } from '@maka/core/session'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; import { QuoteCompanionPanel } from '../tools/side-chat/quote-companion-panel'; import { type SessionWorkbarTab, @@ -692,6 +693,10 @@ export function WorkbarSurface(props: { activeSideChatPanelIds?: ReadonlySet; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; + onStartWorkBoardTask?: (item: WorkBoardItem) => void; + resolveWorkBoardStartTask?: (item: WorkBoardItem) => { ok: boolean; message?: string }; + onOpenWorkBoardSession?: (link: WorkBoardLinkedSession) => void; + workBoardStartTaskEnabled?: boolean; confirmBypass: () => Promise; }) { const locale = useUiLocale(); @@ -812,6 +817,10 @@ export function WorkbarSurface(props: { ); } else if (tab.kind === 'browser') { diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 797dda1f15..d3db1c6db3 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -150,6 +150,8 @@ export interface DesktopConversationCopy { unarchive: string; delete: string; archived: string; + startTask: string; + openSession: string; }; reviewPanel: { ariaLabel: string; @@ -540,6 +542,8 @@ const COPY = { unarchive: '恢复', delete: '删除', archived: '已归档', + startTask: '开始任务', + openSession: '打开会话', }, reviewPanel: { ariaLabel: 'Git 变更', @@ -794,6 +798,8 @@ const COPY = { unarchive: 'Restore', delete: 'Delete', archived: 'Archived', + startTask: 'Start task', + openSession: 'Open session', }, reviewPanel: { ariaLabel: 'Git changes', diff --git a/apps/desktop/src/renderer/styles/work-board.css b/apps/desktop/src/renderer/styles/work-board.css index b4bd0deeb8..17e520ecae 100644 --- a/apps/desktop/src/renderer/styles/work-board.css +++ b/apps/desktop/src/renderer/styles/work-board.css @@ -30,14 +30,56 @@ gap: 8px; } +/* The create block reads as one card, like the chat composer: the textarea + owns the top of the card and the submit button sits where a send button + would — bottom right. */ .maka-work-board-create { display: flex; - gap: 8px; + flex-direction: column; + gap: 4px; + padding: 8px; + border-radius: var(--radius-surface); + background: var(--background-elevated); + box-shadow: inset 0 0 0 var(--border-width-hairline) var(--border); } +.maka-work-board-create:focus-within { + box-shadow: + inset 0 0 0 var(--border-width-hairline) var(--border), + 0 0 0 var(--focus-ring-width) var(--focus-ring); +} + +/* `field-sizing: content` grows the field with what is typed: it opens at two + rows, stretches to four, and only then does `overflow-y` take over and + scroll. The explicit `line-height` keeps the row math exact. */ .maka-work-board-create-input { - flex: 1; - min-width: 0; + width: 100%; + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: inherit; + font: inherit; + line-height: 20px; + resize: none; + field-sizing: content; + min-height: 40px; + max-height: 80px; + overflow-y: auto; +} + +.maka-work-board-create-input::placeholder { + color: var(--muted-foreground); +} + +.maka-work-board-create-input:disabled { + opacity: 0.6; +} + +.maka-work-board-create-button { + align-self: flex-end; } .maka-work-board-list { diff --git a/apps/desktop/src/renderer/work-board-panel.tsx b/apps/desktop/src/renderer/work-board-panel.tsx index cec058ea1c..2f197f6028 100644 --- a/apps/desktop/src/renderer/work-board-panel.tsx +++ b/apps/desktop/src/renderer/work-board-panel.tsx @@ -25,6 +25,7 @@ import { useUiLocale } from '@maka/ui'; import type { CreateWorkBoardItemInput, WorkBoardItem, + WorkBoardLinkedSession, WorkBoardListQuery, WorkBoardScope, } from '@maka/core/work-board'; @@ -53,6 +54,11 @@ interface ActiveWorkBoardRowActions { onReopen(): void; onMove(): void; onArchive(): void; + canStart: boolean; + startReason?: string; + onStartTask(): void; + onOpenSession(link: WorkBoardLinkedSession): void; + startTaskEnabled: boolean; } interface ArchivedWorkBoardRowActions { @@ -92,7 +98,7 @@ function WorkBoardRow(props: { } }} /> - ) : ( + ) : ( {item.title} )} {item.archived && {copy.archived}} @@ -103,8 +109,25 @@ function WorkBoardRow(props: {