From 8b47d1d2b3278d0ec569dc5f1a414c91cb3ea927 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Mon, 7 Sep 2026 22:38:31 +0800 Subject: [PATCH 1/6] feat(workhub): align conversation identity with unified experiment Generated-by: Codex --- .../e2e/workhub-reconstruction.spec.ts | 13 +++ .../__tests__/workhub-surface-flow.test.ts | 3 + apps/desktop/src/renderer/styles/workhub.css | 92 ++++++++++++++++++- apps/desktop/src/renderer/workhub-surface.tsx | 73 ++++++++++++++- apps/desktop/stories/workhub.stories.tsx | 24 +++++ 5 files changed, 197 insertions(+), 8 deletions(-) diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 397ca975e3..3acf19c471 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -58,6 +58,14 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba await workHubComposer.press('Enter'); const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); await expect(routedTurn.locator('.workhub-submitted')).toBeVisible(); + await expect(routedTurn.locator('.workhub-message-identity')).toContainText(sessionName!); + const workIdentity = await routedTurn.getAttribute('data-work-session-id'); + expect(workIdentity).toBeTruthy(); + const identityColor = await routedTurn.evaluate((element) => + getComputedStyle(element).getPropertyValue('--workhub-work-color'), + ); + expect(identityColor).toContain('oklch'); + await routedTurn.locator('.workhub-submitted > button').click(); await expect(page.getByRole('region', { name: 'WorkHub' })).toBeHidden(); @@ -74,6 +82,11 @@ test('WorkHub rebuilds delegated execution feedback after navigating away and ba page.locator('.workhub-projected-turn', { hasText: routedPrompt }) .locator('.workhub-submitted-state'), ).toHaveText('关联有效 · 已完成'); + await expect(routedTurn).toHaveAttribute('data-work-session-id', workIdentity!); + expect(await routedTurn.evaluate((element) => + getComputedStyle(element).getPropertyValue('--workhub-work-color'), + )).toBe(identityColor); + }); test('WorkHub replaces the exact linked delegation across Sessions', async ({ diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts index 772fb55f39..fceaff86c7 100644 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts @@ -154,6 +154,9 @@ test('durable delegation renders every projected target state as a navigable res ); assert.match(markup, / + + ) : null} + {rail}

{props.text}

@@ -835,6 +901,7 @@ function WorkHubMessageFrame(props: { width="100%" className="maka-chat-message-bubble maka-chat-message-bubble-assistant workhub-assistant-bubble" > + {rail} {props.children} diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index c447e788cb..152da3cb28 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -275,3 +275,27 @@ export const ConversationPromptAnchors: Story = { export const ConversationPromptAnchorsNarrow: Story = { ...ConversationPromptAnchors, }; + +// Repeated messages for one work must highlight together, independently of +// intervening work. This is the same durable assignment seam as production. +export const WorkIdentityAcrossTurns: Story = { + render: () => { + const first = submittedTurn(); + const other: WorkHubCoordinationTurn = { + ...first, messageId: 'other-message', turnId: 'other-turn', + text: '检查另一个工作的界面布局。', + assignment: { ...first.assignment!, targetSessionId: 'other-work', targetSessionName: '界面布局' }, + }; + const followup = { ...first, messageId: 'followup-message', turnId: 'followup-turn', text: '继续支付回调,检查失败重试。' }; + return ; + }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.querySelectorAll('.workhub-bound-turn')).toHaveLength(3)); + const turns = Array.from(canvasElement.querySelectorAll('.workhub-bound-turn')); + const rail = turns[0]!.querySelector('.workhub-work-rail')!; + await userEvent.hover(rail); + await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['true', 'false', 'true'])); + await userEvent.unhover(rail); + await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['false', 'false', 'false'])); + }, +}; From ef5da919d0cddec12744c8d5ceb0fdb9c1ea515a Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Mon, 7 Sep 2026 22:51:42 +0800 Subject: [PATCH 2/6] feat(workhub): link navigation and prompt anchors by work identity Generated-by: Codex --- .../src/renderer/features/workhub/index.ts | 2 + .../workhub/ui/workhub-navigation-rail.tsx | 15 ++++++- .../workhub/ui/workhub-prompt-rail.tsx | 17 +++++++- .../workhub/ui/workhub-work-identity.tsx | 42 +++++++++++++++++++ apps/desktop/src/renderer/styles/workhub.css | 41 ++++++++++++++++-- apps/desktop/src/renderer/workhub-surface.tsx | 25 ++++------- apps/desktop/stories/workhub.stories.tsx | 28 +++++++++++++ packages/ui/src/prompt-anchor-rail.tsx | 15 +++++-- 8 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index 10f1cf43e5..e7cc03499a 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -23,3 +23,5 @@ export * from './model/routing-strategy.js'; export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js'; export { WorkHubPromptRail } from './ui/workhub-prompt-rail.js'; + +export { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue } from './ui/workhub-work-identity.js'; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx index 857bb5fe14..9a20668f76 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useState } from 'react'; +import { useContext, useState, type CSSProperties } from 'react'; import type { WorkHubRailCopy } from '../../../locales/workhub-copy.js'; import type { UiLocale } from '@maka/core/ui-locale'; import { Button, dotForStatus, presentSessionStatus } from '@maka/ui'; @@ -29,6 +29,8 @@ import { type WorkHubWorkFilter, } from '../model/anchor-rail.js'; +import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js'; + export function WorkHubNavigationRail(props: { readonly locale: UiLocale; readonly sessions: readonly WorkHubAnchorSession[]; @@ -37,6 +39,7 @@ export function WorkHubNavigationRail(props: { readonly copy: WorkHubRailCopy; readonly onOpenSession: (sessionId: string) => void; }) { + const highlight = useContext(WorkHubHighlightContext); const [filter, setFilter] = useState('all'); const anchors = deriveWorkHubAnchors({ sessions: props.sessions, @@ -79,7 +82,15 @@ export function WorkHubNavigationRail(props: { return ( highlight.highlight(anchor.target.sessionId)} + onMouseLeave={() => highlight.highlight(undefined)} + onFocus={() => highlight.highlight(anchor.target.sessionId)} + onBlur={() => highlight.highlight(undefined)} + label={{anchor.sessionName}} description={`${anchor.target.sessionId === props.focusSessionId ? props.copy.focused : anchor.projectName} · ${state}`} startContent={variant ? : undefined} isSelected={anchor.target.sessionId === props.focusSessionId} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx index fb466370da..23f49b02ba 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx @@ -17,12 +17,25 @@ * under the License. */ +import { useContext } from 'react'; +import { WorkHubHighlightContext, workHubIdentityHue } from './workhub-work-identity.js'; import { useChatLayoutContext } from '@astryxdesign/core/Chat'; import { PromptAnchorRail, type PromptAnchorRailTurn } from '@maka/ui'; /** Uses the enclosing chat layout's scroller, never the Session navigation. */ -export function WorkHubPromptRail({ turns }: { turns: readonly PromptAnchorRailTurn[] }) { +export function WorkHubPromptRail({ turns }: { turns: readonly (PromptAnchorRailTurn & { sessionId?: string })[] }) { + const highlight = useContext(WorkHubHighlightContext); const layout = useChatLayoutContext(); if (!layout) throw new Error('WorkHubPromptRail requires ChatSurfaceLayout'); - return ; + return ({ + ...turn, + accentColor: turn.sessionId + ? `oklch(var(--workhub-${highlight.sessionId === turn.sessionId ? 'highlight' : 'tone'}) ${workHubIdentityHue(turn.sessionId)})` + : undefined, + highlighted: Boolean(turn.sessionId && highlight.sessionId === turn.sessionId), + }))} + onHighlightTurn={(turn) => highlight.highlight(turns.find((candidate) => candidate.turnId === turn?.turnId)?.sessionId)} + scrollRef={layout.scrollContainerRef} + />; } diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx new file mode 100644 index 0000000000..bc66f69034 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx @@ -0,0 +1,42 @@ +/* + * 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 { createContext, useState, type ReactNode } from 'react'; + +export const WorkHubHighlightContext = createContext<{ + sessionId: string | undefined; + highlight(sessionId: string | undefined): void; +}>({ sessionId: undefined, highlight: () => {} }); + +/** Stable across refreshes and reordering; color supplements the visible work name. */ +export function workHubIdentityHue(sessionId: string): number { + let hash = 0; + for (const char of sessionId) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) >>> 0; + const hues = [250, 165, 65, 315, 205, 25]; + return hues[hash % hues.length]!; +} + + +/** Work identity hover is local presentation state shared by the three rails. */ +export function WorkHubHighlightProvider({ children }: { children: ReactNode }) { + const [sessionId, highlight] = useState(); + return + {children} + ; +} diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index b02aa91874..1dd71cc104 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -27,7 +27,7 @@ gap: 0; padding: 0; overflow-x: hidden; - overflow-y: hidden; + overflow-y: auto; } .workhub-timeline { @@ -377,13 +377,15 @@ padding-inline-start: var(--space-3, 12px); } -.workhub-bound-turn { +.workhub-bound-turn, +.workhub-work-identity { --workhub-work-color: oklch(0.58 0.1 var(--workhub-work-hue)); --workhub-work-label: oklch(0.42 0.075 var(--workhub-work-hue)); --workhub-work-highlight: oklch(0.48 0.18 var(--workhub-work-hue)); } -.dark .workhub-bound-turn { +.dark .workhub-bound-turn, +.dark .workhub-work-identity { --workhub-work-color: oklch(0.76 0.105 var(--workhub-work-hue)); --workhub-work-label: oklch(0.8 0.09 var(--workhub-work-hue)); --workhub-work-highlight: oklch(0.85 0.15 var(--workhub-work-hue)); @@ -415,7 +417,8 @@ left: -4px; } -.workhub-bound-turn[data-work-highlighted='true'] { +.workhub-bound-turn[data-work-highlighted='true'], +.workhub-work-identity[data-work-highlighted='true'] { --workhub-work-color: var(--workhub-work-highlight); --workhub-work-label: var(--workhub-work-highlight); } @@ -425,3 +428,33 @@ transition: none; } } + +.workhub-surface { + --workhub-tone: 0.58 0.1; + --workhub-highlight: 0.48 0.18; +} + +.dark .workhub-surface { + --workhub-tone: 0.76 0.105; + --workhub-highlight: 0.85 0.15; +} + +.workhub-navigation-item { + color: var(--workhub-work-label); +} + +.workhub-navigation-item[data-work-highlighted='true'] { + background: color-mix(in oklch, var(--workhub-work-color) 8%, transparent); +} + +.workhub-surface .maka-prompt-rail:has([data-highlighted='true']) { + opacity: 1; +} + +.workhub-navigation-label { + display: block; + overflow: hidden; + color: var(--workhub-work-label); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index f2800423cf..e59be81a31 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { createContext, useContext, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { useContext, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { ChatMessage, ChatMessageBubble, @@ -41,14 +41,9 @@ import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { WorkHubNavigationRail, WorkHubPromptRail } from './features/workhub/index.js'; +import { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue, WorkHubNavigationRail, WorkHubPromptRail } from './features/workhub/index.js'; import { getWorkHubRailCopy } from './locales/workhub-copy.js'; -const WorkHubHighlightContext = createContext<{ - sessionId: string | undefined; - highlight(sessionId: string | undefined): void; -}>({ sessionId: undefined, highlight: () => {} }); - export interface WorkHubConversationTurn { requestId: string; text: string; @@ -381,7 +376,6 @@ export function WorkHubSurface(props: { }, }); }, [conversationReady, initialLoadSettled, route, routeGate, sendLease]); - const [highlightedWork, setHighlightedWork] = useState(); const visible = visibleWorkHubConversation(coordination.turns, turns); const visibleCoordinationTurns = visible.coordination; const visibleLocalTurns = visible.local; @@ -389,7 +383,7 @@ export function WorkHubSurface(props: { const surfaceReady = initialLoadSettled && conversationReady; return ( - + ({ turnId: `workhub-request-${turn.requestId}`, label: turn.text, + sessionId: turn.outcome?.kind === 'submitted' || turn.outcome?.kind === 'stop' || turn.outcome?.kind === 'resume' + ? turn.outcome.target.sessionId : undefined, })), ]} /> - + ); } @@ -834,14 +831,6 @@ function WorkHubTurnView(props: { ); } -/** Stable across refreshes and reordering; color supplements the visible work name. */ -function workHubIdentityHue(sessionId: string): number { - let hash = 0; - for (const char of sessionId) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) >>> 0; - const hues = [250, 165, 65, 315, 205, 25]; - return hues[hash % hues.length]!; -} - function WorkHubMessageFrame(props: { anchorId: string; text: string; diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 152da3cb28..ebf622333d 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -297,5 +297,33 @@ export const WorkIdentityAcrossTurns: Story = { await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['true', 'false', 'true'])); await userEvent.unhover(rail); await waitFor(() => expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['false', 'false', 'false'])); + const ticks = Array.from(canvasElement.querySelectorAll('.maka-prompt-rail-tick')); + expect(ticks).toHaveLength(3); + const navigation = canvasElement.querySelector('.workhub-navigation-item')!; + await userEvent.hover(navigation); + await waitFor(() => { + expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['true', 'false', 'true']); + expect(ticks.map(tick => tick.dataset.highlighted)).toEqual(['true', undefined, 'true']); + expect(getComputedStyle(ticks[0]!).color).toBe(getComputedStyle(ticks[2]!).color); + expect(getComputedStyle(navigation.querySelector('.workhub-navigation-label')!).color) + .toBe(getComputedStyle(ticks[0]!).color); + expect(getComputedStyle(ticks[0]!).color).not.toBe(getComputedStyle(ticks[1]!).color); + }); + await userEvent.unhover(navigation); + await userEvent.hover(ticks[1]!); + await waitFor(() => { + expect(turns.map(turn => turn.dataset.workHighlighted)).toEqual(['false', 'true', 'false']); + expect(navigation.dataset.workHighlighted).toBe('false'); + }); + await userEvent.unhover(ticks[1]!); + ticks[0]!.focus(); + await waitFor(() => expect(navigation.dataset.workHighlighted).toBe('true')); + ticks[0]!.blur(); + await userEvent.hover(rail); + await waitFor(() => { + expect(navigation.dataset.workHighlighted).toBe('true'); + expect(ticks.map(tick => tick.dataset.highlighted)).toEqual(['true', undefined, 'true']); + }); + await userEvent.unhover(rail); }, }; diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index 29e80605cc..b397ad069c 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -212,6 +212,9 @@ export function observeActivePromptRailVisibility( } export interface PromptAnchorRailTurn { + /** Optional host identity color; ordinary Session ticks remain neutral. */ + accentColor?: string; + highlighted?: boolean; turnId: string; label: string; reply?: string; @@ -240,6 +243,8 @@ export function mergePromptAnchorRailTurns( } export interface PromptAnchorRailProps { + /** Presentation-only hover/focus linkage; never navigates the transcript. */ + onHighlightTurn?: (turn: PromptAnchorRailTurn | undefined) => void; turns: readonly PromptAnchorRailTurn[]; scrollRef: RefObject; /** When the indexed Turn is outside the Host's active transcript range. */ @@ -376,7 +381,7 @@ export function selectPromptRailTickForMountedTurn(input: { } /** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */ -export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart }: PromptAnchorRailProps): React.ReactElement | null { +export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; const [activeSelection, setActiveSelection] = useState<{ turnId: string; @@ -743,7 +748,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe className="maka-prompt-rail" aria-label={copy.promptRailAriaLabel} ref={railRef} - onPointerLeave={() => setHoveredIndex(null)} + onPointerLeave={() => { setHoveredIndex(null); onHighlightTurn?.(undefined); }} > {railTurns.map((turn, index) => { const isActive = turn.turnId === activeRailTurnId; @@ -775,12 +780,16 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe label={copy.jumpToPrompt(preview)} className="maka-prompt-rail-tick" data-prompt-turn-id={turn.turnId} + data-highlighted={turn.highlighted || undefined} data-active={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined} onClick={() => jumpTo(turn)} - onPointerEnter={() => setHoveredIndex(index)} + onPointerEnter={() => { setHoveredIndex(index); onHighlightTurn?.(turn); }} + onFocus={() => onHighlightTurn?.(turn)} + onBlur={() => onHighlightTurn?.(undefined)} style={ { + color: turn.accentColor, '--maka-prompt-rail-index': index, '--maka-prompt-rail-scale': scale, } as CSSProperties From 7fda9ba43f359c4e781abea1ed352f2f7714e06f Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 00:03:57 +0800 Subject: [PATCH 3/6] feat(workhub): reuse composer controls and align conversation layout Generated-by: Codex --- apps/desktop/renderer-architecture.json | 42 ++--- .../__tests__/composer-directories.test.ts | 2 +- .../__tests__/new-task-staged-content.test.ts | 2 +- .../runtime-host-workhub-ipc-main.test.ts | 2 + .../main/__tests__/workhub-controller.test.ts | 19 ++ .../main/runtime-host-desktop-candidate.ts | 1 + .../src/main/runtime-host-workhub-ipc-main.ts | 16 ++ apps/desktop/src/preload/bridge-contract.d.ts | 1 + apps/desktop/src/preload/preload.ts | 4 + apps/desktop/src/renderer/app-shell.tsx | 41 ++--- .../src/renderer/composer-attachments.ts | 59 +------ .../composition/desktop-feature-services.tsx | 5 + .../controller/use-composer-attachments.ts | 34 ++++ .../renderer/features/conversation/index.ts | 5 + .../model/slash-command-presentation.ts | 51 ++++++ .../tools/side-chat/quote-companion-panel.tsx | 5 +- .../src/renderer/features/workhub/index.ts | 4 + .../features/workhub/services-context.tsx | 35 ++++ .../features/workhub/ui/workhub-composer.tsx | 162 ++++++++++++++++++ apps/desktop/src/renderer/pending-items.ts | 56 +----- .../create-workhub-composer-services.ts | 28 +++ apps/desktop/src/renderer/styles/workhub.css | 28 ++- .../src/renderer/workhub-controller.ts | 14 +- .../src/renderer/workhub-coordination-port.ts | 2 + apps/desktop/src/renderer/workhub-surface.tsx | 44 +++-- apps/desktop/stories/workhub.stories.tsx | 136 +++++++++++++++ packages/core/src/session.ts | 38 +++- .../workhub-coordination-action-gate.test.ts | 9 + .../workhub-coordination-protocol.test.ts | 12 ++ .../workhub-message-attachments.test.ts | 101 +++++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/workhub-coordination.ts | 20 ++- .../src/server/execution-composition.ts | 15 +- .../workhub-coordination-action-gate.ts | 18 +- .../workhub-coordination-coordinator.ts | 7 +- .../src/server/workhub-message-attachments.ts | 60 +++++++ packages/ui/package.json | 5 +- packages/ui/src/composer-attachments.ts | 77 +++++++++ packages/ui/src/composer.tsx | 6 +- packages/ui/src/pending-items.ts | 74 ++++++++ .../ui/src}/use-composer-attachments.ts | 19 +- 41 files changed, 1044 insertions(+), 219 deletions(-) create mode 100644 apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts create mode 100644 apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts create mode 100644 apps/desktop/src/renderer/features/workhub/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts create mode 100644 packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts create mode 100644 packages/runtime-host/src/server/workhub-message-attachments.ts create mode 100644 packages/ui/src/composer-attachments.ts create mode 100644 packages/ui/src/pending-items.ts rename {apps/desktop/src/renderer => packages/ui/src}/use-composer-attachments.ts (97%) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 51b23b7387..9a6a332a4b 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -223,7 +223,6 @@ "src/renderer/use-app-shell-session-list.ts", "src/renderer/use-app-shell-session-ui-reads.ts", "src/renderer/use-app-shell-session-workspace.ts", - "src/renderer/use-composer-attachments.ts", "src/renderer/use-deep-research-run.ts", "src/renderer/use-delayed-flag.ts", "src/renderer/use-external-store-selector.ts", @@ -281,7 +280,6 @@ "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/composer-mentions", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/scroll-motion-policy", "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/turn-footer-actions", - "src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx -> src/renderer/use-composer-attachments", "src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts -> src/renderer/settled-message-merge", "src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx -> src/renderer/theme", "src/renderer/features/workbar/ui/workbar-surface.tsx -> src/renderer/work-board-panel" @@ -718,7 +716,7 @@ "nonTriviaTokens": 1395 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 79, + "importDeclarations": 77, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -830,6 +828,7 @@ "./error-boundary": 1, "./features/app-update/index.js": 1, "./features/conversation": 1, + "./features/conversation/index.js": 1, "./features/goals": 1, "./features/module-hub": 1, "./features/session-collaboration": 1, @@ -860,7 +859,6 @@ "./use-app-shell-composer-quotes": 1, "./use-app-shell-session-ui-reads": 1, "./use-app-shell-session-workspace": 1, - "./use-composer-attachments": 1, "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, @@ -891,11 +889,10 @@ "@maka/core/slash-command-catalog": 1, "@maka/core/ui-locale": 1, "@maka/ui": 1, - "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 121, - "nonTriviaTokens": 14630 + "importSpecifiers": 116, + "nonTriviaTokens": 14544 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1237,7 +1234,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "./features/conversation/index.js": 1 + } }, "src/renderer/composer-defaults.ts": { "bridgePaths": {}, @@ -2036,7 +2035,9 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": {} + "dependencyPaths": { + "./features/conversation/index.js": 1 + } }, "src/renderer/pending-session-view.ts": { "bridgePaths": {}, @@ -4168,29 +4169,6 @@ "react": 1 } }, - "src/renderer/use-composer-attachments.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 2, - "useRef": 2, - "useState": 2, - "useUiLocale": 1 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./composer-attachments.js": 1, - "./locales/conversation-copy.js": 1, - "./locales/shell-copy.js": 1, - "./pending-items.js": 1, - "@maka/core/attachments": 1, - "@maka/core/events": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/use-deep-research-run.ts": { "bridgePaths": { "window.maka.deepResearch.get": 1, diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts index b82d888e14..f3b093de2c 100644 --- a/apps/desktop/src/main/__tests__/composer-directories.test.ts +++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts @@ -25,7 +25,7 @@ import { normalizeSessionSendCommand } from '../permission-response-guard.js'; import { useComposerAttachments, type ComposerAttachmentService, -} from '../../renderer/use-composer-attachments.js'; +} from '../../renderer/features/conversation/index.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts index b0fa5ee43e..b5e9755d52 100644 --- a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts +++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts @@ -29,7 +29,7 @@ import { getDesktopConversationCopy } from '../../renderer/locales/conversation- import { useComposerAttachments, type ComposerAttachmentService, -} from '../../renderer/use-composer-attachments.js'; +} from '../../renderer/features/conversation/index.js'; import { useAppShellComposerQuotes } from '../../renderer/use-app-shell-composer-quotes.js'; import { composerModelSupportsVision, diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts index 70a84dedf9..33e8313b62 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts @@ -94,6 +94,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' assert.deepEqual( await handlers.get('workhub:act')?.({}, { actionId: 'create-action', + newWorkDefaults: { permissionMode: 'bypass' }, userText: 'Start accessibility review', proposal: { disposition: 'create_new', title: 'Accessibility review' }, create: { @@ -112,6 +113,7 @@ test('projects WorkHub coordination resolution through its dedicated IPC domain' ); assert.deepEqual(actions, [{ actionId: 'create-action', + newWorkDefaults: { permissionMode: 'bypass' }, userText: 'Start accessibility review', proposal: { disposition: 'create_new', title: 'Accessibility review' }, create: { diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts index b8f0fc0fb1..cddaaeb527 100644 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-controller.test.ts @@ -3273,3 +3273,22 @@ test('deterministic routing preserves executable instructions after the model te assert.equal(result.kind, 'submitted'); if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'ledger'); }); + + +test('composer defaults apply only to creation while attachments follow explicit and automatic routing', async () => { + const actions: WorkHubCoordinationActInput[] = []; + const newWorkDefaults = { model: { llmConnectionId: 'chosen', llmConnectionSlug: 'chosen', model: 'chosen-model' }, permissionMode: 'bypass' as const }; + const attachments: NonNullable = [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'file-1' } }]; + const existing = createWorkHubController({ sessions: port([session('payments')]), onAct: (input) => actions.push(input) }); + await existing.submit({ requestId: 'explicit-composer', text: 'Continue payments', explicitTarget: { sessionId: 'payments' }, newWorkDefaults, attachments }); + assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); + assert.equal(actions[0]?.newWorkDefaults, undefined); + assert.deepEqual(actions[0]?.attachments, attachments); + const freshPort = port([]); + freshPort.create = async () => session('created-work'); + const fresh = createWorkHubController({ sessions: freshPort, onAct: (input) => actions.push(input) }); + await fresh.submit({ requestId: 'new-composer', text: 'Create a new Session for an accessibility audit', newWorkDefaults, attachments }); + assert.equal(actions[1]?.proposal.disposition, 'create_new'); + assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults); + assert.deepEqual(actions[1]?.attachments, attachments); +}); diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 32dfc95ce3..3fd058bf61 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -866,6 +866,7 @@ export async function createDesktopRuntimeHostCandidate( }); if (target.access === 'owner') { registerRuntimeHostWorkHubIpc(client, ipc, { + attachmentIngest: { approvals: deps.attachmentApprovals, stat: deps.stat, resizeImage: deps.resizeImage }, resolveCreateProject: () => deps.resolveSessionCreateProject({}, target), emitSessionsChanged, }); diff --git a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts index 1ebca81bf6..7bb4283f0a 100644 --- a/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-workhub-ipc-main.ts @@ -25,11 +25,14 @@ import type { WorkspaceTarget, } from '@maka/runtime-host/protocol'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { prepareIngestItems, resolveAttachmentRefs } from './attachment-ingest.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { ReconnectableReadIpcMain } from './ipc-reconnect-policy.js'; type RuntimeHostWorkHubClient = Pick< DesktopRuntimeHostClient, + | 'ingestAttachment' | 'actWorkHubCoordination' | 'listWorkHubCoordinationCandidates' | 'recordWorkHubCoordination' @@ -39,6 +42,7 @@ type RuntimeHostWorkHubClient = Pick< type RendererWorkHubActionInput = Omit; export interface RuntimeHostWorkHubIpcOptions { + attachmentIngest?: Pick[0], 'approvals' | 'stat'> & { resizeImage?: (bytes: Uint8Array) => Promise }; resolveCreateProject(): Promise; emitSessionsChanged(reason: 'created' | 'status-change', sessionId: string): void; } @@ -56,6 +60,16 @@ export function registerRuntimeHostWorkHubIpc( client.recordWorkHubCoordination(input), ); ipcMain.handle('workhub:candidates', () => client.listWorkHubCoordinationCandidates()); + ipcMain.handle('workhub:prepareAttachments', async (event, items: unknown) => { + if (!options.attachmentIngest) throw new Error('WorkHub attachments are unavailable'); + const prepared = await prepareIngestItems({ ...options.attachmentIngest, senderId: event.sender.id, items }); + const refs = await resolveAttachmentRefs({ + files: prepared.files, + resizeImage: options.attachmentIngest.resizeImage, + snapshot: ({ name, mimeType, content }) => client.ingestAttachment({ sessionId: WORKHUB_COORDINATION_SESSION_ID, name, mimeType, content }), + }); + return prepared.commit(() => refs); + }); ipcMain.handle('workhub:act', async (_event, rawInput: RendererWorkHubActionInput) => { try { const proposal = rawInput?.proposal; @@ -63,6 +77,7 @@ export function registerRuntimeHostWorkHubIpc( actionId: rawInput?.actionId, userText: rawInput?.userText, proposal, + ...(rawInput?.attachments ? { attachments: rawInput.attachments } : {}), ...(rawInput?.confirmation === undefined ? {} : { confirmation: rawInput.confirmation }), @@ -78,6 +93,7 @@ export function registerRuntimeHostWorkHubIpc( if (createsTarget) { result = await client.actWorkHubCoordination({ ...base, + ...(rawInput.newWorkDefaults ? { newWorkDefaults: rawInput.newWorkDefaults } : {}), create: { workspace: await options.resolveCreateProject(), }, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 85e6b0a70e..42e131e946 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1007,6 +1007,7 @@ export interface MakaBridge { ): () => void; }; workHub: { + prepareAttachments(coordinationSessionId: string, items: RendererIngestInput[]): Promise; /** Resolve the active Runtime Host's stable coordination conversation. */ resolveCoordinationSession(): Promise; /** Persist one deterministic clarification or routing summary. */ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 99790e2880..ff2eaacdc2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2000,6 +2000,10 @@ const makaBridge = { }, }, workHub: { + async prepareAttachments(coordinationSessionId: string, items: Parameters[1]) { + const scope = await resolveDesktopWorkHubCoordinationCreateScope(coordinationSessionId, runtimeHostSessionRef); + return ipcRenderer.invoke('workhub:prepareAttachments', scope, await encodeIngestItems(items)); + }, resolveCoordinationSession(): Promise { return resolveDesktopWorkHubCoordinationSession( activeRuntimeHostRef, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 24518aa030..cf79cdd9b4 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,7 +35,6 @@ import type { } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; -import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { collapseSessionRevisions } from '@maka/core/session-revisions'; import { isLinkedSubagentSession } from '@maka/core/session'; @@ -66,7 +65,6 @@ import { reconcileInteractions, } from '@maka/ui'; import type { ConnectionEvent } from '@maka/core/connections'; -import { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; import { Button } from '@astryxdesign/core/Button'; import { useKeyboardHelp } from './keyboard-help'; import { useCommandPalette } from './command-palette'; @@ -195,7 +193,7 @@ import { import * as liveContent from './live-content-seed'; import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useTurnActionRegistry } from './use-turn-action-registry'; -import { useComposerAttachments } from './use-composer-attachments'; +import { useComposerAttachments, desktopSlashCommandPresentation } from './features/conversation/index.js'; import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; import { type ComposerMentionsSurfaceInput, @@ -552,7 +550,7 @@ function AppShellContent({ const sessionHostConnections = useShellConnections({ toastApi, uiLocale, - target: { kind: 'session', sessionId: ownerActiveId }, + target: { kind: 'session', sessionId: workHubActive ? workHubCoordinationSessionId : ownerActiveId }, }); const startupConnectionSnapshot = onboarding.snapshot; const newTaskUsesDefaultHost = taskEntry.selectors.usesDefaultHost; @@ -568,7 +566,7 @@ function AppShellContent({ } : defaultHostConnections.snapshot; } - const activeConnectionSnapshot = activeId + const activeConnectionSnapshot = workHubActive || activeId ? sessionHostConnections.snapshot : newTaskConnectionSnapshot; const connections = activeConnectionSnapshot.connections; @@ -1222,31 +1220,7 @@ function AppShellContent({ streaming: turnActive || activeStreamingLive, }), ); - const presentation: Record< - SlashCommandIdForSurface<'desktop'>, - Omit - > = { - compact: { - ...shellCopy.slashCommands.compact, - keywords: ['compact', 'context', '压缩', '上下文'], - Icon: Minimize2, - }, - side: { - ...shellCopy.slashCommands.side, - keywords: ['side', 'btw', '侧聊', '追问'], - Icon: MessageCircleQuestion, - }, - swarm: { - ...shellCopy.slashCommands.swarm, - keywords: ['swarm', 'multi-agent', '多智能体'], - Icon: Network, - }, - graph: { - ...shellCopy.slashCommands.graph, - keywords: ['graph', 'agent graph', '智能体图'], - Icon: GitBranch, - }, - }; + const presentation = desktopSlashCommandPresentation(shellCopy.slashCommands); return availableCommands.map(({ id }) => ({ id, ...presentation[id] })); }, [activeId, activeStreamingLive, shellCopy.slashCommands, turnActive], @@ -2655,6 +2629,13 @@ function AppShellContent({ locale={uiLocale} {...(activeId ? { initialFocusSessionId: activeId } : {})} onOpenSession={openSessionInChat} + composerServices={{ + sessions, + modelChoices: chatModelChoices, + defaults: { model: newChatModel, permissionMode: newTaskPermissionMode }, + confirmBypass: () => confirmBypassPermission(toastApi, uiLocale), + onOpenModelSettings: () => openSettingsSection('models'), + }} /> ) : ( { - if (item.source.type === 'retained') return []; - return [ - item.source.type === 'approval' - ? { - approvalId: item.source.approvalId, - name: item.source.name, - ...(item.mimeType ? { mimeType: item.mimeType } : {}), - } - : { file: item.source.file }, - ]; - }); -} - -export function retainedAttachmentRefs( - pending: readonly PendingAttachment[], -): AttachmentRef[] { - return pending.flatMap((item) => - item.source.type === 'retained' - ? [structuredClone(item.source.attachment)] - : [], - ); -} +export { pendingAttachmentSourceKey, toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment, type ComposerIngestInput } from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index d12a42a7ca..70a402938b 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -27,6 +27,7 @@ import { ModuleHubServicesProvider } from '../features/module-hub'; import { RuntimeHostManagementServicesProvider } from '../features/runtime-host-management'; import { SessionCollaborationServicesProvider } from '../features/session-collaboration'; import { SessionNavigationServicesProvider } from '../features/session-navigation'; +import { WorkHubComposerServicesProvider } from '../features/workhub/index.js'; import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; import { WorkbarServicesProvider } from '../features/workbar'; @@ -39,6 +40,7 @@ import { createDesktopSessionCollaborationServices } from '../platform/desktop/c import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services'; +import { createDesktopWorkHubComposerServices } from '../platform/desktop/create-workhub-composer-services.js'; import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services'; export function createDesktopFeatureServices() { @@ -54,6 +56,7 @@ export function createDesktopFeatureServices() { sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), workbar: createDesktopWorkbarServices(), + workhub: createDesktopWorkHubComposerServices(), }; } @@ -68,6 +71,7 @@ export function DesktopFeatureServicesProvider(props: { + @@ -79,6 +83,7 @@ export function DesktopFeatureServicesProvider(props: { + diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts new file mode 100644 index 0000000000..c47feb32d9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-attachments.ts @@ -0,0 +1,34 @@ +/* + * 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 { useUiLocale } from '@maka/ui'; +import { useComposerAttachments as useSharedComposerAttachments } from '@maka/ui/use-composer-attachments'; +import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +export type { ComposerAttachmentService } from '@maka/ui/use-composer-attachments'; + +/** Desktop localization for the shared staging and preview lifecycle. */ +export function useComposerAttachments(options: Omit[0], 'copy' | 'formatError'>) { + const locale = useUiLocale(); + return useSharedComposerAttachments({ + ...options, + copy: getDesktopConversationCopy(locale).actions, + formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale), + }); +} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index ee44a48d4c..ea87193c3d 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -52,3 +52,8 @@ export type { ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; export { restoreTranscriptTailAfterSend } from './controller/transcript-reading-position.js'; + +export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; +export * from '@maka/ui/composer-attachments'; +export * from '@maka/ui/pending-items'; +export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js'; diff --git a/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts b/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts new file mode 100644 index 0000000000..a66df2d2d9 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/model/slash-command-presentation.ts @@ -0,0 +1,51 @@ +/* + * 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 { GitBranch, MessageCircleQuestion, Minimize2, Network } from '@maka/ui/icons'; +import type { ComposerSlashCommandOption } from '@maka/ui'; +import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; +import type { getShellCopy } from '../../../locales/shell-copy.js'; +export function desktopSlashCommandPresentation(copy: ReturnType['app']['slashCommands']) { + const presentation: Record< + SlashCommandIdForSurface<'desktop'>, + Omit + > = { + compact: { + ...copy.compact, + keywords: ['compact', 'context', '压缩', '上下文'], + Icon: Minimize2, + }, + side: { + ...copy.side, + keywords: ['side', 'btw', '侧聊', '追问'], + Icon: MessageCircleQuestion, + }, + swarm: { + ...copy.swarm, + keywords: ['swarm', 'multi-agent', '多智能体'], + Icon: Network, + }, + graph: { + ...copy.graph, + keywords: ['graph', 'agent graph', '智能体图'], + Icon: GitBranch, + }, + }; + return presentation; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index d49fafb4a0..353fe2f510 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -36,7 +36,8 @@ import { import type { SessionSummary } from '@maka/core/session'; import { generalizedErrorMessageForLocale } from '@maka/core/redaction'; import { useQuoteCompanion } from './use-quote-companion'; -import { useComposerAttachments } from '../../../../use-composer-attachments'; +import { useComposerAttachments } from '@maka/ui/use-composer-attachments'; +import { localizedShellErrorMessage } from '../../../../locales/shell-copy.js'; import { useComposerMentionsContext } from '../../../../composer-mentions.js'; import { preflightAttachmentItems } from '../../../../attachment-preflight'; import { toComposerIngestItems } from '../../../../composer-attachments'; @@ -137,6 +138,8 @@ export function QuoteCompanionPanel(props: { removeAttachment, clearSubmittedAttachments, } = useComposerAttachments({ + copy: getDesktopConversationCopy(locale).actions, + formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale), draftKey, toastApi: toast, service: attachments, diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index e7cc03499a..b138123918 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -25,3 +25,7 @@ export { WorkHubNavigationRail } from './ui/workhub-navigation-rail.js'; export { WorkHubPromptRail } from './ui/workhub-prompt-rail.js'; export { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue } from './ui/workhub-work-identity.js'; + +export { WorkHubComposer, type WorkHubComposerServices, type WorkHubComposerSelection } from './ui/workhub-composer.js'; + +export { WorkHubComposerServicesProvider, useWorkHubComposerServices } from './services-context.js'; diff --git a/apps/desktop/src/renderer/features/workhub/services-context.tsx b/apps/desktop/src/renderer/features/workhub/services-context.tsx new file mode 100644 index 0000000000..fd71321192 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/services-context.tsx @@ -0,0 +1,35 @@ +/* + * 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 { createServicesContext } from '../../application/contracts/feature-services.js'; +import type { WorkHubCreateDefaults } from '@maka/core/session'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; + +export interface WorkHubComposerPort { + attachments: { + pickFiles(): Promise<{ ok: true; files: Array<{ approvalId: string; name: string; mimeType?: string; size: number }> } | { ok: false; reason: 'cancelled' }>; + previewApproval(approvalId: string): Promise<{ ok: true; base64: string; mimeType: string } | { ok: false; reason: string }>; + }; + prepareAttachments(sessionId: string, items: Array<{ approvalId: string; name: string; mimeType?: string } | { file: File }>): Promise; + setModelConfiguration(sessionId: string, input: NonNullable & { thinkingLevel: null }): Promise; + setPermissionMode(sessionId: string, mode: ChatDefaultPermissionMode): Promise; +} +const { Provider, useServices } = createServicesContext('WorkHubComposerServicesProvider'); +export const WorkHubComposerServicesProvider = Provider; +export const useWorkHubComposerServices = useServices; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx new file mode 100644 index 0000000000..d749ba5ca1 --- /dev/null +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx @@ -0,0 +1,162 @@ +/* + * 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 { useRef, useState } from 'react'; +import { Composer, useToast, type ComposerProps, type ChatModelChoice } from '@maka/ui'; +import type { SessionSummary, WorkHubCreateDefaults } from '@maka/core/session'; +import { isChatDefaultPermissionMode } from '@maka/core/settings'; +import { useComposerAttachments } from '@maka/ui/use-composer-attachments'; +import { toComposerIngestItems } from '@maka/ui/composer-attachments'; +import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; +import type { AttachmentRef } from '@maka/core/events'; +import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; +import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { useWorkHubComposerServices } from '../services-context.js'; +import { workHubIdentityHue } from './workhub-work-identity.js'; + +export interface WorkHubComposerServices { + sessions: readonly SessionSummary[]; + modelChoices: ChatModelChoice[]; + defaults: WorkHubCreateDefaults; + confirmBypass(): Promise; + onOpenModelSettings(): void; +} + +export interface WorkHubComposerSelection { + attachments?: AttachmentRef[]; + explicitTarget?: { sessionId: string }; + newWorkDefaults?: WorkHubCreateDefaults; +} + +/** Bind the shared Composer to an explicit Work or to creation defaults. */ +type WorkHubComposerProps = Omit & { + services?: WorkHubComposerServices; + locale: UiLocale; + attachmentScope?: string; + onSend(text: string, selection: WorkHubComposerSelection, onAccepted?: () => void): Promise; +}; + +export function WorkHubComposer(props: WorkHubComposerProps) { + if (!props.services) return props.onSend(text, {})} />; + return ; +} + +function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, ...composer }: WorkHubComposerProps) { + const settings = useWorkHubComposerServices(); + const [selectedId, setSelectedId] = useState(''); + const [defaults, setDefaults] = useState(services?.defaults ?? {}); + const [changing, setChanging] = useState(false); + const [submitting, setSubmitting] = useState(false); + const toast = useToast(); + const staged = useComposerAttachments({ + draftKey: `workhub:${attachmentScope}`, + toastApi: toast, + service: settings.attachments, + copy: getDesktopConversationCopy(locale).actions, + formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale), + }); + const uploaded = useRef(new Map()); + const chinese = locale === 'zh-CN'; + const selected = services?.sessions.find((session) => session.id === selectedId); + const unavailable = Boolean(selectedId && !selected); + const defaultChoice = services?.modelChoices.find((choice) => defaults.model && + choice.connectionId === defaults.model.llmConnectionId && choice.connectionSlug === defaults.model.llmConnectionSlug && choice.model === defaults.model.model) + ?? services?.modelChoices.find((choice) => choice.isDefault); + const defaultModel = defaultChoice ? { llmConnectionId: defaultChoice.connectionId, llmConnectionSlug: defaultChoice.connectionSlug, model: defaultChoice.model } : undefined; + const busy = changing || submitting || Boolean(composer.sendBlocked); + const settingsLocked = busy || unavailable || selected?.status === 'running' || selected?.status === 'waiting_for_user'; + const change = async (operation: () => Promise) => { + setChanging(true); + try { await operation(); } + catch { toast.error(chinese ? '配置更新失败,请重试' : 'Could not update settings. Try again.'); } + finally { setChanging(false); } + }; + return <> +
+ + {selected ? (chinese ? '模型与权限用于此 Work' : 'Settings apply to this Work') : (chinese ? '模型与权限用于新 Work' : 'Settings apply to new Work')} +
+ { + const snapshot = [...staged.pendingAttachments]; + setSubmitting(true); + try { + if (snapshot.length > MAX_ATTACHMENT_COUNT || snapshot.some((item) => item.size > MAX_ATTACHMENT_BYTES)) { + throw new Error(chinese ? '附件数量或大小超过限制' : 'Attachment count or size exceeds the limit'); + } + const attachments: AttachmentRef[] = []; + for (const item of snapshot) { + let ref = uploaded.current.get(item.stagingKey); + if (!ref) { + [ref] = await settings.prepareAttachments(attachmentScope!, toComposerIngestItems([item])); + if (!ref) throw new Error('Attachment upload did not return a reference'); + uploaded.current.set(item.stagingKey, ref); + } + attachments.push(ref); + } + const accepted = await onSend(text.trim() || (chinese ? '请查看附件。' : 'Please review the attachments.'), { + ...(selectedId ? { explicitTarget: { sessionId: selectedId } } : { newWorkDefaults: { ...defaults, model: defaultModel } }), + ...(attachments.length ? { attachments } : {}), + }, () => { + staged.clearSubmittedAttachments(snapshot); + for (const item of snapshot) uploaded.current.delete(item.stagingKey); + }); + return accepted; + } catch (error) { + toast.error(chinese ? '发送失败' : 'Could not send', localizedShellErrorMessage(error, chinese ? '请重试' : 'Try again', locale)); + return false; + } finally { setSubmitting(false); } + }} + activeSession={selected} + modelChoices={services?.modelChoices} + noModelConnection={!selected && services?.modelChoices.length === 0} + activeModelConnectionId={selected?.llmConnectionId} + activeModelConnectionSlug={selected?.llmConnectionSlug} + activeModel={selected?.model} + modelLabel={selected?.model ?? defaultModel?.model} + onModelChange={selected && services ? (model) => change(() => settings.setModelConfiguration(selected.id, { ...model, thinkingLevel: null })) : undefined} + modelSwitchAvailability={settingsLocked ? { available: false, pending: changing, reason: 'pending' } : undefined} + newChatModel={defaultModel} + onPickNewChatModel={services ? (model) => { setDefaults((current) => ({ ...current, model })); } : undefined} + onOpenModelSettings={services?.onOpenModelSettings} + permissionMode={selected?.permissionMode ?? defaults.permissionMode ?? 'ask'} + permissionModeDisabledReason={settingsLocked ? (chinese ? '当前无法修改配置' : 'Settings are currently locked') : undefined} + onPermissionModeChange={services ? (mode) => change(async () => { + if (!isChatDefaultPermissionMode(mode)) return; + if (mode === 'bypass' && !await services.confirmBypass()) return; + if (selected) await settings.setPermissionMode(selected.id, mode); + else setDefaults((current) => ({ ...current, permissionMode: mode })); + }) : undefined} + /> + ; +} diff --git a/apps/desktop/src/renderer/pending-items.ts b/apps/desktop/src/renderer/pending-items.ts index eb1963ac22..2c5bd8afac 100644 --- a/apps/desktop/src/renderer/pending-items.ts +++ b/apps/desktop/src/renderer/pending-items.ts @@ -17,58 +17,4 @@ * under the License. */ -/** Generic keyed staging registry shared by composer surfaces. */ -export type PendingByKey = Record; - -/** - * The bucket the session-less composer stages into, whatever the workspace - * picker points at (#3408). - * - * The rest of the new-task surface is keyed by (profileId, hostId, projectId) - * since #3122, and staged files and quotes were keyed with it. But they are - * in-memory intent, not Host state: nothing persists them, and nothing restores - * them per target. Keying them by the target only made them drop out of the - * composer when the picker moved. - * - * A key that never moves is also the only owner an in-flight submission can - * safely have. `send()` captures the key it submitted from and clears that key - * when it resolves, so a key that followed the picker would leave the files it - * just sent staged under the new target, ready to be sent a second time. This - * one cannot go stale, so no re-keying rule is needed to keep it honest. - */ -export const NEW_TASK_PENDING_KEY = 'new-task'; - -export function selectPending(map: PendingByKey, key: string): T[] { - return map[key] ?? []; -} - -export function appendPending( - map: PendingByKey, - key: string, - items: readonly T[], -): PendingByKey { - return { ...map, [key]: [...(map[key] ?? []), ...items] }; -} - -export function removePending(map: PendingByKey, key: string, index: number): PendingByKey { - const current = map[key] ?? []; - return { ...map, [key]: current.filter((_, i) => i !== index) }; -} - -export function removePendingItems( - map: PendingByKey, - key: string, - items: readonly T[], - identityOf: (item: T) => unknown = (item) => item, -): PendingByKey { - const submitted = new Set(items.map(identityOf)); - const remaining = (map[key] ?? []).filter((item) => !submitted.has(identityOf(item))); - if (remaining.length === 0) return clearPending(map, key); - return { ...map, [key]: remaining }; -} - -export function clearPending(map: PendingByKey, key: string): PendingByKey { - const next = { ...map }; - delete next[key]; - return next; -} +export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending, type PendingByKey } from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts new file mode 100644 index 0000000000..af4cb1632a --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-composer-services.ts @@ -0,0 +1,28 @@ +/* + * 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 type { MakaBridge } from '../../../preload/bridge-contract.js'; +export function createDesktopWorkHubComposerServices(bridge: MakaBridge = window.maka) { + return { + attachments: bridge.attachments, + prepareAttachments: bridge.workHub.prepareAttachments, + setModelConfiguration: bridge.sessions.setModelConfiguration, + setPermissionMode: bridge.sessions.setPermissionMode, + }; +} diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 1dd71cc104..5c20a04663 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -51,7 +51,7 @@ box-sizing: border-box; align-items: flex-start; margin-top: var(--space-6, 24px); - padding: 0 var(--space-3, 12px) 12px; + padding: 0 var(--space-4, 16px) 12px; } .workhub-header h1, @@ -75,6 +75,7 @@ .workhub-message-list { flex: 1 1 auto; + padding-inline: 0; } .workhub-body { @@ -308,9 +309,6 @@ padding-inline: 16px; } - .workhub-turn { - padding-inline: 4px; - } } @media (max-width: 1240px) { @@ -318,6 +316,12 @@ display: block; } + .workhub-conversation-shell, + .workhub-header { + width: min(var(--maka-reading-measure), calc(100% - 2 * (var(--space-6) + var(--space-3)))); + margin-inline: auto; + } + .workhub-anchor-rail { position: static; width: min(var(--maka-reading-measure), 100%); @@ -458,3 +462,19 @@ text-overflow: ellipsis; white-space: nowrap; } + +.workhub-composer-scope { + box-sizing: border-box; + width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6))); + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px 8px; + font-size: var(--font-size-body-sm, 12px); + color: var(--color-text-secondary); +} +.workhub-composer-scope label { display: flex; align-items: center; gap: 8px; min-width: 0; } +.workhub-composer-scope select { max-width: 240px; color: inherit; background: transparent; border: 0; font: inherit; } +@media (max-width: 600px) { .workhub-composer-scope { flex-wrap: wrap; } } diff --git a/apps/desktop/src/renderer/workhub-controller.ts b/apps/desktop/src/renderer/workhub-controller.ts index 6eea15a9c6..56f4fbbab0 100644 --- a/apps/desktop/src/renderer/workhub-controller.ts +++ b/apps/desktop/src/renderer/workhub-controller.ts @@ -120,6 +120,7 @@ export interface WorkHubProjectedTurn { } export interface WorkHubCoordinationTurn { + attachments?: WorkHubCoordinationActInput['attachments']; messageId: string; turnId: string; text: string; @@ -160,6 +161,8 @@ export interface WorkHubProjection { } export interface WorkHubSubmitInput { + attachments?: WorkHubCoordinationActInput['attachments']; + newWorkDefaults?: WorkHubCoordinationActInput['newWorkDefaults']; requestId: string; text: string; retryAction?: true; @@ -583,13 +586,13 @@ export function createWorkHubController(deps: { text: input.text, sessions: ordinary, }); - const resume = await submitNamedDelegationAction(input, resumeDecision, 'resume', routingStrategy.strategyId); + const resume = input.attachments?.length ? undefined : await submitNamedDelegationAction(input, resumeDecision, 'resume', routingStrategy.strategyId); if (resume) return resume; const stopDecision = submissionPolicy.resolveStop({ text: input.text, sessions: ordinary, }); - const stop = await submitNamedDelegationAction(input, stopDecision, 'stop', routingStrategy.strategyId); + const stop = input.attachments?.length ? undefined : await submitNamedDelegationAction(input, stopDecision, 'stop', routingStrategy.strategyId); if (stop) return stop; const candidateSet = await coordination.candidates(); const candidateBySessionId = new Map( @@ -657,6 +660,7 @@ export function createWorkHubController(deps: { await coordination.act({ actionId: input.requestId, userText: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), proposal: { disposition: 'answer_here' }, }); return { @@ -676,6 +680,8 @@ export function createWorkHubController(deps: { ? { actionId: input.requestId, userText: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), confirmation: { kind: 'user_correction' }, proposal: { disposition: 'replace', @@ -686,6 +692,8 @@ export function createWorkHubController(deps: { : { actionId: input.requestId, userText: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), proposal: { disposition: 'create_new', title }, }); if ( @@ -731,6 +739,7 @@ export function createWorkHubController(deps: { ? { actionId: input.requestId, userText: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), candidateSetId: candidateSet.candidateSetId, confirmation: { kind: 'user_correction' }, proposal: { @@ -745,6 +754,7 @@ export function createWorkHubController(deps: { : { actionId: input.requestId, userText: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), candidateSetId: candidateSet.candidateSetId, proposal: { disposition: 'delegate_existing', diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 954e68286b..88f9c82fd8 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -194,6 +194,7 @@ export function projectWorkHubCoordinationTurns( messageId: message.id, turnId: message.coordinationTurnId, text: boundedWorkHubTimelineText(message.userText), + ...(message.attachments ? { attachments: message.attachments } : {}), state: 'completed', assignment: { actionId: message.actionId, @@ -217,6 +218,7 @@ export function projectWorkHubCoordinationTurns( messageId: message.id, turnId: message.turnId, text, + ...(message.attachments ? { attachments: message.attachments } : {}), state: stateByTurnId.get(message.turnId) ?? 'running', updatedAt: message.ts, }); diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index e59be81a31..255de41673 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -26,7 +26,7 @@ import { } from '@astryxdesign/core'; import { Button } from '@astryxdesign/core/Button'; import type { UiLocale } from '@maka/core/ui-locale'; -import { ChatSurfaceLayout, Composer } from '@maka/ui'; +import { ChatSurfaceLayout } from '@maka/ui'; import { type WorkHubController, type WorkHubCoordinationTurn, @@ -36,12 +36,13 @@ import { type WorkHubSubmission, type WorkHubSubmitInput, } from './workhub-controller.js'; +import type { AttachmentRef } from '@maka/core/events'; import { WorkHubCoordinationFailure } from './workhub-coordination-port.js'; import { WorkHubSendLease, type WorkHubSendAttempt, } from './workhub-send-lease.js'; -import { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue, WorkHubNavigationRail, WorkHubPromptRail } from './features/workhub/index.js'; +import { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue, WorkHubNavigationRail, WorkHubPromptRail, WorkHubComposer, type WorkHubComposerServices, type WorkHubComposerSelection } from './features/workhub/index.js'; import { getWorkHubRailCopy } from './locales/workhub-copy.js'; export interface WorkHubConversationTurn { @@ -50,6 +51,7 @@ export interface WorkHubConversationTurn { state: 'routing' | 'settled' | 'failed'; outcome?: WorkHubSubmission; failure?: WorkHubSurfaceFailure; + submissionContext?: WorkHubComposerSelection & { attachments?: AttachmentRef[] }; } export type WorkHubSurfaceFailure = @@ -220,13 +222,16 @@ export async function submitLeasedWorkHubSurfaceInput(input: { * The persistent Coordination Session transcript is the primary conversation. * Ordinary Sessions remain a read-only status/routing projection. */ -export function WorkHubSurface(props: { +interface WorkHubSurfaceProps { controller: WorkHubController; leaseScope: string; locale: UiLocale; initialFocusSessionId?: string; + composerServices?: WorkHubComposerServices; onOpenSession(sessionId: string): void; -}) { +} + +export function WorkHubSurface(props: WorkHubSurfaceProps) { const copy = workHubCopy(props.locale); const railCopy = getWorkHubRailCopy(props.locale); const [projection, setProjection] = useState({ sessions: [], turns: [] }); @@ -355,26 +360,31 @@ export function WorkHubSurface(props: { }); }, [copy, projection, props.controller, refresh, routeGate]); - const send = useCallback(async (value: string) => { + const send = useCallback(async (value: string, selection: WorkHubComposerSelection, onAccepted?: () => void) => { const text = value.trim(); if (!text || !initialLoadSettled || !conversationReady || routeGate.pending) return false; - return submitLeasedWorkHubSurfaceInput({ + const submissionContext = selection; + const accepted = await submitLeasedWorkHubSurfaceInput({ lease: sendLease, text, submit: async (attempt) => { const { requestId } = attempt; setTurns((current) => current.some((turn) => turn.requestId === requestId) ? current.map((turn) => turn.requestId === requestId - ? { requestId, text: attempt.text, state: 'routing' } + ? { requestId, text: attempt.text, state: 'routing', submissionContext } : turn) - : [...current, { requestId, text: attempt.text, state: 'routing' }]); - return route({ + : [...current, { requestId, text: attempt.text, state: 'routing', submissionContext }]); + const result = await route({ requestId, text: attempt.text, + ...submissionContext, ...(attempt.retrying ? { retryAction: true as const } : {}), }); + if (workHubSubmissionClearsDraft(result)) onAccepted?.(); + return result; }, }); + return accepted; }, [conversationReady, initialLoadSettled, route, routeGate, sendLease]); const visible = visibleWorkHubConversation(coordination.turns, turns); const visibleCoordinationTurns = visible.coordination; @@ -387,7 +397,13 @@ export function WorkHubSurface(props: { projection.sessions.some((work) => work.target.sessionId === session.id)), + } : undefined} + locale={props.locale} + attachmentScope={props.leaseScope} draftKey="workhub" draftPersistence={sendLease} onSend={send} @@ -478,6 +494,7 @@ export function WorkHubSurface(props: { submit: (attempt) => route({ requestId: attempt.requestId, text: attempt.text, + ...turn.submissionContext, explicitTarget: target, ...(attempt.retrying ? { retryAction: true as const } : {}), ...(turn.outcome?.kind === 'clarification' && turn.outcome.correction @@ -524,7 +541,8 @@ export function WorkHubCoordinationStatus(props: { false} onStop={() => {}} @@ -616,6 +634,7 @@ export function WorkHubCoordinationTurnView(props: { {rail}

{props.text}

+ {props.attachments?.length ?
    {props.attachments.map((attachment, index) =>
  • {attachment.name}
  • )}
: null} diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index ebf622333d..9079acf28f 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -17,6 +17,10 @@ * under the License. */ +import { ToastProvider } from '@maka/ui'; +import { useState } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import { WorkHubComposerServicesProvider } from '../src/renderer/features/workhub/index.js'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fn, userEvent, within, waitFor } from 'storybook/test'; import type { @@ -327,3 +331,135 @@ export const WorkIdentityAcrossTurns: Story = { await userEvent.unhover(rail); }, }; + +const composerWrites = { model: fn(), permission: fn(), send: fn(), upload: fn() }; +const composerModels = ['model-a', 'model-b'].map((model, index) => ({ + connectionId: 'connection-test', connectionSlug: 'test', providerType: 'openai' as const, + providerLabel: 'OpenAI', model, label: model, isDefault: index === 0, thinkingLevels: [], +})); +function ConfiguredComposerSurface({ failFirst = false }: { failFirst?: boolean }) { + const [failures] = useState(() => ({ remaining: failFirst ? 1 : 0 })); + const [session, setSession] = useState({ + id: TARGET_SESSION_ID, name: SESSION_NAME, isFlagged: false, isArchived: false, labels: [], + hasUnread: false, status: 'active', backend: 'ai-sdk', llmConnectionId: 'connection-test', + llmConnectionSlug: 'test', connectionLocked: false, model: 'model-a', permissionMode: 'ask', + }); + const [fixture] = useState(() => ({ + ...controller([submittedTurn()]), + submit: async (input) => { + composerWrites.send(input); + if (failures.remaining-- > 0) throw new Error('Temporary Host failure'); + return { kind: 'discussion', requestId: input.requestId, text: input.text, strategyId: 'wh-r2.4-session-context-continuity' }; + }, + })); + return ({ ok: true, files: [{ approvalId: 'file-1', name: 'requirements.txt', size: 12, mimeType: 'text/plain' }] }), + previewApproval: async () => ({ ok: false, reason: 'not-image' }), + }, + prepareAttachments: async (sessionId, items) => { + composerWrites.upload(sessionId, items); + return [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }]; + }, + setModelConfiguration: async (sessionId, model) => { + composerWrites.model(sessionId, model); + setSession((current) => ({ ...current, ...model, thinkingLevel: undefined })); + }, + setPermissionMode: async (sessionId, permissionMode) => { + composerWrites.permission(sessionId, permissionMode); + setSession((current) => ({ ...current, permissionMode })); + }, + }}> +
+ {}} composerServices={{ + sessions: [session], modelChoices: composerModels, + defaults: { model: { llmConnectionId: 'connection-test', llmConnectionSlug: 'test', model: 'model-a' }, permissionMode: 'ask' }, + confirmBypass: async () => true, onOpenModelSettings: () => {}, + }} /> +
+
; +} + +// Production Composer, with only native file picking and Host writes replaced. +export const StandardComposer: Story = { + render: () => , + play: async ({ canvasElement }) => { + Object.values(composerWrites).forEach((spy) => spy.mockClear()); + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByRole('combobox', { name: '当前 Work' })).toBeEnabled()); + await expect(canvas.getByText('模型与权限用于新 Work')).toBeVisible(); + await expect(canvas.getByRole('button', { name: '添加上下文' })).toBeEnabled(); + await userEvent.selectOptions(canvas.getByRole('combobox', { name: '当前 Work' }), TARGET_SESSION_ID); + await expect(canvas.getByText('模型与权限用于此 Work')).toBeVisible(); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(canvas.getByRole('button', { name: /切换当前任务模型/ })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'model-b' })); + await waitFor(() => expect(composerWrites.model).toHaveBeenCalledWith(TARGET_SESSION_ID, expect.objectContaining({ model: 'model-b' }))); + await userEvent.click(canvas.getByRole('button', { name: /权限模式/ })); + await userEvent.click(page.getByRole('menuitemradio', { name: '完全权限' })); + await waitFor(() => expect(composerWrites.permission).toHaveBeenCalledWith(TARGET_SESSION_ID, 'bypass')); + await userEvent.selectOptions(canvas.getByRole('combobox', { name: '当前 Work' }), ''); + await userEvent.click(canvas.getByRole('button', { name: /选择新任务模型/ })); + await userEvent.click(page.getByRole('menuitemradio', { name: 'model-b' })); + await userEvent.click(canvas.getByRole('button', { name: '添加上下文' })); + await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ })); + await waitFor(() => expect(canvas.getByText('requirements.txt')).toBeVisible()); + const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement; + await userEvent.click(editor); + await userEvent.type(editor, 'Review requirements'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(composerWrites.send).toHaveBeenCalledWith(expect.objectContaining({ + newWorkDefaults: expect.objectContaining({ model: expect.objectContaining({ model: 'model-b' }), permissionMode: 'ask' }), + attachments: [expect.objectContaining({ name: 'requirements.txt' })], + }))); + expect(composerWrites.send.mock.lastCall?.[0].explicitTarget).toBeUndefined(); + expect(composerWrites.model).toHaveBeenCalledTimes(1); + expect(composerWrites.permission).toHaveBeenCalledTimes(1); + const composer = canvasElement.querySelector('.maka-composer-astryx') as HTMLElement; + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0)); + await userEvent.click(canvas.getByRole('button', { name: '添加上下文' })); + await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ })); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(1)); + await userEvent.click(editor); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(composerWrites.send).toHaveBeenCalledTimes(2)); + expect(composerWrites.send.mock.lastCall?.[0].text).toBe('请查看附件。'); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0)); + const scopeBox = canvasElement.querySelector('.workhub-composer-scope')!.getBoundingClientRect(); + const plateBox = composer.firstElementChild!.getBoundingClientRect(); + expect(Math.abs(scopeBox.left - plateBox.left)).toBeLessThanOrEqual(1); + expect(Math.abs(scopeBox.right - plateBox.right)).toBeLessThanOrEqual(1); + const conversationBox = canvasElement.querySelector('.workhub-conversation-shell')!.getBoundingClientRect(); + expect(Math.abs(conversationBox.left - plateBox.left)).toBeLessThanOrEqual(1); + expect(Math.abs(conversationBox.right - plateBox.right)).toBeLessThanOrEqual(1); + canvasElement.dataset.workhubComposerVerified = 'true'; + + }, +}; + +export const StandardComposerNarrow: Story = { ...StandardComposer, parameters: { viewport: { defaultViewport: 'tablet' } } }; + +export const ComposerRetainsFailedAttachment: Story = { + render: () => , + play: async ({ canvasElement }) => { + Object.values(composerWrites).forEach((spy) => spy.mockClear()); + const canvas = within(canvasElement); + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(canvas.getByRole('combobox', { name: '当前 Work' })).toBeEnabled()); + await userEvent.click(canvas.getByRole('button', { name: '添加上下文' })); + await userEvent.click(page.getByRole('menuitem', { name: /添加文件/ })); + const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement; + await userEvent.click(editor); + await userEvent.type(editor, 'Review requirements'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(canvasElement.querySelector('.workhub-turn[data-state="failed"]')).not.toBeNull()); + const composer = canvasElement.querySelector('.maka-composer-astryx') as HTMLElement; + expect(within(composer).getByText('requirements.txt')).toBeVisible(); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(composerWrites.send).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0)); + expect(composerWrites.upload).toHaveBeenCalledTimes(1); + expect(composerWrites.send.mock.calls[0]?.[0].requestId).toBe(composerWrites.send.mock.calls[1]?.[0].requestId); + canvasElement.dataset.workhubComposerVerified = 'true'; + }, +}; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b495caed0d..18e917d461 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -27,6 +27,7 @@ import { decodeMessageContent, TOOL_ACTIVITY_KINDS, type MessageContent, + type AttachmentRef, type ToolActivityKind, type ToolResultContent, } from './events.js'; @@ -930,9 +931,31 @@ export type WorkHubDelegationWorkspace = | { readonly kind: 'project'; readonly projectId: string } | { readonly kind: 'host_path'; readonly path: string }; +/** User-selected creation defaults; never applied to an existing Work. */ +export interface WorkHubCreateDefaults { + readonly model?: { + readonly llmConnectionId: string; + readonly llmConnectionSlug: string; + readonly model: string; + }; + readonly permissionMode?: PermissionMode; +} + +export function isWorkHubCreateDefaults(value: unknown): value is WorkHubCreateDefaults { + if (!isRecord(value) || Object.keys(value).some((key) => key !== 'model' && key !== 'permissionMode')) return false; + if (value.permissionMode !== undefined && !isPermissionMode(value.permissionMode)) return false; + if (value.model === undefined) return true; + const model = value.model; + return isRecord(model) && + Object.keys(model).length === 3 && + ['llmConnectionId', 'llmConnectionSlug', 'model'].every((key) => + typeof model[key] === 'string' && model[key].trim().length > 0 && model[key].length <= 512); +} + export interface WorkHubDelegationCreateSpec { readonly title: string; readonly workspace: WorkHubDelegationWorkspace; + readonly defaults?: WorkHubCreateDefaults; } interface WorkHubCoordinationMessageEnvelope { @@ -951,6 +974,7 @@ interface WorkHubCoordinationMessageEnvelope { disposition: WorkHubDelegationDisposition; /** Exact target payload; retained so retry does not depend on renderer memory. */ userText: string; + attachments?: AttachmentRef[]; /** Present exactly for create_new. */ create?: WorkHubDelegationCreateSpec; } @@ -1292,7 +1316,7 @@ const WORKHUB_DELEGATION_ASSIGNED_MESSAGE_SHAPE = 'targetMessageId', 'targetSessionName', ], - ['create', 'steered', 'replacesActionId', 'replacesDelegationId'], + ['attachments', 'create', 'steered', 'replacesActionId', 'replacesDelegationId'], ); const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE = defineObjectShape()( @@ -1315,7 +1339,7 @@ const WORKHUB_DELEGATION_REPLACEMENT_REQUESTED_MESSAGE_SHAPE = 'replacedTargetMessageId', 'targetSessionName', ], - ['create'], + ['attachments', 'create'], ); const WORKHUB_DELEGATION_SUPERSEDED_MESSAGE_SHAPE = defineObjectShape()( @@ -1396,7 +1420,7 @@ const WORKHUB_DELEGATION_STOP_RESOLVED_MESSAGE_SHAPE = ); const WORKHUB_DELEGATION_CREATE_SHAPE = defineObjectShape()( ['title', 'workspace'], - [], + ['defaults'], ); const WORKHUB_DELEGATION_PROJECT_WORKSPACE_SHAPE = defineObjectShape< Extract @@ -1660,6 +1684,7 @@ function isWorkHubCoordinationMessage(message: Record): boolean typeof message.targetSessionId === 'string' && typeof message.userText === 'string' && message.userText.trim().length > 0 && + isWorkHubMessageAttachments(message.attachments) && ((message.disposition === 'delegate_existing' && message.create === undefined) || (message.disposition === 'create_new' && isWorkHubDelegationCreateSpec(message.create))) && (message.disposition === 'delegate_existing' || message.disposition === 'create_new'); @@ -1712,10 +1737,17 @@ function isWorkHubActionIdentity(message: Record): boolean { ); } +function isWorkHubMessageAttachments(value: unknown): boolean { + if (value === undefined) return true; + try { decodeMessageContent({ text: '', attachments: value }); return true; } + catch { return false; } +} + function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec { if ( !isRecord(value) || !hasExactShape(value, WORKHUB_DELEGATION_CREATE_SHAPE) || + (value.defaults !== undefined && !isWorkHubCreateDefaults(value.defaults)) || typeof value.title !== 'string' || value.title.trim().length === 0 || !isRecord(value.workspace) diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 22d7fd17b4..2c89865caa 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -1279,6 +1279,8 @@ describe('WorkHub Coordination Action Gate', () => { ); const input = { actionId: 'create', + attachments: [{ name: 'requirements.txt', kind: 'other' as const, mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file' as const, sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }], + newWorkDefaults: { model: { llmConnectionId: 'conn', llmConnectionSlug: 'test', model: 'chosen-model' }, permissionMode: 'ask' as const }, userText: 'Create an accessibility audit', proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' }, create: { workspace: { kind: 'host_path' as const, path: '/workspace' } }, @@ -1291,15 +1293,21 @@ describe('WorkHub Coordination Action Gate', () => { assert.deepEqual(restartedReplay, first); assert.equal(effects.assignments.length, 2); assert.deepEqual(effects.assignments[0], effects.assignments[1]); + assert.deepEqual(effects.assignments[0]?.attachments, input.attachments); assert.match(effects.assignments[0]!.targetSessionId, /^whs_[a-f0-9]{48}$/u); assert.deepEqual(effects.assignments[0]!.create, { title: 'Accessibility audit', workspace: input.create.workspace, + defaults: input.newWorkDefaults, }); await assert.rejects( gate.act({ ...input, proposal: { disposition: 'create_new', title: 'Different' } }, CONTEXT), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); + await assert.rejects( + new WorkHubCoordinationActionGate(effects).act({ ...input, newWorkDefaults: { ...input.newWorkDefaults, permissionMode: 'bypass' } }, CONTEXT), + (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', + ); assert.equal(effects.assignments.length, 2); }); @@ -3032,6 +3040,7 @@ function assignmentRecord( delegationId: `delegation-${input.actionId}`, disposition: input.disposition, userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), ...(input.create ? { create: input.create } : {}), ...(input.replacesActionId ? { replacesActionId: input.replacesActionId } : {}), ...(input.replacesDelegationId ? { replacesDelegationId: input.replacesDelegationId } : {}), diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 04a51c1043..93c4a4676d 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -507,3 +507,15 @@ test('WorkHub Coordination action results preserve the admitted disposition', () ); } }); + + +test('WorkHub decodes attachment context and user-selected creation defaults without strategy authority', () => { + const attachments = [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }]; + const input = { actionId: 'composer-action', userText: 'Create an audit', proposal: { disposition: 'create_new', title: 'Audit' }, create: { workspace: { kind: 'host_path', path: '/workspace' } }, newWorkDefaults: { model: { llmConnectionId: 'connection-1', llmConnectionSlug: 'primary', model: 'chosen-model' }, permissionMode: 'ask' }, attachments }; + assert.deepEqual(decodeWorkHubCoordinationActInput(input), input); + assert.deepEqual(decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-1', text: 'Review file', attachments }).attachments, attachments); + assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, newWorkDefaults: { permissionMode: 'invented' } })); + assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, newWorkDefaults: { ...input.newWorkDefaults, workspace: '/forged' } })); + assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, proposal: { disposition: 'answer_here' }, create: undefined })); + assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, attachments: Array(9).fill(attachments[0]) })); +}); diff --git a/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts b/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts new file mode 100644 index 0000000000..6a094772cd --- /dev/null +++ b/packages/runtime-host/src/__tests__/workhub-message-attachments.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import type { AttachmentRef } from '@maka/core/events'; +import { HostArtifactCoordinator } from '../server/artifact-coordinator.js'; +import { SessionAdmissionGate } from '../server/session-admission-gate.js'; +import { copyWorkHubAttachmentsToTarget } from '../server/workhub-message-attachments.js'; + +test('delegation copies only selected canonical attachments into the target Work', async () => { + const root = await mkdtemp(join(tmpdir(), 'workhub-attachments-')); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const store = await openInteractiveArtifactStoreForWrite(owner.lease); + try { + const selected = await store.create({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: 'upload-1', + name: 'requirements.txt', + kind: 'file', + source: 'user_upload', + mimeType: 'text/plain', + content: 'requirements', + }); + await store.create({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId: 'upload-2', + name: 'unrelated.txt', + kind: 'file', + source: 'user_upload', + mimeType: 'text/plain', + content: 'private draft', + }); + const artifacts = new HostArtifactCoordinator(store, () => {}, new SessionAdmissionGate(), { + probeSessionRemoval: async () => ({ kind: 'present' }), + }); + const source: AttachmentRef = { + name: selected.name, + kind: 'other', + mimeType: 'text/plain', + bytes: selected.sizeBytes, + ref: { + kind: 'session_file', + sessionId: WORKHUB_COORDINATION_SESSION_ID, + relativePath: selected.id, + }, + }; + const copied = await copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [ + source, + ]); + assert.equal(await artifacts.validateTurnAttachments('target-work', copied), undefined); + assert.equal(copied[0]!.ref.kind, 'session_file'); + if (copied[0]!.ref.kind !== 'session_file') throw new Error('Expected canonical ref'); + const binary = await store.readTextInSession('target-work', copied[0]!.ref.relativePath); + assert.deepEqual(binary, { ok: true, text: 'requirements' }); + assert.notEqual(copied[0]!.ref.relativePath, selected.id); + await assert.rejects( + copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [ + { ...source, bytes: 999 }, + ]), + /metadata/, + ); + await assert.rejects( + copyWorkHubAttachmentsToTarget(store, artifacts, 'target-work', [ + { ...source, ref: { ...source.ref, sessionId: 'foreign-work' } } as AttachmentRef, + ]), + /different Session/, + ); + } finally { + store.close(); + } + } finally { + await owner.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index ac0d00407f..3078a049b2 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,8 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 133 as const; +// 133: WorkHub actions carry attachments and new-Work model/permission defaults. +// Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 132 as const; // 131: Logical model steps bind durable Request Composition identities. // 130: Turn contributions carry the optional bounded `failureMessage` diagnostic. // Epoch-129 peers reject this added field on the strict contribution shape. diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index ebbd1092ef..dd8bf5f666 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -17,6 +17,9 @@ * under the License. */ +import type { AttachmentRef } from '@maka/core/events'; +import { decodeMessageContent } from './turn.js'; +import { isWorkHubCreateDefaults, type WorkHubCreateDefaults } from '@maka/core/session'; import { requireCount, requireEntityId, @@ -80,6 +83,7 @@ export interface WorkHubCoordinationResolveResult { export interface WorkHubCoordinationAnswerInput { readonly turnId: string; readonly text: string; + readonly attachments?: AttachmentRef[]; } export interface WorkHubCoordinationRecordInput { @@ -177,6 +181,8 @@ export interface WorkHubCoordinationCreateContext { export interface WorkHubCoordinationActInput { readonly actionId: string; readonly userText: string; + readonly newWorkDefaults?: WorkHubCreateDefaults; + readonly attachments?: AttachmentRef[]; readonly proposal: WorkHubCoordinationProposal; readonly candidateSetId?: string; readonly create?: WorkHubCoordinationCreateContext; @@ -295,8 +301,9 @@ export function decodeWorkHubCoordinationResolveResult( export function decodeWorkHubCoordinationAnswerInput( value: unknown, ): WorkHubCoordinationAnswerInput { - const input = requireExactRecord(value, 'WorkHub Coordination answer input', ['turnId', 'text']); + const input = requireShapedRecord(value, 'WorkHub Coordination answer input', ['turnId', 'text'], ['attachments']); return { + ...(input.attachments !== undefined ? { attachments: decodeMessageContent({ text: input.text, attachments: input.attachments }).attachments! } : {}), turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'), text: requireUtf8String( input.text, @@ -367,9 +374,16 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi value, 'WorkHub Coordination action input', ['actionId', 'userText', 'proposal'], - ['candidateSetId', 'create', 'confirmation'], + ['candidateSetId', 'create', 'confirmation', 'newWorkDefaults', 'attachments'], ); const proposal = decodeWorkHubCoordinationProposal(input.proposal); + if (input.newWorkDefaults !== undefined && (!isWorkHubCreateDefaults(input.newWorkDefaults) || + !(proposal.disposition === 'create_new' || (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new')))) { + throw invalidProtocolFrame('Invalid WorkHub creation defaults'); + } + if (input.attachments !== undefined && !['answer_here', 'delegate_existing', 'create_new', 'replace'].includes(proposal.disposition)) { + throw invalidProtocolFrame('This WorkHub action does not accept attachments'); + } const base = { actionId: requireEntityId(input.actionId, 'WorkHub Coordination action id'), userText: requireUtf8String( @@ -378,6 +392,8 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), proposal, + ...(input.attachments !== undefined ? { attachments: decodeMessageContent({ text: input.userText, attachments: input.attachments }).attachments! } : {}), + ...(input.newWorkDefaults !== undefined ? { newWorkDefaults: input.newWorkDefaults as WorkHubCreateDefaults } : {}), }; if (proposal.disposition === 'delegate_existing') { if ( diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 9caabd01ad..4f6e72dfd8 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -17,6 +17,7 @@ * under the License. */ +import { copyWorkHubAttachmentsToTarget } from './workhub-message-attachments.js'; import { createHash, randomUUID } from 'node:crypto'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import type { ContextOffloadLimits } from '@maka/core/context-offload'; @@ -1560,7 +1561,13 @@ export async function createExecutionRuntimeHostComposition( sessionId: input.targetSessionId, workspace: input.create.workspace, name: input.create.title, - modelTarget: { kind: 'default' }, + modelTarget: input.create.defaults?.model ? { + kind: 'explicit', + connectionId: input.create.defaults.model.llmConnectionId, + connectionSlug: input.create.defaults.model.llmConnectionSlug, + model: input.create.defaults.model.model, + } : { kind: 'default' }, + ...(input.create.defaults?.permissionMode ? { permissionMode: input.create.defaults.permissionMode } : {}), collaborationMode: 'agent', orchestrationMode: 'default', }) @@ -1570,7 +1577,10 @@ export async function createExecutionRuntimeHostComposition( .digest('hex') .slice(0, 48); const messageId = `whm_${suffix}`; - const content = normalizeMessageContent({ text: input.userText }); + const targetAttachments = !durable && input.attachments?.length + ? await copyWorkHubAttachmentsToTarget(openedArtifactStore, artifacts, input.targetSessionId, input.attachments) + : input.attachments; + const content = normalizeMessageContent({ text: input.userText, ...(targetAttachments ? { attachments: targetAttachments } : {}) }); const persisted = durable ?? (await sessionAdmission.runMany( @@ -1628,6 +1638,7 @@ export async function createExecutionRuntimeHostComposition( delegationId, disposition: input.disposition, userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), ...(steered ? { steered: true as const } : {}), ...(input.create ? { create: input.create } : {}), ...(input.replacesActionId && input.replacesDelegationId diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index 8d701e46d9..23d9a04a72 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { AttachmentRef } from '@maka/core/events'; import { createHash } from 'node:crypto'; import type { SessionHeader, @@ -116,7 +117,7 @@ export interface WorkHubActionGateEffects { delegationId: string, ): Promise; answer( - input: { readonly turnId: string; readonly text: string }, + input: { readonly turnId: string; readonly text: string; readonly attachments?: AttachmentRef[] }, context: ConnectionContext, ): Promise; clarify(input: { @@ -181,6 +182,7 @@ export interface WorkHubDelegationAssignmentInput { readonly targetSessionName: string; readonly disposition: WorkHubDelegationDisposition; readonly userText: string; + readonly attachments?: AttachmentRef[]; readonly create?: WorkHubDelegationCreateSpec; readonly replacesActionId?: string; readonly replacesDelegationId?: string; @@ -360,7 +362,7 @@ export class WorkHubCoordinationActionGate { if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId); - await this.#effects.answer({ turnId, text: input.userText }, context); + await this.#effects.answer({ turnId, text: input.userText, ...(input.attachments ? { attachments: input.attachments } : {}) }, context); return { disposition: 'answer_here', coordinationTurnId: turnId }; } if (proposal.disposition === 'clarify') { @@ -762,7 +764,8 @@ export class WorkHubCoordinationActionGate { targetSessionName: target.title, disposition: 'create_new', userText: input.userText, - create: { title: target.title, workspace: input.create.workspace }, + ...(input.attachments ? { attachments: input.attachments } : {}), + create: { title: target.title, workspace: input.create.workspace, ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}) }, replacesActionId: replaced.actionId, replacesDelegationId: replaced.delegationId, replacedTargetSessionId: replaced.targetSessionId, @@ -807,6 +810,7 @@ export class WorkHubCoordinationActionGate { targetSessionName: destination.sessionName, disposition: 'delegate_existing', userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), replacesActionId: replaced.actionId, replacesDelegationId: replaced.delegationId, replacedTargetSessionId: replaced.targetSessionId, @@ -1085,6 +1089,7 @@ function delegationAssignment( targetSessionName, disposition: input.proposal.disposition, userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), } as const; if (input.proposal.disposition === 'delegate_existing') return base; if (!create) { @@ -1098,6 +1103,7 @@ function delegationAssignment( create: { title: input.proposal.title, workspace: create.workspace, + ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}), }, }; } @@ -1137,6 +1143,8 @@ function digest(value: unknown): `sha256:${string}` { function actionFingerprint(input: WorkHubCoordinationActInput): `sha256:${string}` { return digest({ userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), disposition: input.proposal.disposition, ...(input.proposal.disposition === 'delegate_existing' ? { candidateRef: input.proposal.candidateRef } @@ -1171,6 +1179,8 @@ function replacementActionFingerprint( } return digest({ userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.newWorkDefaults ? { newWorkDefaults: input.newWorkDefaults } : {}), disposition: input.proposal.disposition, replacesActionId: input.proposal.replacesActionId, target: { @@ -1261,6 +1271,7 @@ function assignmentInputFromRecord( targetSessionName: assignment.targetSessionName, disposition: assignment.disposition, userText: assignment.userText, + ...(assignment.attachments ? { attachments: assignment.attachments } : {}), ...(assignment.create ? { create: assignment.create } : {}), ...(assignment.replacesActionId && assignment.replacesDelegationId ? { @@ -1281,6 +1292,7 @@ function assignmentInputFromReplacement( targetSessionName: replacement.targetSessionName, disposition: replacement.disposition, userText: replacement.userText, + ...(replacement.attachments ? { attachments: replacement.attachments } : {}), ...(replacement.create ? { create: replacement.create } : {}), replacesActionId: replacement.replacesActionId, replacesDelegationId: replacement.replacesDelegationId, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 6c953ed7e3..5039b82d99 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -197,7 +197,7 @@ export class HostWorkHubCoordinationCoordinator { readStopRequest: (delegationId) => this.#stores.readWorkHubStopRequest(delegationId), readStopResolution: (delegationId) => this.#stores.readWorkHubStopResolution(delegationId), answer: async (input, context) => { - const outcome = await this.#answer({ turnId: input.turnId, text: input.text }, context); + const outcome = await this.#answer(input, context); if (!outcome.ok) { throw new WorkHubActionEffectFailure(outcome.error.code, outcome.error.message); } @@ -248,6 +248,7 @@ export class HostWorkHubCoordinationCoordinator { targetSessionName: input.targetSessionName, disposition: input.disposition, userText: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), replacesActionId: input.replacesActionId, replacesDelegationId: input.replacesDelegationId, replacedTargetSessionId: input.replacedTargetSessionId, @@ -642,7 +643,7 @@ export class HostWorkHubCoordinationCoordinator { turnId: input.turnId, execution: { kind: 'workhub_coordination', - inputDigest: digest({ text: input.text }), + inputDigest: digest({ text: input.text, ...(input.attachments ? { attachments: input.attachments } : {}) }), }, archivedMessage: 'WorkHub Coordination Session is unavailable', // A recorded summary owns its Turn identity durably but is admitted @@ -662,7 +663,7 @@ export class HostWorkHubCoordinationCoordinator { } return recorded.length > 0 ? { kind: 'rejected', outcome: turnIdentityConflict() } - : { kind: 'ready', content: normalizeMessageContent({ text: input.text }) }; + : { kind: 'ready', content: normalizeMessageContent({ text: input.text, ...(input.attachments ? { attachments: input.attachments } : {}) }) }; }, }, context, diff --git a/packages/runtime-host/src/server/workhub-message-attachments.ts b/packages/runtime-host/src/server/workhub-message-attachments.ts new file mode 100644 index 0000000000..5972453a79 --- /dev/null +++ b/packages/runtime-host/src/server/workhub-message-attachments.ts @@ -0,0 +1,60 @@ +/* + * 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 type { AttachmentRef } from '@maka/core/events'; +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores'; +import type { HostArtifactCoordinator } from './artifact-coordinator.js'; +import { WorkHubActionEffectFailure } from './workhub-coordination-action-gate.js'; + +/** Preserve canonical ownership when a Coordination message is delegated. */ +export async function copyWorkHubAttachmentsToTarget( + store: Pick, + artifacts: Pick, + targetSessionId: string, + attachments: readonly AttachmentRef[], +): Promise { + const invalid = await artifacts.validateTurnAttachments( + WORKHUB_COORDINATION_SESSION_ID, + attachments, + ); + if (invalid) throw new WorkHubActionEffectFailure('operation_conflict', invalid); + const ids = attachments.map((attachment) => { + if (attachment.ref.kind !== 'session_file') throw new Error('Invalid WorkHub attachment'); + return attachment.ref.relativePath; + }); + const copied = await store.copyConversationArtifacts({ + sourceSessionId: WORKHUB_COORDINATION_SESSION_ID, + targetSessionId, + turnIds: [], + includeArtifactIds: ids, + }); + return attachments.map((attachment, index) => { + const relativePath = copied.artifactIds.get(ids[index]!); + if (!relativePath) + throw new WorkHubActionEffectFailure( + 'operation_conflict', + 'WorkHub attachment was not copied', + ); + return { + ...attachment, + ref: { kind: 'session_file', sessionId: targetSessionId, relativePath }, + }; + }); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index d9f5513126..22e7137cda 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,10 @@ "./assistant-stream": "./dist/assistant-stream.js", "./icons": "./dist/icons.js", "./maka-uri": "./dist/maka-uri.js", - "./styles.css": "./src/styles.css" + "./styles.css": "./src/styles.css", + "./composer-attachments": "./dist/composer-attachments.js", + "./pending-items": "./dist/pending-items.js", + "./use-composer-attachments": "./dist/use-composer-attachments.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", diff --git a/packages/ui/src/composer-attachments.ts b/packages/ui/src/composer-attachments.ts new file mode 100644 index 0000000000..d978b4ec87 --- /dev/null +++ b/packages/ui/src/composer-attachments.ts @@ -0,0 +1,77 @@ +/* + * 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 type { AttachmentRef } from '@maka/core/events'; + +export type PendingAttachment = { + /** Unique per staged item; keys preview ownership and cleanup. */ + stagingKey: string; + displayName: string; + mimeType?: string; + kind: AttachmentRef['kind']; + size: number; + /** Present only after the URL has decoded successfully. */ + previewUrl?: string; + source: + | { type: 'approval'; approvalId: string; name: string } + | { type: 'file'; file: File } + | { type: 'retained'; attachment: AttachmentRef }; +}; + +export type ComposerIngestInput = + | { approvalId: string; name: string; mimeType?: string } + | { file: File }; + +/** Stable identity across preview-URL merges. */ +export function pendingAttachmentSourceKey( + attachment: PendingAttachment, +): unknown { + if (attachment.source.type === 'approval') { + return `approval:${attachment.source.approvalId}`; + } + if (attachment.source.type === 'file') return attachment.source.file; + return `retained:${JSON.stringify(attachment.source.attachment)}`; +} + +export function toComposerIngestItems( + pending: readonly PendingAttachment[], +): ComposerIngestInput[] { + return pending.flatMap((item) => { + if (item.source.type === 'retained') return []; + return [ + item.source.type === 'approval' + ? { + approvalId: item.source.approvalId, + name: item.source.name, + ...(item.mimeType ? { mimeType: item.mimeType } : {}), + } + : { file: item.source.file }, + ]; + }); +} + +export function retainedAttachmentRefs( + pending: readonly PendingAttachment[], +): AttachmentRef[] { + return pending.flatMap((item) => + item.source.type === 'retained' + ? [structuredClone(item.source.attachment)] + : [], + ); +} diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index de7c8407e8..2692348122 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -314,6 +314,8 @@ export const Composer = forwardRef< pendingDirectories?: readonly import('@maka/core/events').DirectoryReference[]; onRemoveDirectory?(index: number): void; onAttachFilePaths?(files: File[]): void | Promise; + /** Hosts that can submit context without a text prompt opt in. */ + allowAttachmentOnlySend?: boolean; pendingAttachments?: readonly { displayName: string; kind: AttachmentRef['kind']; @@ -1265,7 +1267,7 @@ export const Composer = forwardRef< // `text`. The optional metadata below is a send-time rendering snapshot of // file chips that still exist in the editor, not a second draft state. const text = composerWireText(textPort.getValue()); - if (!text) return; + if (!text && !(props.allowAttachmentOnlySend && props.pendingAttachments?.length)) return; const editable = editableNode(); const workspaceFileReferences = editable ? workspaceFileReferencePositions(editable) : []; const submittedDraftKey = activeDraftKey(); @@ -1453,7 +1455,7 @@ export const Composer = forwardRef< props.sendBlocked || sendPending || importActionBusy || - !text.trim() || + (!text.trim() && !(props.allowAttachmentOnlySend && props.pendingAttachments?.length)) || noModelConnection; // The disabled Send is explanatory only in the no-model dead-end; other // disabled reasons (empty draft, in-flight import) keep the neutral label. diff --git a/packages/ui/src/pending-items.ts b/packages/ui/src/pending-items.ts new file mode 100644 index 0000000000..eb1963ac22 --- /dev/null +++ b/packages/ui/src/pending-items.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +/** Generic keyed staging registry shared by composer surfaces. */ +export type PendingByKey = Record; + +/** + * The bucket the session-less composer stages into, whatever the workspace + * picker points at (#3408). + * + * The rest of the new-task surface is keyed by (profileId, hostId, projectId) + * since #3122, and staged files and quotes were keyed with it. But they are + * in-memory intent, not Host state: nothing persists them, and nothing restores + * them per target. Keying them by the target only made them drop out of the + * composer when the picker moved. + * + * A key that never moves is also the only owner an in-flight submission can + * safely have. `send()` captures the key it submitted from and clears that key + * when it resolves, so a key that followed the picker would leave the files it + * just sent staged under the new target, ready to be sent a second time. This + * one cannot go stale, so no re-keying rule is needed to keep it honest. + */ +export const NEW_TASK_PENDING_KEY = 'new-task'; + +export function selectPending(map: PendingByKey, key: string): T[] { + return map[key] ?? []; +} + +export function appendPending( + map: PendingByKey, + key: string, + items: readonly T[], +): PendingByKey { + return { ...map, [key]: [...(map[key] ?? []), ...items] }; +} + +export function removePending(map: PendingByKey, key: string, index: number): PendingByKey { + const current = map[key] ?? []; + return { ...map, [key]: current.filter((_, i) => i !== index) }; +} + +export function removePendingItems( + map: PendingByKey, + key: string, + items: readonly T[], + identityOf: (item: T) => unknown = (item) => item, +): PendingByKey { + const submitted = new Set(items.map(identityOf)); + const remaining = (map[key] ?? []).filter((item) => !submitted.has(identityOf(item))); + if (remaining.length === 0) return clearPending(map, key); + return { ...map, [key]: remaining }; +} + +export function clearPending(map: PendingByKey, key: string): PendingByKey { + const next = { ...map }; + delete next[key]; + return next; +} diff --git a/apps/desktop/src/renderer/use-composer-attachments.ts b/packages/ui/src/use-composer-attachments.ts similarity index 97% rename from apps/desktop/src/renderer/use-composer-attachments.ts rename to packages/ui/src/use-composer-attachments.ts index 4f762d8847..e5110fb283 100644 --- a/apps/desktop/src/renderer/use-composer-attachments.ts +++ b/packages/ui/src/use-composer-attachments.ts @@ -29,13 +29,10 @@ import { type AttachmentRef, type DirectoryReference, } from '@maka/core/events'; -import { useUiLocale } from '@maka/ui'; import { pendingAttachmentSourceKey, type PendingAttachment, } from './composer-attachments.js'; -import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { appendPending, removePending, @@ -174,7 +171,16 @@ function releasePreviewUrl(url: string | undefined): void { if (url?.startsWith('blob:')) URL.revokeObjectURL(url); } +export interface ComposerAttachmentCopy { + attachmentFailedTitle: string; + tryAgain: string; + imageAttachmentNotDirectTitle: string; + imageAttachmentNotDirectDescription: string; +} + export function useComposerAttachments(options: { + copy: ComposerAttachmentCopy; + formatError(error: unknown, fallback: string): string; draftKey: string; directoryHostId?: string; toastApi: ToastApi; @@ -187,8 +193,7 @@ export function useComposerAttachments(options: { } | undefined; }) { - const uiLocale = useUiLocale(); - const copy = getDesktopConversationCopy(uiLocale).actions; + const copy = options.copy; const [pendingState, setPendingState] = useState({ attachments: {}, directories: {}, @@ -332,7 +337,7 @@ export function useComposerAttachments(options: { } catch (error) { options.toastApi.error( copy.attachmentFailedTitle, - localizedShellErrorMessage(error, copy.tryAgain, uiLocale), + options.formatError(error, copy.tryAgain), ); } } @@ -365,7 +370,7 @@ export function useComposerAttachments(options: { } catch (error) { owner.toastApi.error( copy.attachmentFailedTitle, - localizedShellErrorMessage(error, copy.tryAgain, uiLocale), + options.formatError(error, copy.tryAgain), ); } } From 368b37cd412ab10a81298a569f2c7c9c236235c4 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 10:00:16 +0800 Subject: [PATCH 4/6] fix(workhub): satisfy locale and UI consistency checks Generated-by: Codex --- .../src/renderer/composer-attachments.ts | 2 +- .../src/renderer/features/workhub/index.ts | 2 +- .../features/workhub/ui/workhub-composer.tsx | 41 +++++++----- .../src/renderer/locales/workhub-copy.ts | 64 +++++++++++++++++++ apps/desktop/src/renderer/styles/workhub.css | 4 +- apps/desktop/stories/workhub.stories.tsx | 6 +- docs/astryx-surface-file-inventory.md | 5 +- docs/astryx-surface-file-inventory.paths | 3 + packages/core/src/session.ts | 24 +++++-- .../workhub-coordination-action-gate.test.ts | 24 ++++++- .../workhub-coordination-protocol.test.ts | 63 +++++++++++++++--- .../src/protocol/workhub-coordination.ts | 42 ++++++++++-- .../src/server/execution-composition.ts | 35 ++++++---- .../workhub-coordination-action-gate.ts | 25 ++++++-- .../workhub-coordination-coordinator.ts | 13 +++- 15 files changed, 288 insertions(+), 65 deletions(-) diff --git a/apps/desktop/src/renderer/composer-attachments.ts b/apps/desktop/src/renderer/composer-attachments.ts index c709c41129..900879e9ff 100644 --- a/apps/desktop/src/renderer/composer-attachments.ts +++ b/apps/desktop/src/renderer/composer-attachments.ts @@ -17,4 +17,4 @@ * under the License. */ -export { pendingAttachmentSourceKey, toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment, type ComposerIngestInput } from './features/conversation/index.js'; +export { toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment } from './features/conversation/index.js'; diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts index b138123918..e3e7d8f734 100644 --- a/apps/desktop/src/renderer/features/workhub/index.ts +++ b/apps/desktop/src/renderer/features/workhub/index.ts @@ -28,4 +28,4 @@ export { WorkHubHighlightContext, WorkHubHighlightProvider, workHubIdentityHue } export { WorkHubComposer, type WorkHubComposerServices, type WorkHubComposerSelection } from './ui/workhub-composer.js'; -export { WorkHubComposerServicesProvider, useWorkHubComposerServices } from './services-context.js'; +export { WorkHubComposerServicesProvider } from './services-context.js'; diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx index d749ba5ca1..2ac3781a36 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx @@ -18,13 +18,14 @@ */ import { useRef, useState } from 'react'; -import { Composer, useToast, type ComposerProps, type ChatModelChoice } from '@maka/ui'; +import { Composer, Selector, useToast, type ComposerProps, type ChatModelChoice } from '@maka/ui'; import type { SessionSummary, WorkHubCreateDefaults } from '@maka/core/session'; import { isChatDefaultPermissionMode } from '@maka/core/settings'; import { useComposerAttachments } from '@maka/ui/use-composer-attachments'; import { toComposerIngestItems } from '@maka/ui/composer-attachments'; import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; +import { getWorkHubComposerCopy } from '../../../locales/workhub-copy.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import type { UiLocale } from '@maka/core/ui-locale'; @@ -73,7 +74,7 @@ function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, formatError: (error, fallback) => localizedShellErrorMessage(error, fallback, locale), }); const uploaded = useRef(new Map()); - const chinese = locale === 'zh-CN'; + const copy = getWorkHubComposerCopy(locale); const selected = services?.sessions.find((session) => session.id === selectedId); const unavailable = Boolean(selectedId && !selected); const defaultChoice = services?.modelChoices.find((choice) => defaults.model && @@ -85,20 +86,28 @@ function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, const change = async (operation: () => Promise) => { setChanging(true); try { await operation(); } - catch { toast.error(chinese ? '配置更新失败,请重试' : 'Could not update settings. Try again.'); } + catch { toast.error(copy.settingsUpdateFailed); } finally { setChanging(false); } }; return <>
- - {selected ? (chinese ? '模型与权限用于此 Work' : 'Settings apply to this Work') : (chinese ? '模型与权限用于新 Work' : 'Settings apply to new Work')} +
+ {copy.sendTo} + !session.isArchived).map((session) => ({ value: session.id, label: session.name || session.id })) ?? []), + ]} + onChange={(option) => setSelectedId(option)} + /> +
+ {selected ? (copy.selectedWorkSettings) : (copy.newWorkSettings)}
MAX_ATTACHMENT_COUNT || snapshot.some((item) => item.size > MAX_ATTACHMENT_BYTES)) { - throw new Error(chinese ? '附件数量或大小超过限制' : 'Attachment count or size exceeds the limit'); + throw new Error(copy.attachmentLimitExceeded); } const attachments: AttachmentRef[] = []; for (const item of snapshot) { @@ -124,7 +133,7 @@ function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, } attachments.push(ref); } - const accepted = await onSend(text.trim() || (chinese ? '请查看附件。' : 'Please review the attachments.'), { + const accepted = await onSend(text.trim() || (copy.attachmentPrompt), { ...(selectedId ? { explicitTarget: { sessionId: selectedId } } : { newWorkDefaults: { ...defaults, model: defaultModel } }), ...(attachments.length ? { attachments } : {}), }, () => { @@ -133,7 +142,7 @@ function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, }); return accepted; } catch (error) { - toast.error(chinese ? '发送失败' : 'Could not send', localizedShellErrorMessage(error, chinese ? '请重试' : 'Try again', locale)); + toast.error(copy.sendFailed, localizedShellErrorMessage(error, copy.tryAgain, locale)); return false; } finally { setSubmitting(false); } }} @@ -150,7 +159,7 @@ function ConfiguredWorkHubComposer({ services, locale, attachmentScope, onSend, onPickNewChatModel={services ? (model) => { setDefaults((current) => ({ ...current, model })); } : undefined} onOpenModelSettings={services?.onOpenModelSettings} permissionMode={selected?.permissionMode ?? defaults.permissionMode ?? 'ask'} - permissionModeDisabledReason={settingsLocked ? (chinese ? '当前无法修改配置' : 'Settings are currently locked') : undefined} + permissionModeDisabledReason={settingsLocked ? (copy.settingsLocked) : undefined} onPermissionModeChange={services ? (mode) => change(async () => { if (!isChatDefaultPermissionMode(mode)) return; if (mode === 'bypass' && !await services.confirmBypass()) return; diff --git a/apps/desktop/src/renderer/locales/workhub-copy.ts b/apps/desktop/src/renderer/locales/workhub-copy.ts index d3f4ffdff6..2c717ef125 100644 --- a/apps/desktop/src/renderer/locales/workhub-copy.ts +++ b/apps/desktop/src/renderer/locales/workhub-copy.ts @@ -84,3 +84,67 @@ const COPY = { export function getWorkHubRailCopy(locale: UiLocale): WorkHubRailCopy { return COPY[locale]; } + +interface WorkHubComposerCopy { + readonly settingsUpdateFailed: string; + readonly sendTo: string; + readonly currentWork: string; + readonly routeAutomatically: string; + readonly workUnavailable: string; + readonly selectedWorkSettings: string; + readonly newWorkSettings: string; + readonly attachmentLimitExceeded: string; + readonly attachmentPrompt: string; + readonly sendFailed: string; + readonly tryAgain: string; + readonly settingsLocked: string; +} + +const COMPOSER_COPY = { + 'zh-CN': { + settingsUpdateFailed: '配置更新失败,请重试', + sendTo: '发送到', + currentWork: '当前 Work', + routeAutomatically: '自动识别工作', + workUnavailable: '工作不可用', + selectedWorkSettings: '模型与权限用于此 Work', + newWorkSettings: '模型与权限用于新 Work', + attachmentLimitExceeded: '附件数量或大小超过限制', + attachmentPrompt: '请查看附件。', + sendFailed: '发送失败', + tryAgain: '请重试', + settingsLocked: '当前无法修改配置', + }, + 'zh-TW': { + settingsUpdateFailed: '設定更新失敗,請重試', + sendTo: '傳送至', + currentWork: '目前 Work', + routeAutomatically: '自動識別工作', + workUnavailable: '工作無法使用', + selectedWorkSettings: '模型與權限用於此 Work', + newWorkSettings: '模型與權限用於新 Work', + attachmentLimitExceeded: '附件數量或大小超過限制', + attachmentPrompt: '請查看附件。', + sendFailed: '傳送失敗', + tryAgain: '請重試', + settingsLocked: '目前無法修改設定', + }, + 'en': { + settingsUpdateFailed: 'Could not update settings. Try again.', + sendTo: 'Send to', + currentWork: 'Current Work', + routeAutomatically: 'Route automatically', + workUnavailable: 'Work unavailable', + selectedWorkSettings: 'Settings apply to this Work', + newWorkSettings: 'Settings apply to new Work', + attachmentLimitExceeded: 'Attachment count or size exceeds the limit', + attachmentPrompt: 'Please review the attachments.', + sendFailed: 'Could not send', + tryAgain: 'Try again', + settingsLocked: 'Settings are currently locked', + }, +} satisfies UiCatalog; + +export function getWorkHubComposerCopy(locale: UiLocale): WorkHubComposerCopy { + return COMPOSER_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 5c20a04663..9129d402f9 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -475,6 +475,6 @@ font-size: var(--font-size-body-sm, 12px); color: var(--color-text-secondary); } -.workhub-composer-scope label { display: flex; align-items: center; gap: 8px; min-width: 0; } -.workhub-composer-scope select { max-width: 240px; color: inherit; background: transparent; border: 0; font: inherit; } +.workhub-composer-target { display: flex; align-items: center; gap: 8px; min-width: 0; } +.workhub-composer-target > :last-child { max-width: 240px; min-width: 0; } @media (max-width: 600px) { .workhub-composer-scope { flex-wrap: wrap; } } diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index 9079acf28f..6fa53ec942 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -389,7 +389,8 @@ export const StandardComposer: Story = { await waitFor(() => expect(canvas.getByRole('combobox', { name: '当前 Work' })).toBeEnabled()); await expect(canvas.getByText('模型与权限用于新 Work')).toBeVisible(); await expect(canvas.getByRole('button', { name: '添加上下文' })).toBeEnabled(); - await userEvent.selectOptions(canvas.getByRole('combobox', { name: '当前 Work' }), TARGET_SESSION_ID); + await userEvent.click(canvas.getByRole('combobox', { name: '当前 Work' })); + await userEvent.click(within(canvasElement.ownerDocument.body).getByRole('option', { name: SESSION_NAME })); await expect(canvas.getByText('模型与权限用于此 Work')).toBeVisible(); const page = within(canvasElement.ownerDocument.body); await userEvent.click(canvas.getByRole('button', { name: /切换当前任务模型/ })); @@ -398,7 +399,8 @@ export const StandardComposer: Story = { await userEvent.click(canvas.getByRole('button', { name: /权限模式/ })); await userEvent.click(page.getByRole('menuitemradio', { name: '完全权限' })); await waitFor(() => expect(composerWrites.permission).toHaveBeenCalledWith(TARGET_SESSION_ID, 'bypass')); - await userEvent.selectOptions(canvas.getByRole('combobox', { name: '当前 Work' }), ''); + await userEvent.click(canvas.getByRole('combobox', { name: '当前 Work' })); + await userEvent.click(page.getByRole('option', { name: '自动识别工作' })); await userEvent.click(canvas.getByRole('button', { name: /选择新任务模型/ })); await userEvent.click(page.getByRole('menuitemradio', { name: 'model-b' })); await userEvent.click(canvas.getByRole('button', { name: '添加上下文' })); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4d02b3de02..fe3841cf0f 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 256 files — blocker 0, reimplementation 0, polish 1, aligned 255. +**Totals:** 259 files — blocker 0, reimplementation 0, polish 1, aligned 258. ## Exclusions (explicit) @@ -97,8 +97,11 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx` | shell-chrome-or-panel | Card, ResizeHandle, Spinner | aligned — uses Astryx (Card, ResizeHandle, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx` | shell-chrome-or-panel | Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List, ListItem, Section, Spinner, Tab, TabList | aligned — uses Astryx (Badge, Card, DropdownMenu, DropdownMenuItem, Heading, Icon, Kbd, List) | aligned | | `apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx` | shell-chrome-or-panel | Icon, IconButton, Tooltip | aligned — uses Astryx (Icon, IconButton, Tooltip) | aligned | +| `apps/desktop/src/renderer/features/workhub/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx` | shell-chrome-or-panel | Selector | aligned — uses Astryx (Selector) | aligned | | `apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx` | other | Button, List, ListItem, StatusDot | aligned — uses Astryx (Button, List, ListItem, StatusDot) | aligned | | `apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/keyboard-help.tsx` | dialog-overlay | Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent | aligned — uses Astryx (Dialog, DialogHeader, Heading, Kbd, Layout, LayoutContent) | aligned | | `apps/desktop/src/renderer/live-turn-reconciler.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/maka-tokens.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fd44277143..da07d479ce 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -68,8 +68,11 @@ apps/desktop/src/renderer/features/workbar/ui/side-chat-close-confirmation.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx +apps/desktop/src/renderer/features/workhub/services-context.tsx +apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx apps/desktop/src/renderer/features/workhub/ui/workhub-navigation-rail.tsx apps/desktop/src/renderer/features/workhub/ui/workhub-prompt-rail.tsx +apps/desktop/src/renderer/features/workhub/ui/workhub-work-identity.tsx apps/desktop/src/renderer/keyboard-help.tsx apps/desktop/src/renderer/live-turn-reconciler.tsx apps/desktop/src/renderer/maka-tokens.css diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 18e917d461..e06eddfe00 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -942,14 +942,22 @@ export interface WorkHubCreateDefaults { } export function isWorkHubCreateDefaults(value: unknown): value is WorkHubCreateDefaults { - if (!isRecord(value) || Object.keys(value).some((key) => key !== 'model' && key !== 'permissionMode')) return false; + if ( + !isRecord(value) || + Object.keys(value).some((key) => key !== 'model' && key !== 'permissionMode') + ) + return false; if (value.permissionMode !== undefined && !isPermissionMode(value.permissionMode)) return false; if (value.model === undefined) return true; const model = value.model; - return isRecord(model) && + return ( + isRecord(model) && Object.keys(model).length === 3 && - ['llmConnectionId', 'llmConnectionSlug', 'model'].every((key) => - typeof model[key] === 'string' && model[key].trim().length > 0 && model[key].length <= 512); + ['llmConnectionId', 'llmConnectionSlug', 'model'].every( + (key) => + typeof model[key] === 'string' && model[key].trim().length > 0 && model[key].length <= 512, + ) + ); } export interface WorkHubDelegationCreateSpec { @@ -1739,8 +1747,12 @@ function isWorkHubActionIdentity(message: Record): boolean { function isWorkHubMessageAttachments(value: unknown): boolean { if (value === undefined) return true; - try { decodeMessageContent({ text: '', attachments: value }); return true; } - catch { return false; } + try { + decodeMessageContent({ text: '', attachments: value }); + return true; + } catch { + return false; + } } function isWorkHubDelegationCreateSpec(value: unknown): value is WorkHubDelegationCreateSpec { diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts index 2c89865caa..3d818742d2 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-action-gate.test.ts @@ -1279,8 +1279,23 @@ describe('WorkHub Coordination Action Gate', () => { ); const input = { actionId: 'create', - attachments: [{ name: 'requirements.txt', kind: 'other' as const, mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file' as const, sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }], - newWorkDefaults: { model: { llmConnectionId: 'conn', llmConnectionSlug: 'test', model: 'chosen-model' }, permissionMode: 'ask' as const }, + attachments: [ + { + name: 'requirements.txt', + kind: 'other' as const, + mimeType: 'text/plain', + bytes: 12, + ref: { + kind: 'session_file' as const, + sessionId: 'maka_workhub_coordination', + relativePath: 'artifact-1', + }, + }, + ], + newWorkDefaults: { + model: { llmConnectionId: 'conn', llmConnectionSlug: 'test', model: 'chosen-model' }, + permissionMode: 'ask' as const, + }, userText: 'Create an accessibility audit', proposal: { disposition: 'create_new' as const, title: 'Accessibility audit' }, create: { workspace: { kind: 'host_path' as const, path: '/workspace' } }, @@ -1305,7 +1320,10 @@ describe('WorkHub Coordination Action Gate', () => { (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); await assert.rejects( - new WorkHubCoordinationActionGate(effects).act({ ...input, newWorkDefaults: { ...input.newWorkDefaults, permissionMode: 'bypass' } }, CONTEXT), + new WorkHubCoordinationActionGate(effects).act( + { ...input, newWorkDefaults: { ...input.newWorkDefaults, permissionMode: 'bypass' } }, + CONTEXT, + ), (error) => error instanceof WorkHubActionGateFailure && error.code === 'action_conflict', ); assert.equal(effects.assignments.length, 2); diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts index 93c4a4676d..30c0f9b40e 100644 --- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts @@ -508,14 +508,61 @@ test('WorkHub Coordination action results preserve the admitted disposition', () } }); - test('WorkHub decodes attachment context and user-selected creation defaults without strategy authority', () => { - const attachments = [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'artifact-1' } }]; - const input = { actionId: 'composer-action', userText: 'Create an audit', proposal: { disposition: 'create_new', title: 'Audit' }, create: { workspace: { kind: 'host_path', path: '/workspace' } }, newWorkDefaults: { model: { llmConnectionId: 'connection-1', llmConnectionSlug: 'primary', model: 'chosen-model' }, permissionMode: 'ask' }, attachments }; + const attachments = [ + { + name: 'requirements.txt', + kind: 'other', + mimeType: 'text/plain', + bytes: 12, + ref: { + kind: 'session_file', + sessionId: 'maka_workhub_coordination', + relativePath: 'artifact-1', + }, + }, + ]; + const input = { + actionId: 'composer-action', + userText: 'Create an audit', + proposal: { disposition: 'create_new', title: 'Audit' }, + create: { workspace: { kind: 'host_path', path: '/workspace' } }, + newWorkDefaults: { + model: { + llmConnectionId: 'connection-1', + llmConnectionSlug: 'primary', + model: 'chosen-model', + }, + permissionMode: 'ask', + }, + attachments, + }; assert.deepEqual(decodeWorkHubCoordinationActInput(input), input); - assert.deepEqual(decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-1', text: 'Review file', attachments }).attachments, attachments); - assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, newWorkDefaults: { permissionMode: 'invented' } })); - assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, newWorkDefaults: { ...input.newWorkDefaults, workspace: '/forged' } })); - assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, proposal: { disposition: 'answer_here' }, create: undefined })); - assert.throws(() => decodeWorkHubCoordinationActInput({ ...input, attachments: Array(9).fill(attachments[0]) })); + assert.deepEqual( + decodeWorkHubCoordinationAnswerInput({ turnId: 'answer-1', text: 'Review file', attachments }) + .attachments, + attachments, + ); + assert.throws(() => + decodeWorkHubCoordinationActInput({ + ...input, + newWorkDefaults: { permissionMode: 'invented' }, + }), + ); + assert.throws(() => + decodeWorkHubCoordinationActInput({ + ...input, + newWorkDefaults: { ...input.newWorkDefaults, workspace: '/forged' }, + }), + ); + assert.throws(() => + decodeWorkHubCoordinationActInput({ + ...input, + proposal: { disposition: 'answer_here' }, + create: undefined, + }), + ); + assert.throws(() => + decodeWorkHubCoordinationActInput({ ...input, attachments: Array(9).fill(attachments[0]) }), + ); }); diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts index dd8bf5f666..fb9b6a3e5a 100644 --- a/packages/runtime-host/src/protocol/workhub-coordination.ts +++ b/packages/runtime-host/src/protocol/workhub-coordination.ts @@ -301,9 +301,19 @@ export function decodeWorkHubCoordinationResolveResult( export function decodeWorkHubCoordinationAnswerInput( value: unknown, ): WorkHubCoordinationAnswerInput { - const input = requireShapedRecord(value, 'WorkHub Coordination answer input', ['turnId', 'text'], ['attachments']); + const input = requireShapedRecord( + value, + 'WorkHub Coordination answer input', + ['turnId', 'text'], + ['attachments'], + ); return { - ...(input.attachments !== undefined ? { attachments: decodeMessageContent({ text: input.text, attachments: input.attachments }).attachments! } : {}), + ...(input.attachments !== undefined + ? { + attachments: decodeMessageContent({ text: input.text, attachments: input.attachments }) + .attachments!, + } + : {}), turnId: requireEntityId(input.turnId, 'WorkHub Coordination Turn id'), text: requireUtf8String( input.text, @@ -377,11 +387,20 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi ['candidateSetId', 'create', 'confirmation', 'newWorkDefaults', 'attachments'], ); const proposal = decodeWorkHubCoordinationProposal(input.proposal); - if (input.newWorkDefaults !== undefined && (!isWorkHubCreateDefaults(input.newWorkDefaults) || - !(proposal.disposition === 'create_new' || (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new')))) { + if ( + input.newWorkDefaults !== undefined && + (!isWorkHubCreateDefaults(input.newWorkDefaults) || + !( + proposal.disposition === 'create_new' || + (proposal.disposition === 'replace' && proposal.target.disposition === 'create_new') + )) + ) { throw invalidProtocolFrame('Invalid WorkHub creation defaults'); } - if (input.attachments !== undefined && !['answer_here', 'delegate_existing', 'create_new', 'replace'].includes(proposal.disposition)) { + if ( + input.attachments !== undefined && + !['answer_here', 'delegate_existing', 'create_new', 'replace'].includes(proposal.disposition) + ) { throw invalidProtocolFrame('This WorkHub action does not accept attachments'); } const base = { @@ -392,8 +411,17 @@ export function decodeWorkHubCoordinationActInput(value: unknown): WorkHubCoordi WORKHUB_COORDINATION_TEXT_MAX_BYTES, ), proposal, - ...(input.attachments !== undefined ? { attachments: decodeMessageContent({ text: input.userText, attachments: input.attachments }).attachments! } : {}), - ...(input.newWorkDefaults !== undefined ? { newWorkDefaults: input.newWorkDefaults as WorkHubCreateDefaults } : {}), + ...(input.attachments !== undefined + ? { + attachments: decodeMessageContent({ + text: input.userText, + attachments: input.attachments, + }).attachments!, + } + : {}), + ...(input.newWorkDefaults !== undefined + ? { newWorkDefaults: input.newWorkDefaults as WorkHubCreateDefaults } + : {}), }; if (proposal.disposition === 'delegate_existing') { if ( diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 4f6e72dfd8..f098c3ab81 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1561,13 +1561,17 @@ export async function createExecutionRuntimeHostComposition( sessionId: input.targetSessionId, workspace: input.create.workspace, name: input.create.title, - modelTarget: input.create.defaults?.model ? { - kind: 'explicit', - connectionId: input.create.defaults.model.llmConnectionId, - connectionSlug: input.create.defaults.model.llmConnectionSlug, - model: input.create.defaults.model.model, - } : { kind: 'default' }, - ...(input.create.defaults?.permissionMode ? { permissionMode: input.create.defaults.permissionMode } : {}), + modelTarget: input.create.defaults?.model + ? { + kind: 'explicit', + connectionId: input.create.defaults.model.llmConnectionId, + connectionSlug: input.create.defaults.model.llmConnectionSlug, + model: input.create.defaults.model.model, + } + : { kind: 'default' }, + ...(input.create.defaults?.permissionMode + ? { permissionMode: input.create.defaults.permissionMode } + : {}), collaborationMode: 'agent', orchestrationMode: 'default', }) @@ -1577,10 +1581,19 @@ export async function createExecutionRuntimeHostComposition( .digest('hex') .slice(0, 48); const messageId = `whm_${suffix}`; - const targetAttachments = !durable && input.attachments?.length - ? await copyWorkHubAttachmentsToTarget(openedArtifactStore, artifacts, input.targetSessionId, input.attachments) - : input.attachments; - const content = normalizeMessageContent({ text: input.userText, ...(targetAttachments ? { attachments: targetAttachments } : {}) }); + const targetAttachments = + !durable && input.attachments?.length + ? await copyWorkHubAttachmentsToTarget( + openedArtifactStore, + artifacts, + input.targetSessionId, + input.attachments, + ) + : input.attachments; + const content = normalizeMessageContent({ + text: input.userText, + ...(targetAttachments ? { attachments: targetAttachments } : {}), + }); const persisted = durable ?? (await sessionAdmission.runMany( diff --git a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts index 23d9a04a72..b9c9e88019 100644 --- a/packages/runtime-host/src/server/workhub-coordination-action-gate.ts +++ b/packages/runtime-host/src/server/workhub-coordination-action-gate.ts @@ -117,7 +117,11 @@ export interface WorkHubActionGateEffects { delegationId: string, ): Promise; answer( - input: { readonly turnId: string; readonly text: string; readonly attachments?: AttachmentRef[] }, + input: { + readonly turnId: string; + readonly text: string; + readonly attachments?: AttachmentRef[]; + }, context: ConnectionContext, ): Promise; clarify(input: { @@ -362,7 +366,14 @@ export class WorkHubCoordinationActionGate { if (proposal.disposition === 'answer_here') { const turnId = coordinationTurnId(input.actionId, 'answer'); await this.#claimAction(input.actionId, 'answer_here', fingerprint, turnId); - await this.#effects.answer({ turnId, text: input.userText, ...(input.attachments ? { attachments: input.attachments } : {}) }, context); + await this.#effects.answer( + { + turnId, + text: input.userText, + ...(input.attachments ? { attachments: input.attachments } : {}), + }, + context, + ); return { disposition: 'answer_here', coordinationTurnId: turnId }; } if (proposal.disposition === 'clarify') { @@ -764,8 +775,12 @@ export class WorkHubCoordinationActionGate { targetSessionName: target.title, disposition: 'create_new', userText: input.userText, - ...(input.attachments ? { attachments: input.attachments } : {}), - create: { title: target.title, workspace: input.create.workspace, ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}) }, + ...(input.attachments ? { attachments: input.attachments } : {}), + create: { + title: target.title, + workspace: input.create.workspace, + ...(input.newWorkDefaults ? { defaults: input.newWorkDefaults } : {}), + }, replacesActionId: replaced.actionId, replacesDelegationId: replaced.delegationId, replacedTargetSessionId: replaced.targetSessionId, @@ -810,7 +825,7 @@ export class WorkHubCoordinationActionGate { targetSessionName: destination.sessionName, disposition: 'delegate_existing', userText: input.userText, - ...(input.attachments ? { attachments: input.attachments } : {}), + ...(input.attachments ? { attachments: input.attachments } : {}), replacesActionId: replaced.actionId, replacesDelegationId: replaced.delegationId, replacedTargetSessionId: replaced.targetSessionId, diff --git a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts index 5039b82d99..1084180500 100644 --- a/packages/runtime-host/src/server/workhub-coordination-coordinator.ts +++ b/packages/runtime-host/src/server/workhub-coordination-coordinator.ts @@ -643,7 +643,10 @@ export class HostWorkHubCoordinationCoordinator { turnId: input.turnId, execution: { kind: 'workhub_coordination', - inputDigest: digest({ text: input.text, ...(input.attachments ? { attachments: input.attachments } : {}) }), + inputDigest: digest({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + }), }, archivedMessage: 'WorkHub Coordination Session is unavailable', // A recorded summary owns its Turn identity durably but is admitted @@ -663,7 +666,13 @@ export class HostWorkHubCoordinationCoordinator { } return recorded.length > 0 ? { kind: 'rejected', outcome: turnIdentityConflict() } - : { kind: 'ready', content: normalizeMessageContent({ text: input.text, ...(input.attachments ? { attachments: input.attachments } : {}) }) }; + : { + kind: 'ready', + content: normalizeMessageContent({ + text: input.text, + ...(input.attachments ? { attachments: input.attachments } : {}), + }), + }; }, }, context, From 2b07fc630dcc29027a7719c97ab655034b38c701 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 10:09:02 +0800 Subject: [PATCH 5/6] fix(desktop): explicitly export composer attachment APIs Generated-by: Codex --- apps/desktop/src/renderer/features/conversation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index ea87193c3d..95a408f8d2 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -54,6 +54,6 @@ export { SessionLocalMessages } from './controller/session-local-messages.js'; export { restoreTranscriptTailAfterSend } from './controller/transcript-reading-position.js'; export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; -export * from '@maka/ui/composer-attachments'; -export * from '@maka/ui/pending-items'; +export { toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment } from '@maka/ui/composer-attachments'; +export { NEW_TASK_PENDING_KEY, selectPending, appendPending, removePending, removePendingItems, clearPending, type PendingByKey } from '@maka/ui/pending-items'; export { desktopSlashCommandPresentation } from './model/slash-command-presentation.js'; From d2f7b963424588d0861617a7a83adcd13e4ba611 Mon Sep 17 00:00:00 2001 From: 404ARE <936233544@qq.com> Date: Tue, 8 Sep 2026 10:33:27 +0800 Subject: [PATCH 6/6] fix(workhub): align intermediate widths and scope repeated actions Generated-by: Codex --- apps/desktop/src/renderer/styles/workhub.css | 5 +---- apps/desktop/src/renderer/workhub-surface.tsx | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css index 9129d402f9..bec8666f5d 100644 --- a/apps/desktop/src/renderer/styles/workhub.css +++ b/apps/desktop/src/renderer/styles/workhub.css @@ -82,10 +82,7 @@ display: grid; width: 100%; min-width: 0; - grid-template-columns: minmax(180px, 220px) minmax( - 0, - var(--maka-reading-measure) - ) minmax(180px, 220px); + grid-template-columns: minmax(0, 220px) var(--maka-reading-measure) minmax(0, 220px); gap: var(--space-6, 24px); justify-content: center; } diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 255de41673..b62a4f5955 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -876,6 +876,7 @@ function WorkHubMessageFrame(props: {