Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/shell/canvas-availability.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/shell/routine-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,9 @@ function RoutineEditorPanel({
tenantId={tenantId}
value={targetAssetId}
onChange={pickTarget}
{...(subject.preselectedAssetId !== undefined
? { preselectedAssetId: subject.preselectedAssetId }
: {})}
/>
{needsTargetHint && targetAssetId === null ? (
<p className="text-xs text-[var(--ui-danger)]" role="alert">
Expand Down
45 changes: 45 additions & 0 deletions apps/web/test/routine-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
31 changes: 28 additions & 3 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, string> = {};
for (const agent of workbenchAgentsQuery.data ?? []) {
Expand Down Expand Up @@ -1720,7 +1739,10 @@ function ChatWorkspaceInner({
onCreateRoutineInSpace !== undefined &&
activeWorkbenchId !== null
) {
onCreateRoutineInSpace(activeWorkbenchId);
onCreateRoutineInSpace(
activeWorkbenchId,
singleWorkbenchAgentDefinitionAssetId,
);
return;
}
toast(CHAT_STRINGS.runRoutineUnavailable);
Expand Down Expand Up @@ -1855,7 +1877,10 @@ export function ChatWorkspace({
tenantId: string,
) => Promise<readonly BringInMember[]>;
/** "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. */
Expand Down
108 changes: 107 additions & 1 deletion packages/chat-ui/test/chat-workspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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({
Expand Down
Loading