Start a workflow from the switcher

This commit is contained in:
Ian Gulliver
2026-10-16 11:00:09 -07:00
parent db58d76837
commit eb0c80a645
12 changed files with 383 additions and 223 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+63 -13
View File
@@ -1,4 +1,4 @@
import { post } from "./api.js";
import { get, post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { focusLead } from "./panes.js";
import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
@@ -51,6 +51,7 @@ const shortcuts = [
["↑ ↓", "Move the highlight"],
["Enter", "Pick the highlighted row"],
["name: title", "Create the task in the project whose name starts with name"],
["Esc", "Back from the workflow list to the task list"],
],
],
[
@@ -81,12 +82,14 @@ export function openSwitcher() {
if (document.querySelector(".switcher")) {
return;
}
const input = h("input", { placeholder: "Go to a task, or type a title to create one…", autocomplete: "off" });
const placeholder = "Go to a task, or type a title to create one…";
const input = h("input", { placeholder, autocomplete: "off" });
const list = h("ul", { class: "switcher-list" });
const backdrop = h("div", { class: "switcher-backdrop" });
const box = h("div", { class: "switcher" }, input, list);
let selected = 0;
let rows = [];
let picking = null;
const offs = onEach(["projects", "tasks", "sessions", "inbox"], () => render());
const onEscape = (e) => {
if (e.key !== "Escape") {
@@ -94,6 +97,14 @@ export function openSwitcher() {
}
e.preventDefault();
e.stopPropagation();
if (picking) {
input.value = picking.typed;
input.placeholder = placeholder;
picking = null;
selected = 0;
render();
return;
}
close();
};
addEventListener("keydown", onEscape, { capture: true });
@@ -107,17 +118,62 @@ export function openSwitcher() {
close();
focusTask(taskId, state.inbox.some((item) => item.task_id === taskId) ? "inbox" : "transcript");
};
const create = async (projectId, title) => {
const create = async (projectId, title, workflow) => {
close();
try {
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title } });
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title, workflow } });
go(task.id);
}
catch (err) {
void showAlert(errorText(err));
}
};
const pickWorkflow = async (project, title) => {
const p = { project, title, typed: input.value, workflows: undefined };
picking = p;
input.value = "";
input.placeholder = `Pick a workflow to start in ${project.name}: ${title}`;
selected = 0;
render();
input.focus();
try {
p.workflows = await get("/api/workflows");
}
catch (err) {
close();
void showAlert(errorText(err));
return;
}
if (picking === p) {
render();
}
};
const renderWorkflows = (p) => {
const q = input.value.trim().toLowerCase();
rows = (p.workflows ?? [])
.filter((w) => `${w.name} ${w.description}`.toLowerCase().includes(q))
.map((w) => {
const el = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "Start ", h("span", { class: "workflow-name" }, w.name), " in ", h("span", { class: "project-name" }, p.project.name), ": ", p.title, w.description && h("span", { class: "workflow-description" }, w.description)), h("span", { class: "task-state" }, "workflow"));
return { el, pick: () => void create(p.project.id, p.title, w.name) };
});
if (p.workflows === undefined) {
return "Loading workflows…";
}
return p.workflows.length === 0 ? "No saved workflows." : "No workflow matches.";
};
const render = () => {
const empty = picking ? renderWorkflows(picking) : renderTasks();
selected = Math.min(selected, Math.max(rows.length - 1, 0));
rows.forEach((row, i) => {
row.el.classList.toggle("selected", i === selected);
row.el.onmousedown = (e) => {
e.preventDefault();
row.pick();
};
});
replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, empty));
};
const renderTasks = () => {
const typed = input.value.trim();
const prefix = /^([^:\s]+):\s*(.*)$/.exec(typed);
const named = prefix ? state.projects.filter((p) => p.name.toLowerCase().startsWith(prefix[1].toLowerCase())) : [];
@@ -149,16 +205,10 @@ export function openSwitcher() {
for (const project of title ? (targets ?? (fallback ? [fallback] : [])) : []) {
const el = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "New task in ", h("span", { class: "project-name" }, project.name), ": ", title), h("span", { class: "task-state", title: "Model for the first message" }, models[0]));
rows.push({ el, pick: () => void create(project.id, title) });
const wf = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "Start workflow… in ", h("span", { class: "project-name" }, project.name), ": ", title), h("span", { class: "task-state" }, "workflow"));
rows.push({ el: wf, pick: () => void pickWorkflow(project, title) });
}
selected = Math.min(selected, Math.max(rows.length - 1, 0));
rows.forEach((row, i) => {
row.el.classList.toggle("selected", i === selected);
row.el.onmousedown = (e) => {
e.preventDefault();
row.pick();
};
});
replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, "No active tasks. Type a title to create one."));
return "No active tasks. Type a title to create one.";
};
input.oninput = () => {
selected = 0;
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -185,7 +185,7 @@ export interface paths {
};
get: operations["listTasks"];
put?: never;
/** @description Creates the task's branch and worktree from the project's default branch. */
/** @description Creates the task's branch and worktree from the project's default branch, or with workflow, starts a run of that workflow from it. */
post: operations["createTask"];
delete?: never;
options?: never;
@@ -978,6 +978,8 @@ export interface components {
};
TaskCreate: {
title: string;
/** @description The name of a saved workflow to run instead of creating one task with no agent: each stage with no after stages becomes its own task, titled "<title>: <stage>", with the title as the run's brief. The response is the first of those tasks. */
workflow?: string;
};
WorkflowSummary: {
name: string;
+1
View File
@@ -21,6 +21,7 @@ export type Participant = Schemas["Participant"];
export type Focus = Schemas["Focus"];
export type TaskTab = Schemas["TaskTab"];
export type LeadNote = Schemas["LeadNote"];
export type WorkflowSummary = Schemas["WorkflowSummary"];
type Method = "get" | "post" | "put" | "delete";
+85 -13
View File
@@ -1,4 +1,4 @@
import { post } from "./api.js";
import { get, post, type Project, type WorkflowSummary } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { focusLead } from "./panes.js";
import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
@@ -49,6 +49,7 @@ const shortcuts: [string, [string, string][]][] = [
["↑ ↓", "Move the highlight"],
["Enter", "Pick the highlighted row"],
["name: title", "Create the task in the project whose name starts with name"],
["Esc", "Back from the workflow list to the task list"],
],
],
[
@@ -89,12 +90,14 @@ export function openSwitcher(): void {
return;
}
const input = h("input", { placeholder: "Go to a task, or type a title to create one…", autocomplete: "off" });
const placeholder = "Go to a task, or type a title to create one…";
const input = h("input", { placeholder, autocomplete: "off" });
const list = h("ul", { class: "switcher-list" });
const backdrop = h("div", { class: "switcher-backdrop" });
const box = h("div", { class: "switcher" }, input, list);
let selected = 0;
let rows: { el: HTMLElement; pick: () => void }[] = [];
let picking: { project: Project; title: string; typed: string; workflows: WorkflowSummary[] | undefined } | null = null;
const offs = onEach(["projects", "tasks", "sessions", "inbox"], () => render());
const onEscape = (e: KeyboardEvent) => {
@@ -103,6 +106,14 @@ export function openSwitcher(): void {
}
e.preventDefault();
e.stopPropagation();
if (picking) {
input.value = picking.typed;
input.placeholder = placeholder;
picking = null;
selected = 0;
render();
return;
}
close();
};
addEventListener("keydown", onEscape, { capture: true });
@@ -118,17 +129,79 @@ export function openSwitcher(): void {
focusTask(taskId, state.inbox.some((item) => item.task_id === taskId) ? "inbox" : "transcript");
};
const create = async (projectId: string, title: string) => {
const create = async (projectId: string, title: string, workflow?: string) => {
close();
try {
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title } });
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title, workflow } });
go(task.id);
} catch (err) {
void showAlert(errorText(err));
}
};
const pickWorkflow = async (project: Project, title: string) => {
const p = { project, title, typed: input.value, workflows: undefined as WorkflowSummary[] | undefined };
picking = p;
input.value = "";
input.placeholder = `Pick a workflow to start in ${project.name}: ${title}`;
selected = 0;
render();
input.focus();
try {
p.workflows = await get("/api/workflows");
} catch (err) {
close();
void showAlert(errorText(err));
return;
}
if (picking === p) {
render();
}
};
const renderWorkflows = (p: NonNullable<typeof picking>) => {
const q = input.value.trim().toLowerCase();
rows = (p.workflows ?? [])
.filter((w) => `${w.name} ${w.description}`.toLowerCase().includes(q))
.map((w) => {
const el = h(
"li",
{ class: "new-task" },
h(
"span",
{ class: "task-title" },
"Start ",
h("span", { class: "workflow-name" }, w.name),
" in ",
h("span", { class: "project-name" }, p.project.name),
": ",
p.title,
w.description && h("span", { class: "workflow-description" }, w.description),
),
h("span", { class: "task-state" }, "workflow"),
);
return { el, pick: () => void create(p.project.id, p.title, w.name) };
});
if (p.workflows === undefined) {
return "Loading workflows…";
}
return p.workflows.length === 0 ? "No saved workflows." : "No workflow matches.";
};
const render = () => {
const empty = picking ? renderWorkflows(picking) : renderTasks();
selected = Math.min(selected, Math.max(rows.length - 1, 0));
rows.forEach((row, i) => {
row.el.classList.toggle("selected", i === selected);
row.el.onmousedown = (e) => {
e.preventDefault();
row.pick();
};
});
replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, empty));
};
const renderTasks = () => {
const typed = input.value.trim();
const prefix = /^([^:\s]+):\s*(.*)$/.exec(typed);
const named = prefix ? state.projects.filter((p) => p.name.toLowerCase().startsWith(prefix[1]!.toLowerCase())) : [];
@@ -181,16 +254,15 @@ export function openSwitcher(): void {
h("span", { class: "task-state", title: "Model for the first message" }, models[0]),
);
rows.push({ el, pick: () => void create(project.id, title) });
const wf = h(
"li",
{ class: "new-task" },
h("span", { class: "task-title" }, "Start workflow… in ", h("span", { class: "project-name" }, project.name), ": ", title),
h("span", { class: "task-state" }, "workflow"),
);
rows.push({ el: wf, pick: () => void pickWorkflow(project, title) });
}
selected = Math.min(selected, Math.max(rows.length - 1, 0));
rows.forEach((row, i) => {
row.el.classList.toggle("selected", i === selected);
row.el.onmousedown = (e) => {
e.preventDefault();
row.pick();
};
});
replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, "No active tasks. Type a title to create one."));
return "No active tasks. Type a title to create one.";
};
input.oninput = () => {
+9
View File
@@ -2180,6 +2180,15 @@ button.danger-primary {
color: var(--accent);
}
.switcher-list .workflow-name {
font-weight: 700;
}
.switcher-list .workflow-description {
display: block;
color: var(--muted);
}
.switcher-list li.selected {
background: var(--accent-tint);
}