diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 1ab3bb754b..e49513ef9e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -891,7 +891,7 @@ "react": 1 }, "importSpecifiers": 148, - "nonTriviaTokens": 15620 + "nonTriviaTokens": 15613 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, 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..8d3814b971 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 @@ -278,6 +278,7 @@ describe('composer first-send cleanup', () => { it('keeps the session once the first send lands', async () => { const removed: string[] = []; + let currentDraftKey = 'draft:project-A'; const restoreWindow = installWindow({ newTasks: { create: async () => ({ id: 'session-1' }) }, sessions: { @@ -295,7 +296,28 @@ describe('composer first-send cleanup', () => { }); try { - assert.equal(await createAppShellChatActions(createActionsDeps()).send('hello'), true); + const actions = createAppShellChatActions({ + ...createActionsDeps(), + captureComposerImportOwner: () => ({ + sessionId: undefined, + navSection: 'sessions', + newTaskDraftKey: currentDraftKey, + }), + checkTaskSubmissionReadiness: async () => { + currentDraftKey = 'draft:project-B'; + return true; + }, + }); + let resolved: [string, string?] | undefined; + assert.equal( + await actions.send('hello', undefined, { + onSessionResolved: (...args) => { + resolved = args; + }, + }), + true, + ); + assert.deepEqual(resolved, ['session-1', 'draft:project-A']); } finally { restoreWindow(); } @@ -303,6 +325,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..b99b53b5fd 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 @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import type { IpcMain } from 'electron'; +import { normalizeWorkBoardLinkedSession } from '@maka/core/work-board'; import { registerWorkBoardIpc, type WorkBoardChangedEvent, @@ -96,6 +97,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 +130,7 @@ describe('Work Board IPC', () => { 'workBoard:archive', 'workBoard:unarchive', 'workBoard:remove', + 'workBoard:linkSession', ]); } finally { registration.close(); @@ -143,11 +146,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 +165,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 +231,215 @@ 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(); + } + }); + }); + + test('passes the canonical board project to the Host validator for a project-scoped item', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + const validated: Array<{ link: unknown; project: string | undefined }> = []; + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + validateLinkedSession: async (link, expectedProjectId) => { + validated.push({ link, project: expectedProjectId }); + return true; + }, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + { + scope: { kind: 'project', projectId: 'p1' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + }, + ); + assert.ok(created.ok); + const link = { + profileId: 'profile-1', + hostId: 'host-1', + sessionId: 'session-1', + linkedAt: 1, + }; + const linked = await ipc.invoke>( + 'workBoard:linkSession', + created.ok ? created.value.id : '', + link, + ); + assert.equal(linked.ok, true); + assert.deepEqual(validated, [{ link, project: 'p1' }]); + } finally { + registration.close(); + } + }); + }); + + test('rejects a project-scoped link whose Session belongs to another project on the same Host', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + // Production-shaped Host validation: a Session's workspace target must be + // a project matching the canonical board project, mirroring + // runtime-host-boot.ts. + const sessions = [ + { id: 's-p1', workspace: { target: { kind: 'project', projectId: 'p1' } } }, + { id: 's-p2', workspace: { target: { kind: 'project', projectId: 'p2' } } }, + ]; + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + validateLinkedSession: async (link, expectedProjectId) => { + const normalized = normalizeWorkBoardLinkedSession(link); + if (!normalized.ok) return false; + const session = sessions.find((entry) => entry.id === normalized.value.sessionId); + if (!session) return false; + return ( + session.workspace.target.kind === 'project' && + session.workspace.target.projectId === expectedProjectId + ); + }, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + { + scope: { kind: 'project', projectId: 'p1' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + }, + ); + assert.ok(created.ok); + // The same-Host Session from project p2 must not be linked to the p1 + // board item. + const linked = await ipc.invoke>( + 'workBoard:linkSession', + created.ok ? created.value.id : '', + { profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p2', linkedAt: 1 }, + ); + assert.equal(linked.ok, false); + if (!linked.ok) assert.equal(linked.code, 'invalid_input'); + + // The p1 Session links cleanly. + const linkedOk = await ipc.invoke>( + 'workBoard:linkSession', + created.ok ? created.value.id : '', + { profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p1', linkedAt: 1 }, + ); + assert.equal(linkedOk.ok, true); + } finally { + registration.close(); + } + }); + }); + + test('fails closed when the item changes during Host validation (revision CAS)', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + let itemId: string | undefined; + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + // Simulate a concurrent mutation (e.g. the item being moved to another + // project) racing the async Host validation: the revision read before + // validation must no longer match when linkSession commits. + validateLinkedSession: async () => { + await ipc.invoke('workBoard:update', itemId, { title: 'raced move' }); + return true; + }, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + { + scope: { kind: 'project', projectId: 'p1' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + }, + ); + assert.ok(created.ok); + itemId = created.ok ? created.value.id : undefined; + const linked = await ipc.invoke>( + 'workBoard:linkSession', + itemId, + { profileId: 'profile-1', hostId: 'host-1', sessionId: 's-p1', linkedAt: 1 }, + ); + assert.equal(linked.ok, false); + if (!linked.ok) assert.equal(linked.code, 'operation_conflict'); } 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..ba0c23f42d 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -23,7 +23,8 @@ 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 type { WorkBoardActiveItem, WorkBoardItem, WorkBoardLinkedSession } from '@maka/core/work-board'; +import { LocaleProvider, type ToastApi } from '@maka/ui'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { createFakeWorkbarServices, @@ -33,6 +34,14 @@ import { type WorkbarController, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; +import { + createFakeTaskEntryServices, + TaskEntryServicesProvider, + useTaskEntryController, + type TaskEntryController, + type TaskEntryHost, + type TaskEntryServices, +} from '../../renderer/features/task-entry/testing.js'; function session(id: string): SessionSummary { return { @@ -61,6 +70,7 @@ function shellUpdate(sessionId: string, ref: string): ShellRunUpdate { } as ShellRunUpdate; } let latestController: WorkbarController | undefined; +let latestTaskEntryController: TaskEntryController | undefined; let controllerRenderSnapshots: Array<{ activeId: string | undefined; terminalOwnerIds: Array; @@ -109,9 +119,29 @@ function controller(): WorkbarController { return latestController; } +function taskEntryController(): TaskEntryController { + assert.ok(latestTaskEntryController); + return latestTaskEntryController; +} + +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,13 +151,143 @@ function input( authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), shellObscured: false, modelChoices: [], - reportError: (title, description) => errors.push(`${title}: ${description}`), + toastApi, }; } +function workBoardItem(id: string): WorkBoardActiveItem { + return { + schemaVersion: 1, + id, + revision: 1, + scope: { kind: 'project', projectId: `project-${id}` }, + title: id, + state: 'todo', + archived: false, + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + createdAt: 1, + updatedAt: 1, + }; +} + +function workBoardDraftKey(target: { + profileId: string; + hostId: string; + projectId: string; +}): string { + return `draft:${target.profileId}:${target.hostId}:${target.projectId}`; +} + +function workBoardItemDraftKey(itemId: string): string { + return workBoardDraftKey({ + profileId: 'profile-1', + hostId: 'host-1', + projectId: `project-${itemId}`, + }); +} + +function workBoardInput( + activeSession: SessionSummary | undefined, + toastApi: ToastApi = createFakeToastApi(), + overrides: Partial = {}, + ownerRef: { current: number } = { current: 0 }, +): UseWorkbarControllerInput { + return { + ...input(activeSession, toastApi), + resolveWorkBoardTarget: (item) => ({ + ok: true as const, + target: { + profileId: 'profile-1', + hostId: 'host-1', + projectId: `project-${item.id}`, + }, + }), + prepareWorkBoardDraft: (target, draft) => workBoardDraftKey(target), + openNewTaskSurface: () => { + ownerRef.current += 1; + return ownerRef.current; + }, + composerRef: { current: { setDraft: () => undefined, focus: () => undefined } }, + ...overrides, + }; +} + +function taskEntryProject(id: string) { + return { + id, + name: id, + locations: [{ path: `/tmp/${id}`, isWorktree: false }], + available: true, + preferredPath: `/tmp/${id}`, + }; +} + +function taskEntryHost(): Extract { + return { + profile: { id: 'profile-1', name: 'Local', kind: 'local' }, + hostId: 'host-1', + readiness: 'ready', + state: 'available', + projects: [taskEntryProject('project-A'), taskEntryProject('project-B')], + capabilities: { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: false, + }, + selectedProjectId: 'project-A', + chatDefaults: { permissionMode: 'ask', thinkingLevel: 'high' }, + }; +} + +function WorkBoardCompositionProbe(props: { ownerRef: { current: number } }) { + const taskEntry = useTaskEntryController({ + reportError() {}, + manageProjects() {}, + }); + latestTaskEntryController = taskEntry; + latestController = useWorkbarController(workBoardInput( + session('active'), + createFakeToastApi(), + { + resolveWorkBoardTarget: taskEntry.commands.resolveWorkBoardTarget, + prepareWorkBoardDraft: taskEntry.commands.prepareWorkBoardDraft, + openNewTaskSurface: () => { + props.ownerRef.current += 1; + return props.ownerRef.current; + }, + }, + props.ownerRef, + )); + return null; +} + +function renderWorkBoardComposition( + root: ReturnType['root'], + taskEntryServices: TaskEntryServices, + workbarServices: WorkbarServices, + ownerRef: { current: number }, +) { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement( + TaskEntryServicesProvider, + { services: taskEntryServices }, + createElement( + WorkbarServicesProvider, + { services: workbarServices }, + createElement(WorkBoardCompositionProbe, { ownerRef }), + ), + ), + }), + ); +} + describe('useWorkbarController', () => { afterEach(() => { latestController = undefined; + latestTaskEntryController = undefined; controllerRenderSnapshots = []; cleanupFakeDom(); delete (globalThis as { window?: unknown }).window; @@ -441,18 +601,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, []); @@ -551,4 +711,264 @@ describe('useWorkbarController', () => { assert.equal(disposals, 1); assert.deepEqual(activeSessions, ['a', 'b']); }); + + it('links a Session produced on the surface that owns the claim', async () => { + const { root } = installReactRenderer(); + const links: Array<{ id: string; sessionId: string }> = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + workBoard: { + linkSession: async (id, link) => { + links.push({ id, sessionId: link.sessionId }); + return { ok: true, value: workBoardItem(id) }; + }, + }, + }); + const ownerRef = { current: 0 }; + const opened: number[] = []; + const controllerInput = workBoardInput(session('a'), createFakeToastApi(), { + openNewTaskSurface: () => { + ownerRef.current += 1; + opened.push(1); + return ownerRef.current; + }, + }, ownerRef); + + await act(async () => renderController(root, services, controllerInput)); + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + assert.equal(opened.length, 1); + assert.equal(ownerRef.current, 1); + + // The synchronous owner handoff links without an intervening render. + await act(async () => + controller().commands.bindNewTaskSessionResolver(ownerRef.current)( + JSON.stringify(['host-1', 'session-1']), + workBoardItemDraftKey('A'), + ), + ); + assert.deepEqual(links, [{ id: 'A', sessionId: 'session-1' }]); + }); + + it('does not let a New Task reopened on the same Host/project consume the claim', async () => { + // Regression for the review: the draft key is derived only from + // (profileId, hostId, projectId), so two surfaces on the same target share + // a draft key. The claim must be bound to the surface owner token instead. + const { root } = installReactRenderer(); + const links: Array<{ id: string; sessionId: string }> = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + workBoard: { + linkSession: async (id, link) => { + links.push({ id, sessionId: link.sessionId }); + return { ok: true, value: workBoardItem(id) }; + }, + }, + }); + const ownerRef = { current: 0 }; + const opened: number[] = []; + const controllerInput = workBoardInput(session('a'), createFakeToastApi(), { + openNewTaskSurface: () => { + ownerRef.current += 1; + opened.push(1); + return ownerRef.current; + }, + }, ownerRef); + + await act(async () => renderController(root, services, controllerInput)); + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + assert.equal(ownerRef.current, 1); + + // The user abandons that surface and opens a fresh New Task on the SAME + // Host/project (a new owner token, identical draft key). + ownerRef.current += 1; + // A first send there must not consume the claim or link item A. + await act(async () => + controller().commands.bindNewTaskSessionResolver(ownerRef.current)( + 'session-B', + workBoardItemDraftKey('A'), + ), + ); + assert.deepEqual(links, []); + + // The mismatched send abandoned the claim, so a later send on the + // original surface must not resurrect it either. + await act(async () => + controller().commands.bindNewTaskSessionResolver(1)( + 'session-A', + workBoardItemDraftKey('A'), + ), + ); + assert.deepEqual(links, []); + }); + + it('does not link a first send from a different project surface', async () => { + const { root } = installReactRenderer(); + const links: Array<{ id: string; sessionId: string }> = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + workBoard: { + linkSession: async (id, link) => { + links.push({ id, sessionId: link.sessionId }); + return { ok: true, value: workBoardItem(id) }; + }, + }, + }); + const ownerRef = { current: 0 }; + const controllerInput = workBoardInput(session('a'), createFakeToastApi(), { + openNewTaskSurface: () => { + ownerRef.current += 1; + return ownerRef.current; + }, + }, ownerRef); + + await act(async () => renderController(root, services, controllerInput)); + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + // A different New Task surface (project B) is opened and sends first. + ownerRef.current += 1; + await act(async () => + controller().commands.bindNewTaskSessionResolver(ownerRef.current)( + 'session-B', + workBoardItemDraftKey('B'), + ), + ); + assert.deepEqual(links, []); + }); + + it('drops a claim when Task Entry changes project within the same surface', async () => { + const { root } = installReactRenderer(); + const ownerRef = { current: 0 }; + const links: Array<{ id: string; sessionId: string }> = []; + const workbarServices = createFakeWorkbarServices({ + workBoard: { + linkSession: async (id, link) => { + links.push({ id, sessionId: link.sessionId }); + return { ok: true, value: workBoardItem(id) }; + }, + }, + }); + const taskEntryServices = createFakeTaskEntryServices({ + catalog: { + ...createFakeTaskEntryServices().catalog, + getCatalog: async () => ({ + defaultProfileId: 'profile-1', + hosts: [taskEntryHost()], + }), + }, + }); + + await act(async () => + renderWorkBoardComposition(root, taskEntryServices, workbarServices, ownerRef), + ); + assert.equal(taskEntryController().selectors.target?.projectId, 'project-A'); + + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + const surfaceOwnerToken = ownerRef.current; + assert.equal(surfaceOwnerToken, 1); + + await act(async () => + taskEntryController().selectors.workspacePicker.groups[0]?.onSelectProject?.( + 'project-B', + ), + ); + assert.equal(taskEntryController().selectors.target?.projectId, 'project-B'); + const projectBDraftKey = taskEntryController().selectors.draftKey; + + await act(async () => + controller().commands.bindNewTaskSessionResolver(surfaceOwnerToken)( + 'session-B', + projectBDraftKey, + ), + ); + assert.deepEqual(links, []); + + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + assert.equal(ownerRef.current, 2); + assert.equal(taskEntryController().selectors.target?.projectId, 'project-A'); + }); + + it('retries a failed link against the same Session instead of creating a duplicate', async () => { + const { root } = installReactRenderer(); + let attempts = 0; + const linkCalls: Array<{ id: string; sessionId: string }> = []; + const errors: string[] = []; + const defaults = createFakeWorkbarServices(); + const services = createFakeWorkbarServices({ + workBoard: { + linkSession: async (id, link) => { + attempts += 1; + linkCalls.push({ id, sessionId: link.sessionId }); + if (attempts === 1) return { ok: false, message: 'transient SQLite busy' }; + return { ok: true, value: workBoardItem(id) }; + }, + }, + }); + const ownerRef = { current: 0 }; + const opened: number[] = []; + const controllerInput = workBoardInput(session('a'), createFakeToastApi(errors), { + openNewTaskSurface: () => { + ownerRef.current += 1; + opened.push(1); + return ownerRef.current; + }, + }, ownerRef); + + await act(async () => renderController(root, services, controllerInput)); + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + await act(async () => + controller().commands.bindNewTaskSessionResolver(ownerRef.current)( + JSON.stringify(['host-1', 'session-1']), + workBoardItemDraftKey('A'), + ), + ); + // First attempt fails; the claim (with its Session id) must be retained. + assert.equal(linkCalls.length, 1); + assert.ok(errors.some((message) => message.includes('SQLite'))); + + // Retry by pressing Start on the same item: reuse the same Session and do + // not open a new surface (which would create a duplicate Session). + await act(async () => + controller().host.onStartWorkBoardTask?.(workBoardItem('A')), + ); + assert.equal(linkCalls.length, 2); + assert.equal(opened.length, 1); + assert.deepEqual( + linkCalls.map((call) => call.sessionId), + ['session-1', 'session-1'], + ); + }); + + it('opens a previously linked Session from a board item', async () => { + const { root } = installReactRenderer(); + const opened: string[] = []; + const controllerInput = workBoardInput(session('a'), createFakeToastApi(), { + openSessionInChat: (key) => opened.push(key), + }); + + await act(async () => + renderController(root, createFakeWorkbarServices(), controllerInput), + ); + const link: WorkBoardLinkedSession = { + profileId: 'profile-1', + hostId: 'host-1', + sessionId: 'session-1', + linkedAt: 1, + }; + await act(async () => + controller().host.onOpenWorkBoardSession?.(link), + ); + + assert.deepEqual(opened, [JSON.stringify(['host-1', 'session-1'])]); + }); }); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 608ebfd96c..cd96bda48f 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,30 @@ const workBoardIpc = registerWorkBoardIpc({ workspaceRoot, mainWindowController, store: workBoardStore, + validateLinkedSession: async (value, expectedProjectId) => { + 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(); + const session = sessions.find((s) => s.id === normalized.value.sessionId); + if (!session) return false; + // A Session existing on the Host is not enough: the link must point at + // the same project as the board item, or a Session from project B could + // be attached to an item belonging to project A. The board project is + // passed from the main-process mutation boundary. + if (expectedProjectId !== undefined) { + return ( + session.workspace.target.kind === 'project' && + session.workspace.target.projectId === expectedProjectId + ); + } + return true; + } 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..e3d077dbff 100644 --- a/apps/desktop/src/main/work-board-ipc-main.ts +++ b/apps/desktop/src/main/work-board-ipc-main.ts @@ -47,6 +47,14 @@ export function registerWorkBoardIpc(input: { readonly workspaceRoot: string; readonly mainWindowController: MainWindowController; readonly store?: WorkBoardStore; + /** + * Proves that a linked Session belongs to the live Host target and, when + * the board item is project-scoped, to that item's project. + */ + readonly validateLinkedSession: ( + link: unknown, + expectedProjectId?: string, + ) => Promise; readonly now?: () => number; }): WorkBoardIpcRegistration { const store = input.store ?? createWorkBoardStore(input.workspaceRoot); @@ -153,6 +161,41 @@ export function registerWorkBoardIpc(input: { }, ); + input.ipcMain.handle( + 'workBoard:linkSession', + async (_event, id: unknown, link: unknown, _options?: unknown): Promise> => { + try { + 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', + ); + } + if (!(await input.validateLinkedSession(link, item.scope.projectId))) { + throw new WorkBoardStoreError( + 'invalid_input', + 'Work Board linked Session does not belong to an available Runtime Host project', + ); + } + // CAS on the revision read above: the async Host validation must not + // race a concurrent mutation (e.g. the item being moved to another + // project), or a Session validated for project A could be written into + // the now-B item. The store enforces this inside its write transaction. + const linked = await store.linkSession( + itemId, + link, + { expectedRevision: item.revision } 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..fda8959a5d 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -116,7 +116,7 @@ type MessageContextOptions = { type SendOptions = MessageContextOptions & { turnOrchestration?: TurnOrchestration; displayText?: string; - onSessionResolved?: (sessionId: string) => void; + onSessionResolved?: (sessionId: string, newTaskDraftKey?: string) => void; }; function copiedArray( @@ -427,12 +427,11 @@ export function createAppShellChatActions(deps: { const initialSessionId = activeIdRef.current; const initialNewTaskTarget = initialSessionId ? undefined : newTaskTarget; const sendOwner = captureComposerImportOwner(); - const newChatOwner = initialSessionId ? null : sendOwner; if (!initialSessionId && !initialNewTaskTarget) return false; if (!(await checkTaskSubmissionReadiness())) return false; if ( (initialSessionId && !isShellSurfaceOwnerActive(sendOwner)) || - (newChatOwner && !isNewChatSendSurfaceActive(newChatOwner)) + (!initialSessionId && !isNewChatSendSurfaceActive(sendOwner)) ) { return false; } @@ -552,7 +551,8 @@ export function createAppShellChatActions(deps: { unsentSessionId = undefined; // The callback fires 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, sendOwner.newTaskDraftKey); await refreshSessions(); return true; } @@ -607,7 +607,7 @@ export function createAppShellChatActions(deps: { ...sendOwner, sessionId: feedbackSessionId, })) || - (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner)); + (!initialSessionId && isNewChatSendSurfaceActive(sendOwner)); await discardUnsentSession(); 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..5ee107002a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -339,6 +339,7 @@ function AppShellContent({ bootstrapSelectionLease, setActiveId, startNewSession, + readSelectionRevision, clearOwnedSessionState, messages, transientMessages, @@ -395,7 +396,8 @@ function AppShellContent({ // Named on its own because the rail depends on it: `taskEntry.commands` is a // fresh object every render, so depending on the bag rather than the command // would rebuild the rail's Project rows on every AppShell commit (#4109). - const { selectLocalProject } = taskEntry.commands; + const { selectLocalProject, resolveWorkBoardTarget, prepareWorkBoardDraft } = + taskEntry.commands; const currentNewTaskDraftKey = taskEntry.selectors.draftKey; // Staged files and quotes do NOT take the target-scoped key: they belong to // the composer the user is looking at, and an in-flight send needs an owner @@ -1499,7 +1501,7 @@ function AppShellContent({ }); const openNewTaskSurface = useCallback(() => { imageNoticeLifecycle.reset(NEW_TASK_PENDING_KEY); - startNewSession(); + const ownerToken = startNewSession(); // Only Plan resets: a new task starts out of Plan, in whatever // orchestration the last one was set to. setNewChatPlanModeActive(false); @@ -1508,6 +1510,7 @@ function AppShellContent({ // New-task affordances reset to the empty-state composer; move focus // there so the user can start typing immediately. window.requestAnimationFrame(() => composerRef.current?.focus()); + return ownerToken; }, [imageNoticeLifecycle, setNavSelection, setSearchScrollTarget, startNewSession]); const createSession = useCallback(async () => { @@ -1593,11 +1596,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({ @@ -1608,7 +1606,12 @@ function AppShellContent({ authoritativeSessionIds: authoritativeSessionIds ?? undefined, shellObscured, modelChoices: chatModelChoices, - reportError: reportWorkbarError, + toastApi, + composerRef, + openNewTaskSurface, + openSessionInChat, + resolveWorkBoardTarget, + prepareWorkBoardDraft, }); const exitWorkHub = useCallback(() => setWorkHubActive(false), []); @@ -2106,6 +2109,7 @@ function AppShellContent({ : undefined; const quotes = pendingQuotes.length ? pendingQuotes : undefined; const ok = await send(text, pending, { + onSessionResolved: workbar.commands.bindNewTaskSessionResolver(readSelectionRevision()), ...directoryOptions, ...(quotes ? { quotes } : {}), ...(workspaceFileReferences.length @@ -3204,8 +3208,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 59662743ab..66100ff896 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,12 +30,14 @@ 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 { 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'; @@ -79,6 +81,16 @@ export interface WorkbarControllerCommands { respondToClientCapability(response: ClientCapabilityResponse): Promise; respondToUserForm(sessionId: string, response: InteractionFormResponse): Promise; toggleRight(): void; + /** + * Accepts the Session produced by a projected first send that belongs to the + * pending Work Board start claim. The claim is owned by one specific + * new-task surface instance (its owner token), so a first send from any + * other surface—even one reopened on the same Host/project—must not consume + * it. + */ + bindNewTaskSessionResolver( + surfaceOwnerToken: number, + ): (sessionId: string, draftKey?: string) => void; } export interface WorkbarControllerSelectors { @@ -95,7 +107,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?(): number; + 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 { @@ -157,8 +177,19 @@ export function useWorkbarController( input: UseWorkbarControllerInput, ): WorkbarController { const locale = useUiLocale(); + // Enforce development-only: the experimental Start-task path must never be + // reachable in a production build even if the flag is set, so the gate + // requires `DEV` as well as the feature flag. + const viteEnv = ( + import.meta as unknown as { + env?: Record; + } + ).env; + const workBoardStartTaskEnabled = + viteEnv?.DEV === true && + viteEnv?.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< @@ -176,6 +207,19 @@ export function useWorkbarController( const activeSessionId = input.activeSession?.id; const activeSessionIdRef = useRef(undefined); + /** + * The in-flight Work Board start claim. The surface token and target-scoped + * draft key jointly own it; `sessionId` is filled once the first send from + * that owner is projected, and is retained across a failed link so a retry + * can reuse the same Session instead of creating a duplicate. + */ + const pendingWorkBoardStartRef = useRef<{ + itemId: string; + target: { profileId: string; hostId: string; projectId: string }; + surfaceOwnerToken: number; + draftKey: string; + sessionId?: string; + } | undefined>(undefined); const resourceGenerationRef = useRef(0); useLayoutEffect(() => { resourceGenerationRef.current += 1; @@ -185,6 +229,159 @@ export function useWorkbarController( activeSessionIdRef.current = undefined; }; }, [activeSessionId]); + + const linkPendingWorkBoardSession = useCallback( + (pending: NonNullable): Promise => { + if (!workBoard) { + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Work Board linking is unavailable in this desktop session.', + ); + return Promise.resolve(false); + } + if (pending.sessionId === undefined) return Promise.resolve(false); + return workBoard + .linkSession(pending.itemId, { + profileId: pending.target.profileId, + hostId: pending.target.hostId, + sessionId: pending.sessionId, + linkedAt: Date.now(), + }) + .then((result) => { + if (result.ok) return true; + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + result.message, + ); + return false; + }) + .catch((error) => { + input.toastApi.error( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + error instanceof Error ? error.message : String(error), + ); + return false; + }); + }, + [input, locale, workBoard], + ); + + /** + * Link the claim's Session and only drop the claim once the link succeeds. + * On failure the claim (with its `sessionId`) is retained so the next Start + * task invocation retries the same Session rather than creating a new one. + */ + const settlePendingWorkBoardLink = useCallback( + (pending: NonNullable): void => { + void linkPendingWorkBoardSession(pending).then((ok) => { + if (ok && pendingWorkBoardStartRef.current === pending) { + pendingWorkBoardStartRef.current = undefined; + } + }); + }, + [linkPendingWorkBoardSession], + ); + + const startWorkBoardTask = useCallback( + (item: WorkBoardItem) => { + const pending = pendingWorkBoardStartRef.current; + if (pending) { + if (pending.itemId === item.id && pending.sessionId !== undefined) { + // A previous link attempt failed for this same item: retry the + // link against the already-created Session instead of opening a + // new surface and creating a duplicate. + settlePendingWorkBoardLink(pending); + return; + } + // A claim without a Session was abandoned (its surface was reopened + // or never projected a first send), so a fresh start replaces it. + pendingWorkBoardStartRef.current = undefined; + } + 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; + } + const surfaceOwnerToken = input.openNewTaskSurface?.(); + if (surfaceOwnerToken === undefined) { + input.toastApi.info( + getDesktopConversationCopy(locale).workBoardPanel.actionFailed, + 'Work Board task start is unavailable.', + ); + return; + } + pendingWorkBoardStartRef.current = { + itemId: item.id, + target: result.target, + surfaceOwnerToken, + draftKey, + }; + globalThis.requestAnimationFrame(() => { + input.composerRef?.current?.setDraft(draftKey, draft); + input.composerRef?.current?.focus(); + }); + }, + [input, locale, settlePendingWorkBoardLink], + ); + + const openWorkBoardSession = useCallback( + (link: WorkBoardLinkedSession) => { + input.openSessionInChat?.( + desktopSessionKey({ hostId: link.hostId, sessionId: link.sessionId }), + ); + }, + [input.openSessionInChat], + ); + + const onNewTaskSessionResolved = useCallback( + (sessionId: string, surfaceOwnerToken: number, draftKey: string | undefined) => { + const pending = pendingWorkBoardStartRef.current; + if (!pending) return; + // A surface reopen changes the token; a Workspace Picker change changes + // the draft key. Neither may attach its Session to the old claim. + if ( + surfaceOwnerToken !== pending.surfaceOwnerToken || + draftKey !== pending.draftKey + ) { + pendingWorkBoardStartRef.current = undefined; + return; + } + const linkedSessionId = (() => { + try { + return parseDesktopSessionKey(sessionId).sessionId; + } catch { + return sessionId; + } + })(); + if (pending.sessionId === undefined) { + pending.sessionId = linkedSessionId; + } + settlePendingWorkBoardLink(pending); + }, + [settlePendingWorkBoardLink], + ); + const bindNewTaskSessionResolver = useCallback( + (surfaceOwnerToken: number) => + (sessionId: string, draftKey?: string) => + onNewTaskSessionResolved(sessionId, surfaceOwnerToken, draftKey), + [onNewTaskSessionResolved], + ); const respondToClientCapability = useCallback< WorkbarControllerCommands['respondToClientCapability'] >( @@ -196,14 +393,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(() => { @@ -365,14 +563,15 @@ export function useWorkbarController( ) { return; } - input.reportError( + input.toastApi.error( terminalCopy.startFailed, localizedShellErrorMessage( error, terminalCopy.startFailed, locale, ), - ownerSessionId, + undefined, + { sessionId: ownerSessionId }, ); }); return; @@ -382,9 +581,9 @@ export function useWorkbarController( } }, [ - input.reportError, layout.openDynamicWorkbarTab, layout.openWorkbarTab, + input, locale, openNewSideConversation, registerTerminal, @@ -663,8 +862,10 @@ export function useWorkbarController( respondToClientCapability, respondToUserForm: sideChat.respondToUserForm, toggleRight, + bindNewTaskSessionResolver, }), [ + bindNewTaskSessionResolver, openSideChatWithQuote, openTool, respondToClientCapability, @@ -751,6 +952,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 f95e7462ec..50ec82511f 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, @@ -201,6 +202,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 } @@ -291,5 +302,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 9e6ddbf21f..a4fd9c8b0c 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/session-workspace-actions.ts b/apps/desktop/src/renderer/session-workspace-actions.ts index 503919e2d5..f60b098dee 100644 --- a/apps/desktop/src/renderer/session-workspace-actions.ts +++ b/apps/desktop/src/renderer/session-workspace-actions.ts @@ -54,7 +54,8 @@ export type MessageListUpdater = ( export interface SessionWorkspaceActions { setActiveId(next: string | undefined): void; - startNewSession(): void; + startNewSession(): number; + readSelectionRevision(): number; clearOwnedSessionState(sessionId: string): void; setMessages: MessageListUpdater; addTransientMessage(sessionId: string, message: TransientUserMessage): void; @@ -209,12 +210,17 @@ export function createSessionWorkspaceActions(deps: { setActiveIdState(next); } - function startNewSession(): void { + function startNewSession(): number { markNewTaskReloadIntent(); setActiveId(undefined); messagesRef.current = []; setMessagesState([]); setTransientMessagesState([]); + return selectionRevisionRef.current; + } + + function readSelectionRevision(): number { + return selectionRevisionRef.current; } function clearOwnedSessionState(sessionId: string): void { @@ -226,6 +232,7 @@ export function createSessionWorkspaceActions(deps: { return { setActiveId, startNewSession, + readSelectionRevision, clearOwnedSessionState, setMessages, addTransientMessage, 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..fe7da2d54f 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: {