From 569c1f262bde7311864b83cbf604ab6db6c37bed Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:23:58 -0700 Subject: [PATCH 1/4] Add tests for a visible, replaceable /routine target preselection (CL-7356) Covers the routine panel honoring RoutinePanelSubject.preselectedAssetId (shown chosen, and freely replaceable) and ChatWorkspace computing that preselection from the conversation's own agent participants: none when there are zero or several, the single agent's definitionAssetId when there is exactly one. --- apps/web/test/routine-panel.test.tsx | 47 ++++++++ packages/chat-ui/test/chat-workspace.test.tsx | 108 +++++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index baab1fbfb..57a4e67b0 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -526,6 +526,53 @@ 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/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({ From f7254e1bc9284e8aaa28b451c1849a38030bc607 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:24:07 -0700 Subject: [PATCH 2/4] /routine passes a visible, replaceable target preselection (CL-7356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CL-7355 already removed every invited-agent guard around routine creation (CL-7357): DefinitionTargetPicker never auto-selects, and the panel opens identically whether invoked from the routines page, the palette, or a chat with no agents — there was nothing left to delete. This wires DefinitionTargetPicker's existing preselectedAssetId prop end to end: ChatWorkspace resolves the active workbench's agent participants (already fetched for the failed-turn model picker) and, only when there is exactly one, hands its definitionAssetId through onCreateRoutineInSpace to RoutinePanelSubject.preselectedAssetId. The picker shows it chosen but never disables changing or clearing it, and only the person's final explicit selection is ever sent to POST /routines — unchanged from CL-7355. --- apps/web/src/pages/chat-page.tsx | 8 ++++++-- apps/web/src/shell/canvas-availability.tsx | 8 ++++++++ apps/web/src/shell/routine-panel.tsx | 3 +++ packages/chat-ui/src/chat-workspace.tsx | 24 +++++++++++++++++++--- 4 files changed, 38 insertions(+), 5 deletions(-) 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/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 95f264e2d..2fe6a6d9c 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,15 @@ 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). + 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 +1732,10 @@ function ChatWorkspaceInner({ onCreateRoutineInSpace !== undefined && activeWorkbenchId !== null ) { - onCreateRoutineInSpace(activeWorkbenchId); + onCreateRoutineInSpace( + activeWorkbenchId, + singleWorkbenchAgentDefinitionAssetId, + ); return; } toast(CHAT_STRINGS.runRoutineUnavailable); @@ -1855,7 +1870,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. */ From 7b40ecc85c63e87b38ea6245076d9458ffd53c2d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 01:18:46 -0700 Subject: [PATCH 3/4] Address review findings (CL-7357) --- packages/chat-ui/src/chat-workspace.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 2fe6a6d9c..7d3e122d5 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -1157,7 +1157,14 @@ function ChatWorkspaceInner({ // (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). + // 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 From 5aedddfdfa82fdc020b47f637719bb26e6a0cc28 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 03:38:07 -0700 Subject: [PATCH 4/4] Fix formatting (CL-7357) --- apps/web/test/routine-panel.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index 57a4e67b0..f8a0c167a 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -568,9 +568,7 @@ describe("RoutinePanel", () => { await settle(); expect(createRoutineCalls).toHaveLength(1); - expect(createRoutineCalls[0]?.["definitionAssetId"]).toBe( - "asset_digest", - ); + 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 () => {