diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 47c8ed17f..a63caddcb 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -306,8 +306,12 @@ export function ChatPage({ // are global-only pages now, reached from the shell rail — no // per-workbench header button or `/run` command opens a scoped view // of either here. - onCreateRoutineInSpace={(inSpaceWorkbenchId) => - openRoutine({ routineId: null, workbenchId: inSpaceWorkbenchId }) + onCreateRoutineInSpace={(inSpaceWorkbenchId, preselectedAssetId) => + openRoutine({ + routineId: null, + workbenchId: inSpaceWorkbenchId, + ...(preselectedAssetId !== undefined ? { preselectedAssetId } : {}), + }) } onWorkbenchNotFound={reportWorkbenchNotFound} onGoToMissionControl={() => navigate(MISSION_CONTROL_PATH)} diff --git a/apps/web/src/shell/canvas-availability.tsx b/apps/web/src/shell/canvas-availability.tsx index f36889abd..f45ad4d07 100644 --- a/apps/web/src/shell/canvas-availability.tsx +++ b/apps/web/src/shell/canvas-availability.tsx @@ -64,6 +64,14 @@ export type RoutinePanelSubject = { * in which case the panel falls back to this workbench's own default * (Myra) workbench — never mints a new one. */ readonly workbenchId?: string; + /** Seeds the target picker's initial selection (CL-7356) — the + * conversation's own single agent participant's definition asset id, + * when the opener could resolve exactly one. Shown visibly in + * `DefinitionTargetPicker` and freely replaceable/clearable by the + * person; only their final explicit pick is ever sent to the backend. + * Omitted whenever the opener found zero or several candidates, or has + * no conversation to derive one from at all. */ + readonly preselectedAssetId?: string; }; /** Workbench's concrete instantiation of `@corbits/shell-layout`'s generic diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index e48785faa..66981e542 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -683,6 +683,9 @@ function RoutineEditorPanel({ tenantId={tenantId} value={targetAssetId} onChange={pickTarget} + {...(subject.preselectedAssetId !== undefined + ? { preselectedAssetId: subject.preselectedAssetId } + : {})} /> {needsTargetHint && targetAssetId === null ? (

diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index baab1fbfb..f8a0c167a 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -526,6 +526,51 @@ describe("RoutinePanel", () => { expect(select.value).toBe(""); }); + // CL-7356: `/routine`'s (and the palette action's) optional + // preselection — computed upstream from the conversation's own agent + // participants — is carried on the subject and shown visibly here, + // never inferred by the panel itself. + test("a subject with no preselectedAssetId opens with nothing chosen (zero or several agent participants upstream)", async () => { + await renderPanel({ routineId: null, workbenchId: "ch_1" }); + await settle(); + const select = container.querySelector( + "#routine-panel-target", + ) as HTMLSelectElement; + expect(select.value).toBe(""); + }); + + test("a subject with preselectedAssetId shows that target already chosen, visibly", async () => { + await renderPanel({ + routineId: null, + workbenchId: "ch_1", + preselectedAssetId: "asset_myra", + }); + await settle(); + const select = container.querySelector( + "#routine-panel-target", + ) as HTMLSelectElement; + expect(select.value).toBe("asset_myra"); + }); + + test("a preselected target is replaceable: picking a different one, then naming the routine, creates with the newly picked definitionAssetId", async () => { + await renderPanel({ + routineId: null, + workbenchId: "ch_1", + preselectedAssetId: "asset_myra", + }); + await settle(); + + selectTarget("asset_digest"); + await settle(); + + const name = fieldByLabel("Name this routine") as HTMLInputElement; + fillAndBlur(name, "Morning digest"); + await settle(); + + expect(createRoutineCalls).toHaveLength(1); + expect(createRoutineCalls[0]?.["definitionAssetId"]).toBe("asset_digest"); + }); + test("empty target list shows the empty state with a link to Agents settings, not a picker", async () => { targets = []; await renderPanel({ routineId: null, workbenchId: "ch_1" }); diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 95f264e2d..7d3e122d5 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -694,7 +694,10 @@ function ChatWorkspaceInner({ * CL-6099) are global-only pages now — reached from the shell rail, not * a per-workbench header button or composer command. */ - readonly onCreateRoutineInSpace?: (workbenchId: string) => void; + readonly onCreateRoutineInSpace?: ( + workbenchId: string, + preselectedAssetId?: string, + ) => void; /** Fired when the routed workbench 404s — a deleted workbench, or a stale * Recents entry that outlived it. The host owns Recents (this package * never touches localStorage), so it's told rather than reaching out. */ @@ -1150,6 +1153,22 @@ function ChatWorkspaceInner({ : Promise.resolve([]), enabled: activeWorkbenchId !== null, }); + // `/routine`'s and "New routine in this space"'s optional preselection + // (CL-7356): exactly one agent participant hands its definition asset id + // straight to the routine panel's picker, visibly and replaceably — zero + // or several participants leave the picker with nothing chosen, same as + // opening it from `/routines` (CL-7357). Not a guarantee: this reads + // `workbenchAgentsQuery`'s current data, which can still be loading (or + // mid-refetch after a participant just joined/left) the moment `/routine` + // fires — a person who types it before the query resolves gets no + // preselection even with exactly one agent, silently. In the common case + // the query is already warm (`failedTurnRecovery` below reads the same + // data), so this is rarely hit in practice; it's a soft nicety, not + // something a caller should rely on always firing. + const singleWorkbenchAgentDefinitionAssetId: string | undefined = + workbenchAgentsQuery.data?.length === 1 + ? workbenchAgentsQuery.data[0]?.definitionAssetId + : undefined; const failedTurnRecovery = useMemo((): FailedTurnRecovery => { const definitionIdByAddress: Record = {}; for (const agent of workbenchAgentsQuery.data ?? []) { @@ -1720,7 +1739,10 @@ function ChatWorkspaceInner({ onCreateRoutineInSpace !== undefined && activeWorkbenchId !== null ) { - onCreateRoutineInSpace(activeWorkbenchId); + onCreateRoutineInSpace( + activeWorkbenchId, + singleWorkbenchAgentDefinitionAssetId, + ); return; } toast(CHAT_STRINGS.runRoutineUnavailable); @@ -1855,7 +1877,10 @@ export function ChatWorkspace({ tenantId: string, ) => Promise; /** "New routine in this space" — see `ChatWorkspaceInner`'s prop note. */ - readonly onCreateRoutineInSpace?: (workbenchId: string) => void; + readonly onCreateRoutineInSpace?: ( + workbenchId: string, + preselectedAssetId?: string, + ) => void; /** See `ChatWorkspaceInner`'s prop of the same name. */ readonly onWorkbenchNotFound?: (workbenchId: string) => void; /** See `ChatWorkspaceInner`'s prop of the same name. */ diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index df2e9af84..5a411ef8b 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -50,10 +50,20 @@ const WORKBENCH_WIRE = { participants: [] as { address: string; handle: string }[], }; +type WorkbenchAgentFixture = { + readonly address: string; + readonly handle: string; + readonly definitionId: string; + readonly definitionAssetId: string; +}; + function stubFetch( sentMessages?: unknown[], workbench: typeof WORKBENCH_WIRE = WORKBENCH_WIRE, - options: { readonly turnsFail?: boolean } = {}, + options: { + readonly turnsFail?: boolean; + readonly workbenchAgents?: readonly WorkbenchAgentFixture[]; + } = {}, ) { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -82,6 +92,9 @@ function stubFetch( if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { return json({ items: [] }); } + if (/\/chat\/workbenches\/[^/]+\/agents$/.test(path)) { + return json({ items: options.workbenchAgents ?? [] }); + } // CL-6380 catch-up: empty list = nothing running. Returning this by // default keeps agent-participant mounts from treating an unstubbed // turns path as a resume failure (CL-6833). @@ -933,6 +946,99 @@ describe("composer slash commands — each wired command's real action", () => { harness.unmount(); }); + test("/routine with zero agent participants passes no preselection (CL-7356)", async () => { + stubFetch(undefined, WORKBENCH_WIRE, { workbenchAgents: [] }); + const opened: (string | undefined)[] = []; + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + onCreateRoutineInSpace: ( + workbenchId: string, + preselectedAssetId?: string, + ) => { + opened.push(workbenchId, preselectedAssetId); + }, + }); + await harness.settle(); + + const textarea = typeInComposer(harness.container, "/routine"); + pressEnter(textarea); + await harness.settle(); + + expect(opened).toEqual(["ch_1", undefined]); + harness.unmount(); + }); + + test("/routine with two agent participants passes no preselection (CL-7356)", async () => { + stubFetch(undefined, WORKBENCH_WIRE, { + workbenchAgents: [ + { + address: "agent:asset_echo/ins_1", + handle: "echo", + definitionId: "def_echo", + definitionAssetId: "asset_echo", + }, + { + address: "agent:asset_digest/ins_2", + handle: "digest", + definitionId: "def_digest", + definitionAssetId: "asset_digest", + }, + ], + }); + const opened: (string | undefined)[] = []; + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + onCreateRoutineInSpace: ( + workbenchId: string, + preselectedAssetId?: string, + ) => { + opened.push(workbenchId, preselectedAssetId); + }, + }); + await harness.settle(); + + const textarea = typeInComposer(harness.container, "/routine"); + pressEnter(textarea); + await harness.settle(); + + expect(opened).toEqual(["ch_1", undefined]); + harness.unmount(); + }); + + test("/routine with exactly one agent participant preselects its definition asset id (CL-7356)", async () => { + stubFetch(undefined, WORKBENCH_WIRE, { + workbenchAgents: [ + { + address: "agent:asset_echo/ins_1", + handle: "echo", + definitionId: "def_echo", + definitionAssetId: "asset_echo", + }, + ], + }); + const opened: (string | undefined)[] = []; + const harness = await mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + onCreateRoutineInSpace: ( + workbenchId: string, + preselectedAssetId?: string, + ) => { + opened.push(workbenchId, preselectedAssetId); + }, + }); + await harness.settle(); + + const textarea = typeInComposer(harness.container, "/routine"); + pressEnter(textarea); + await harness.settle(); + + expect(opened).toEqual(["ch_1", "asset_echo"]); + harness.unmount(); + }); + test("/routine with no host-supplied hop wired falls back to an unavailable toast", async () => { stubFetch(); const harness = await mount({