256 lines
11 KiB
JavaScript
256 lines
11 KiB
JavaScript
import { get, post } from "./api.js";
|
|
import { errorText, h, replace, showAlert } from "./dom.js";
|
|
import { focusLead } from "./panes.js";
|
|
import { summaryMark } from "./project.js";
|
|
import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, openSummary, state, taskState, unreadSummary } from "./state.js";
|
|
import { confirmMerge, models } from "./task.js";
|
|
import { taskStyle } from "./taskcolor.js";
|
|
export function bindShortcuts() {
|
|
addEventListener("keydown", (e) => {
|
|
if (!(e.metaKey || e.ctrlKey) || document.querySelector("dialog[open]")) {
|
|
return;
|
|
}
|
|
if (e.key === "k") {
|
|
e.preventDefault();
|
|
openSwitcher();
|
|
}
|
|
else if (e.key === "p") {
|
|
e.preventDefault();
|
|
focusTab("project");
|
|
}
|
|
else if (e.key === "l") {
|
|
e.preventDefault();
|
|
focusLead();
|
|
}
|
|
else if (e.key === "s") {
|
|
e.preventDefault();
|
|
if (state.selectedTaskId) {
|
|
void confirmMerge(state.selectedTaskId);
|
|
}
|
|
}
|
|
else if (e.key === "/") {
|
|
e.preventDefault();
|
|
showShortcuts();
|
|
}
|
|
});
|
|
}
|
|
const shortcuts = [
|
|
[
|
|
"Anywhere",
|
|
[
|
|
["⌘K", "Switch task, or create one"],
|
|
["⌘P", "Open the Project tab"],
|
|
["⌘L", "Open the Lead tab and its composer"],
|
|
["⌘S", "Merge the right pane's task"],
|
|
["⌘/", "Show these shortcuts"],
|
|
["Esc", "Close the switcher or a dialog"],
|
|
],
|
|
],
|
|
[
|
|
"Task switcher",
|
|
[
|
|
["↑ ↓", "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"],
|
|
],
|
|
],
|
|
[
|
|
"Composer and inbox answers",
|
|
[
|
|
["Enter", "Send"],
|
|
["Shift+Enter", "New line"],
|
|
["Enter / Space", "Pick the focused choice"],
|
|
],
|
|
],
|
|
[
|
|
"Dialogs",
|
|
[["Enter", "Confirm"]],
|
|
],
|
|
[
|
|
"Terminal",
|
|
[["Ctrl+…", "Goes to the shell, not to byre"]],
|
|
],
|
|
];
|
|
function showShortcuts() {
|
|
void showAlert([
|
|
h("h2", { class: "shortcuts-title" }, "Keyboard shortcuts"),
|
|
...shortcuts.map(([group, rows]) => h("section", { class: "shortcuts-group" }, h("h3", {}, group), h("dl", {}, ...rows.flatMap(([keys, what]) => [h("dt", {}, h("kbd", {}, keys)), h("dd", {}, what)])))),
|
|
h("p", { class: "hint" }, "Ctrl works in place of ⌘."),
|
|
]);
|
|
}
|
|
export function openSwitcher() {
|
|
if (document.querySelector(".switcher")) {
|
|
return;
|
|
}
|
|
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", "summaries"], () => render());
|
|
const onEscape = (e) => {
|
|
if (e.key !== "Escape") {
|
|
return;
|
|
}
|
|
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 });
|
|
const close = () => {
|
|
offs();
|
|
removeEventListener("keydown", onEscape, { capture: true });
|
|
backdrop.remove();
|
|
box.remove();
|
|
};
|
|
const go = (taskId) => {
|
|
close();
|
|
const task = findTask(taskId);
|
|
if (state.inbox.some((item) => item.task_id === taskId)) {
|
|
focusTask(taskId, "inbox");
|
|
}
|
|
else if (task && unreadSummary(task)) {
|
|
openSummary(task);
|
|
}
|
|
else {
|
|
focusTask(taskId, "transcript");
|
|
}
|
|
};
|
|
const create = async (projectId, title, body = {}) => {
|
|
close();
|
|
try {
|
|
const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title, ...body } });
|
|
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, { workflow: 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));
|
|
rows[selected]?.el.scrollIntoView({ block: "nearest" });
|
|
};
|
|
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())) : [];
|
|
const targets = named.length > 0 ? named : undefined;
|
|
const title = targets ? prefix[2] : typed;
|
|
const q = title.toLowerCase();
|
|
const inbox = (taskId) => state.inbox.filter((item) => item.task_id === taskId).length;
|
|
const visits = lastVisits();
|
|
const matches = activeTasks()
|
|
.filter(({ project, task }) => targets ? targets.some((p) => p.id === project.id) && task.title.toLowerCase().includes(q) : `${project.name} ${task.title}`.toLowerCase().includes(q))
|
|
.map((m) => ({ ...m, count: inbox(m.task.id), visited: m.task.id === state.selectedTaskId ? Infinity : (visits[m.task.id] ?? 0) }))
|
|
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0) || a.visited - b.visited);
|
|
rows = matches.map(({ project, task, count }) => {
|
|
const ts = taskState(task);
|
|
const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts.kind}`, title: ts.title }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `), h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), summaryMark(task), h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label));
|
|
return { el, pick: () => go(task.id) };
|
|
});
|
|
for (const p of (targets ?? state.projects).filter((p) => `${p.name} project`.toLowerCase().includes(q))) {
|
|
const el = h("li", {}, h("span", { class: "dot none" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, p.name)), h("span", { class: "task-state" }, "project"));
|
|
rows.push({
|
|
el,
|
|
pick: () => {
|
|
close();
|
|
focusProject(p.id);
|
|
},
|
|
});
|
|
}
|
|
const fallback = focusedProject() ?? state.projects[0];
|
|
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 start = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "Start in ", h("span", { class: "project-name" }, project.name), ": ", title), h("span", { class: "task-state", title: "The title is the agent's first message" }, models[0]));
|
|
rows.push({ el: start, pick: () => void create(project.id, title, { prompt: title, model: models[0] }) });
|
|
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) });
|
|
}
|
|
return "No active tasks. Type a title to create one.";
|
|
};
|
|
input.oninput = () => {
|
|
selected = 0;
|
|
render();
|
|
};
|
|
input.onkeydown = (e) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
selected = Math.min(selected + 1, rows.length - 1);
|
|
render();
|
|
}
|
|
else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
selected = Math.max(selected - 1, 0);
|
|
render();
|
|
}
|
|
else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
rows[selected]?.pick();
|
|
}
|
|
};
|
|
backdrop.onclick = close;
|
|
const anchor = document.querySelector(".task-picker").getBoundingClientRect();
|
|
box.style.top = `${anchor.bottom + 4}px`;
|
|
document.body.append(backdrop, box);
|
|
const viewport = document.documentElement.clientWidth;
|
|
const left = anchor.left + anchor.width / 2 - box.offsetWidth / 2;
|
|
box.style.left = `${Math.max(8, Math.min(left, viewport - box.offsetWidth - 8))}px`;
|
|
render();
|
|
input.focus();
|
|
}
|
|
//# sourceMappingURL=switcher.js.map
|