diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d23898ba63..bac1f2fb16 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15687 + "nonTriviaTokens": 15683 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, 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..d885908d3a 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 @@ -295,7 +295,17 @@ describe('composer first-send cleanup', () => { }); try { - assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), true); + const actions = createAppShellChatActions(createActionsDeps()); + let resolved = 0; + assert.equal( + await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }), + true, + ); + assert.equal(resolved, 1); } finally { restoreWindow(); } @@ -303,6 +313,34 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); + it('does not report a resolved session when the first send outcome is unknown', async () => { + let resolved = 0; + const restoreWindow = installWindow({ + newTasks: { create: async () => ({ id: 'session-1' }) }, + sessions: { + // `outcome_unknown`: the Host may have admitted the Message, so the + // Session is kept and the send counts as landed — but nothing proves + // the outcome, so it must not look like a resolved Session. The Work + // Board only links a task to a Session whose first send projected. + submitMessage: async () => ({ ok: false as const, reason: 'outcome_unknown' as const }), + }, + }); + + try { + const actions = createAppShellChatActions(createActionsDeps()); + const result = await actions.send('hello', undefined, { + onSessionResolved: () => { + resolved += 1; + }, + }); + assert.equal(result, true); + } finally { + restoreWindow(); + } + + assert.equal(resolved, 0); + }); + it('projects the first message before activation while waiting to submit until observation', async () => { const observation = deferred(); const order: string[] = []; 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..220cd1965b 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,11 +145,17 @@ describe('Work Board IPC', () => { ipcMain: ipc as unknown as Pick, workspaceRoot: root, mainWindowController: window, + validateLinkedSession: async () => true, }); try { const created = await ipc.invoke>( 'workBoard:create', - itemInput(), + { + scope: { kind: 'project', projectId: 'p1' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + }, ); assert.ok(created.ok); const id = created.ok ? created.value.id : ''; @@ -156,7 +164,15 @@ describe('Work Board IPC', () => { 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 +230,69 @@ 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(); + } + }); + }); + + test('rejects linking a Session to an Inbox item even when the Host validates', 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 () => true, + }); + 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: 'session-1', 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..10d8d1da53 --- /dev/null +++ b/apps/desktop/src/main/__tests__/work-board-target.test.ts @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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'); + }); + + test('rejects archived and ambiguous projects', () => { + const archived = resolveWorkBoardStartTarget( + item({ kind: 'project', projectId: 'old-project-id' }), + catalog([{ id: 'canonical-project', aliases: ['old-project-id'], name: 'Project', locations: [], available: true, archivedAt: 10 }]), + ); + assert.equal(archived.ok ? 'unexpected' : archived.reason, 'unavailable'); + + const shared = { id: 'p1', aliases: ['shared-id'], name: 'Project', locations: [], available: true }; + const multiHost: TaskEntryCatalog = { + defaultProfileId: 'profile-1', + hosts: [ + { + profile: { id: 'profile-1', name: 'Local', kind: 'local' }, + hostId: 'host-1', + readiness: 'ready', + state: 'available', + projects: [shared], + capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true }, + selectedProjectId: null, + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' }, + }, + { + profile: { id: 'profile-2', name: 'Remote', kind: 'remote' }, + hostId: 'host-2', + readiness: 'ready', + state: 'available', + projects: [{ ...shared, id: 'p2' }], + capabilities: { chooseClientDirectory: false, chooseHostDirectory: false, selectNoProject: true }, + selectedProjectId: null, + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'off' }, + }, + ], + }; + const ambiguous = resolveWorkBoardStartTarget(item({ kind: 'project', projectId: 'shared-id' }), multiHost); + assert.equal(ambiguous.ok ? 'unexpected' : ambiguous.reason, 'ambiguous'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index bde0503b47..e720599a80 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -23,7 +23,7 @@ import { afterEach, describe, it } from 'node:test'; import { act, createElement, StrictMode } from 'react'; import type { ShellRunUpdate } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; -import { LocaleProvider } from '@maka/ui'; +import { LocaleProvider, type ToastApi } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeWorkbarServices, @@ -109,9 +109,24 @@ function controller(): WorkbarController { return latestController; } +function createFakeToastApi(errors: string[] = []): ToastApi { + return { + toast: () => '', + success: () => '', + error: (title, description) => { + errors.push(description ? `${title}: ${description}` : title); + return ''; + }, + info: () => '', + warning: () => '', + confirm: async () => false, + dismiss: () => {}, + }; +} + function input( activeSession: SessionSummary | undefined, - errors: string[] = [], + toastApi: ToastApi = createFakeToastApi(), ): UseWorkbarControllerInput { return { available: true, @@ -121,7 +136,7 @@ function input( authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), shellObscured: false, modelChoices: [], - reportError: (title, description) => errors.push(`${title}: ${description}`), + toastApi, }; } @@ -441,18 +456,18 @@ describe('useWorkbarController', () => { }); await act(async () => - renderController(root, services, input(session('a'), currentErrors)), + renderController(root, services, input(session('a'), createFakeToastApi(currentErrors))), ); await act(async () => controller().commands.openTool('terminal')); await act(async () => currentStart.reject(new Error('current failure'))); assert.equal(currentErrors.length, 1); await act(async () => - renderController(root, services, input(session('a'), staleErrors)), + renderController(root, services, input(session('a'), createFakeToastApi(staleErrors))), ); await act(async () => controller().commands.openTool('terminal')); await act(async () => - renderController(root, services, input(session('b'), staleErrors)), + renderController(root, services, input(session('b'), createFakeToastApi(staleErrors))), ); await act(async () => staleStart.reject(new Error('stale failure'))); assert.deepEqual(staleErrors, []); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 1e10e24142..c452e8435f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -68,6 +68,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"; @@ -923,6 +924,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..38f7d2dcff 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,37 @@ 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 itemId = requireWorkBoardId(id); + const item = await store.get(itemId); + if (!item || item.scope.kind !== 'project') { + throw new WorkBoardStoreError( + 'invalid_input', + 'Only project-scoped Work Board items can link a Session', + ); + } + const linked = await store.linkSession( + itemId, + 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 d68e5c75c2..a238e5c7a7 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -104,7 +104,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, @@ -1002,6 +1007,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 f5cbd6c44d..9ccf8602d1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1791,6 +1791,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.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0a685bbab4..7d4b1b1f68 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1636,11 +1636,6 @@ function AppShellContent({ }), [toastApi], ); - const reportWorkbarError = useCallback( - (title: string, description: string, sessionId: string) => - toastApi.error(title, description, undefined, { sessionId }), - [toastApi], - ); const workbarAvailable = navSelection.section === 'sessions' && !workHubActive && Boolean(activeId); const workbar = useWorkbarController({ @@ -1651,7 +1646,12 @@ function AppShellContent({ authoritativeSessionIds: authoritativeSessionIds ?? undefined, shellObscured, modelChoices: chatModelChoices, - reportError: reportWorkbarError, + toastApi, + composerRef, + openNewTaskSurface, + openSessionInChat, + resolveWorkBoardTarget: taskEntry.commands.resolveWorkBoardTarget, + prepareWorkBoardDraft: taskEntry.commands.prepareWorkBoardDraft, }); const exitWorkHub = useCallback(() => setWorkHubActive(false), []); @@ -2148,12 +2148,14 @@ function AppShellContent({ : undefined; const quotes = pendingQuotes.length ? pendingQuotes : undefined; const ok = await send(text, pending, { + onSessionResolved: workbar.commands.onNewTaskSessionResolved, ...directoryOptions, ...(quotes ? { quotes } : {}), ...(workspaceFileReferences.length ? { workspaceFileReferences } : {}), }); + workbar.commands.onNewTaskSessionNotProjected(); if (ok !== false) { clearSubmittedContext(pending); if (quotes) clearQuotes(); @@ -3244,8 +3246,7 @@ function AppShellContent({ )} - {/* Collapse hides the Workbar surface without unmounting its tools; - dynamic resources therefore keep their existing lifecycle. */} + {/* Collapse hides the Workbar surface without unmounting its tools. */} 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..9fe4e9fd8f 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, @@ -34,17 +35,16 @@ import { type WorkspacePickerModel, useUiLocale, } from '@maka/ui'; -import { - getShellCopy, - localizedShellErrorMessage, -} from '../../../locales/shell-copy.js'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { isReadyTaskEntryHost, + prepareTaskEntryDraft, resolveProjectSelection, selectAvailableProfile, 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 +88,8 @@ export interface TaskEntryControllerCommands { selectLocalProject(projectId: string): boolean; addProject(): void; chooseProjectForProfile(profileId: string): Promise; + resolveWorkBoardTarget(item: WorkBoardItem): WorkBoardStartTargetResult; + prepareWorkBoardDraft(target: TaskEntryTarget, draft: string): string | undefined; } export interface TaskEntryController { @@ -493,6 +495,23 @@ export function useTaskEntryController( selectProject(localHost, projectId); return true; }, [localHost, selectProject]); + const resolveWorkBoardTarget = useCallback( + (item: WorkBoardItem): WorkBoardStartTargetResult => + resolveWorkBoardStartTarget(item, catalog), + [catalog], + ); + const prepareWorkBoardDraft = useCallback( + (target: TaskEntryTarget, draft: string): string | undefined => { + // The target was resolved by resolveWorkBoardStartTarget moments ago, so + // the Host and project availability are already proven; only seed the + // selection and persist the draft for the composer. + if (target.projectId === null) return undefined; + setSelectedProfileId(target.profileId); + setProjectSelections((current) => new Map(current).set(target.profileId, target.projectId)); + return prepareTaskEntryDraft(target, draft); + }, + [setProjectSelections, setSelectedProfileId], + ); const addSelectedProject = useCallback(() => { if (selectedHost) void addProjectForHost(selectedHost); }, [addProjectForHost, selectedHost]); @@ -533,6 +552,8 @@ export function useTaskEntryController( selectLocalProject, addProject: addSelectedProject, chooseProjectForProfile, + resolveWorkBoardTarget, + prepareWorkBoardDraft, }, selectors: { ...(target ? { target } : {}), @@ -561,6 +582,8 @@ export function useTaskEntryController( projectPath, refreshCatalog, selectLocalProject, + resolveWorkBoardTarget, + prepareWorkBoardDraft, selectedHost, selectedHostProjection, selectedProfileId, diff --git a/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts b/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts index b273715bc1..6067803adc 100644 --- a/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts +++ b/apps/desktop/src/renderer/features/task-entry/model/task-entry-selection.ts @@ -18,7 +18,11 @@ */ import { findProjectByIdentity } from '@maka/core/project'; -import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../../new-task-reload-intent.js'; +import { + markNewTaskReloadIntent, + UNRESOLVED_NEW_TASK_DRAFT_KEY, + writeNewTaskReloadDraft, +} from '../../../new-task-reload-intent.js'; import type { TaskEntryCatalog, TaskEntryHost, @@ -71,6 +75,18 @@ export function taskEntryDraftKey(target: TaskEntryTarget | undefined): string { : UNRESOLVED_NEW_TASK_DRAFT_KEY; } +/** + * Persist a draft for one explicit target and mark the new-task reload intent, + * so a renderer reload keeps the draft it was prepared for. Returns the draft + * key the composer should be opened with. + */ +export function prepareTaskEntryDraft(target: TaskEntryTarget, draft: string): string { + const draftKey = taskEntryDraftKey(target); + markNewTaskReloadIntent(); + writeNewTaskReloadDraft(draftKey, draft); + return draftKey; +} + export function isReadyTaskEntryHost(host: TaskEntryHost): host is ReadyTaskEntryHost { return host.readiness === 'ready' && host.state === 'available'; } 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..e0522b44c7 --- /dev/null +++ b/apps/desktop/src/renderer/features/task-entry/model/work-board-target.ts @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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, +): 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 (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 77a7fbad4f..8d326b065f 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 @@ -29,12 +29,14 @@ import { import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; -import { Composer, useUiLocale } from '@maka/ui'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; +import { useUiLocale, type ComposerHandle, type ToastApi } from '@maka/ui'; import type { ChatModelChoice } from '@maka/ui'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { sideChatTitleFromPrompt } from '../../../side-chat-command.js'; +import { desktopSessionKey, parseDesktopSessionKey } from '../../../../shared/runtime-host-identity.js'; import { useWorkbarServices } from '../services-context.js'; import type { WorkbarHostModel } from '../ui/workbar-host.js'; import { SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY } from '../ui/side-chat-close-confirmation.js'; @@ -77,6 +79,8 @@ export interface WorkbarControllerCommands { openSideChatWithQuote(quote: QuoteRef): void; respondToClientCapability(response: ClientCapabilityResponse): Promise; toggleRight(): void; + onNewTaskSessionResolved(sessionId: string): void; + onNewTaskSessionNotProjected(): void; } export interface WorkbarControllerSelectors { @@ -93,7 +97,15 @@ export interface UseWorkbarControllerInput { authoritativeSessionIds: ReadonlySet | undefined; shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; - reportError(title: string, description: string, sessionId: string): void; + /** Toast surface owned by the shell composition zone. */ + toastApi: ToastApi; + composerRef?: { current: Pick | null }; + openNewTaskSurface?(): void; + openSessionInChat?(sessionId: string): void; + resolveWorkBoardTarget?(item: WorkBoardItem): + | { ok: true; target: { profileId: string; hostId: string; projectId: string } } + | { ok: false; message: string }; + prepareWorkBoardDraft?(target: { profileId: string; hostId: string; projectId: string }, draft: string): string | undefined; } export interface WorkbarController { @@ -155,8 +167,11 @@ export function useWorkbarController( input: UseWorkbarControllerInput, ): WorkbarController { const locale = useUiLocale(); + const workBoardStartTaskEnabled = + (import.meta as unknown as { env?: Record }).env + ?.VITE_MAKA_WORK_BOARD_START_TASK === '1'; const terminalCopy = getDesktopConversationCopy(locale).terminalPanel; - const { browser, sideChat, terminal } = useWorkbarServices(); + const { browser, sideChat, terminal, workBoard } = useWorkbarServices(); const layout = useWorkbarLayoutState(); const sideConversations = useSideConversationWorkspace(); const [pendingSideChatClose, setPendingSideChatClose] = useState< @@ -174,6 +189,10 @@ export function useWorkbarController( const activeSessionId = input.activeSession?.id; const activeSessionIdRef = useRef(undefined); + const pendingWorkBoardStartRef = useRef<{ + itemId: string; + target: { profileId: string; hostId: string; projectId: string }; + } | undefined>(undefined); const resourceGenerationRef = useRef(0); useLayoutEffect(() => { resourceGenerationRef.current += 1; @@ -183,6 +202,103 @@ export function useWorkbarController( activeSessionIdRef.current = undefined; }; }, [activeSessionId]); + + const onNewTaskSessionNotProjected = useCallback(() => { + pendingWorkBoardStartRef.current = undefined; + }, []); + + const startWorkBoardTask = useCallback( + (item: WorkBoardItem) => { + if (pendingWorkBoardStartRef.current) { + input.toastApi.info( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Finish sending the current Work Board task before starting another one.', + ); + return; + } + const result = input.resolveWorkBoardTarget?.(item); + if (!result) { + input.toastApi.info( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Work Board task start is unavailable.', + ); + return; + } + if (!result.ok) { + input.toastApi.info(getDesktopConversationCopy(locale).workBoardPanel.actionFailed, result.message); + return; + } + const draft = [item.title, item.notes?.trim()].filter(Boolean).join('\n\n'); + const draftKey = input.prepareWorkBoardDraft?.(result.target, draft); + if (!draftKey) { + input.toastApi.info( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Work Board task start is unavailable.', + ); + return; + } + input.openNewTaskSurface?.(); + pendingWorkBoardStartRef.current = { itemId: item.id, target: result.target }; + globalThis.requestAnimationFrame(() => { + input.composerRef?.current?.setDraft(draftKey, draft); + input.composerRef?.current?.focus(); + }); + }, + [input, locale], + ); + + const openWorkBoardSession = useCallback( + (link: WorkBoardLinkedSession) => { + input.openSessionInChat?.( + desktopSessionKey({ hostId: link.hostId, sessionId: link.sessionId }), + ); + }, + [input.openSessionInChat], + ); + + const onNewTaskSessionResolved = useCallback( + (sessionId: string) => { + const pending = pendingWorkBoardStartRef.current; + if (!pending) return; + pendingWorkBoardStartRef.current = undefined; + const linkedSessionId = (() => { + try { + return parseDesktopSessionKey(sessionId).sessionId; + } catch { + return sessionId; + } + })(); + if (!workBoard) { + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Work Board linking is unavailable in this desktop session.', + ); + return; + } + void workBoard + .linkSession(pending.itemId, { + profileId: pending.target.profileId, + hostId: pending.target.hostId, + sessionId: linkedSessionId, + linkedAt: Date.now(), + }) + .then((result) => { + if (!result.ok) { + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + result.message, + ); + } + }) + .catch((error) => { + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + error instanceof Error ? error.message : String(error), + ); + }); + }, + [input, locale, workBoard], + ); const respondToClientCapability = useCallback< WorkbarControllerCommands['respondToClientCapability'] >( @@ -194,14 +310,15 @@ export function useWorkbarController( } catch (error) { if (activeSessionIdRef.current !== sessionId) return; const copy = getShellCopy(locale).chatActions; - input.reportError( + input.toastApi.error( copy.responseFailedTitle, localizedShellErrorMessage(error, copy.responseFailedFallback, locale), - sessionId, + undefined, + { sessionId }, ); } }, - [input.reportError, locale, sideChat], + [input, locale, sideChat], ); const panelsStateRef = useRef(layout.workbarPanelsState); useLayoutEffect(() => { @@ -363,14 +480,15 @@ export function useWorkbarController( ) { return; } - input.reportError( + input.toastApi.error( terminalCopy.startFailed, localizedShellErrorMessage( error, terminalCopy.startFailed, locale, ), - ownerSessionId, + undefined, + { sessionId: ownerSessionId }, ); }); return; @@ -380,9 +498,9 @@ export function useWorkbarController( } }, [ - input.reportError, layout.openDynamicWorkbarTab, layout.openWorkbarTab, + input, locale, openNewSideConversation, registerTerminal, @@ -655,8 +773,22 @@ export function useWorkbarController( ); const commands = useMemo( - () => ({ openTool, openSideChatWithQuote, respondToClientCapability, toggleRight }), - [openSideChatWithQuote, openTool, respondToClientCapability, toggleRight], + () => ({ + openTool, + openSideChatWithQuote, + respondToClientCapability, + toggleRight, + onNewTaskSessionResolved, + onNewTaskSessionNotProjected, + }), + [ + onNewTaskSessionNotProjected, + onNewTaskSessionResolved, + openSideChatWithQuote, + openTool, + respondToClientCapability, + toggleRight, + ], ); const activeSideChatTabIds = useMemo( @@ -737,6 +869,10 @@ export function useWorkbarController( onActivityStateChange: sideConversations.setActive, sourceSession: input.activeSession, modelChoices: input.modelChoices, + onStartWorkBoardTask: startWorkBoardTask, + resolveWorkBoardStartTask: input.resolveWorkBoardTarget, + onOpenWorkBoardSession: openWorkBoardSession, + workBoardStartTaskEnabled, closeConfirmation: { key: pendingSideChatClose.map(({ tab }) => tab.id).join(':') || 'closed', diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index ca077e1a56..a86a979019 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -35,6 +35,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; +import type { WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; import type { SessionChangedEvent, SessionSummary, @@ -200,6 +201,16 @@ export interface WorkbarAttachmentsService { >; } +export interface WorkbarWorkBoardService { + linkSession( + id: string, + link: WorkBoardLinkedSession, + ): Promise< + | { readonly ok: true; readonly value: WorkBoardItem } + | { readonly ok: false; readonly message: string } + >; +} + export type SideChatSendResult = | { ok: true; turnId: string; steered?: false } | { ok: true; turnId: string; steered: true; messageId: string } @@ -286,5 +297,6 @@ export interface WorkbarServices { readonly artifacts: WorkbarArtifactsService; readonly inspector: WorkbarInspectorService; readonly attachments: WorkbarAttachmentsService; + readonly workBoard?: WorkbarWorkBoardService; readonly sideChat: SideChatSessionPort; } 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/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 23480b7124..c779d5696c 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -33,7 +33,8 @@ export type DesktopWorkbarBridge = Pick< | 'shellRuns' | 'todo' | 'transcripts' ->; +> & + Partial>; export interface DesktopWorkbarServiceDependencies { readSettledMessages: typeof readSettledMessagesFrom; @@ -93,6 +94,13 @@ export function createDesktopWorkbarServices( bridge.inspector.subscribeUsageChanges(sessionId, handler), }, attachments: bridge.attachments, + ...(bridge.workBoard + ? { + workBoard: { + linkSession: (id, link) => bridge.workBoard!.linkSession(id, link), + }, + } + : {}), sideChat: { listSessions: () => bridge.sessions.list(), listTurns: (sessionId) => bridge.sessions.listTurns(sessionId), diff --git a/apps/desktop/src/renderer/styles/work-board.css b/apps/desktop/src/renderer/styles/work-board.css index b4bd0deeb8..b0458a91f1 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 { @@ -89,6 +131,7 @@ display: flex; gap: 4px; flex-shrink: 0; + flex-wrap: wrap; } .maka-work-board-message { diff --git a/apps/desktop/src/renderer/work-board-panel.tsx b/apps/desktop/src/renderer/work-board-panel.tsx index cec058ea1c..f5b514048a 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: {