Inbox consideration
This commit is contained in:
+2
-2
@@ -143,9 +143,9 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
|
||||
- **Sessions.** A lead session is an `agent_sessions` row with `owner` set to the user's login, `role` `lead` and no `task_id`. Its events carry `owner` and no project or task. It runs in `leads/<login>` with `leadGuidance` (passed and re-attached the same way as a task agent's guidance) and the lead tools instead of the task tools. It has no Stop-hook bounce, no snapshots, no end-of-turn commit and no turn summary; a turn that ends in error records the error on its `session.state` event, which the pane shows as a note.
|
||||
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs`; the lead's PostToolUse hook (Codex: `turn/steer` after each completed tool item) hands held messages over after its next tool call, like a task's queued messages (see "Agent sessions"), and any still held become its next turn. Signals are never handed over mid-turn. The composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
|
||||
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged`, `task.discarded`, and `turn.ended` with `summary: true` ("<task> finished a turn:" with its `result` cut to 1500 characters). The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` whose last line is `NO_REPLY` (the model sometimes writes a sentence before it), and hides the live partial message while its text is still a prefix of it. A lead turn that byre starts without a handoff (a signal batch or a handoff continuation) is held (`ctx.held`): its `byre` message isn't drawn, the spinner stays off and its partial message stays hidden, until the lead draws something else, such as a tool call, thinking or a real message, which draws the `byre` message ahead of it. A `NO_REPLY` turn that drew nothing else ends without anything of it ever drawn; one that did keeps all of it, without the `NO_REPLY`.
|
||||
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
|
||||
- **Tools.** `list_tasks` (each project's active tasks, then a line listing the titles of its `TODO.md` items on the default branch that no active task was started from), `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
|
||||
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the right pane's task picker, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/<id>`, `#/project/<id>`, or `#/` for neither.
|
||||
- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or the project's Project tab with `NO_REPLY` when nothing does. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
|
||||
- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed error; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or, when nothing does, the project's Project tab with a short status in chat: one line per active task, then up to three TODO items it would start next, each with a reason, starting one only when the human picks it. It writes each item as a link to `#/todo/<project_id>/<URL-encoded title>`; Markdown views draw that (`web/src/todolink.ts`) as the title with a **Start** button that calls `POST /api/projects/{id}/todo/tasks` with the first model, as the Project tab's does, or, once an active task was started from that item, the task's colour, dot and state, linking to it. A chat view refills them whenever its transcript grows or tasks or sessions change. When that status says nothing new since its last one, it replies `NO_REPLY` instead. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
|
||||
- **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre sets `Manager.startedTodo` for them, and while it is set and the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears it. It is in memory, so a restart clears it.
|
||||
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
|
||||
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const leadGuidance = `You are the human's lead agent in byre, shown in the left pane of byre's web UI. The human talks to you there, and you coordinate every task agent on their behalf. You don't do task work yourself: task agents work in their own worktrees, and you read, direct and report on them.
|
||||
- Signals: byre sends you, as messages from byre, what happens across tasks: questions and errors that task agents file, turns they finish with their final message, questions the human answered, tasks created, merged or discarded. Several may arrive in one message. Messages from the human arrive separately.
|
||||
- Don't respond to what the human just did. A signal about their own action (a question they answered, a task they created, merged or discarded) tells them nothing new: don't report, confirm or comment on it. Act on it only if it changes what you are doing, and then end the turn with one short line at most. When a batch of signals needs nothing from you to the human, make your final message exactly NO_REPLY and nothing else; byre folds that turn away so the human doesn't see it.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (blocked questions and errors, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab and make your final message NO_REPLY.
|
||||
- "Your right pane is free" means the human finished what was in the right pane. Show them the next thing that needs them, most urgent first (blocked questions and errors, then finished turns, then tasks ready to merge), with one line saying what it is. If nothing needs them, show that project's project tab, then post a short status: one line per active task (what it's doing or waiting on), then up to three TODO items you'd start next (from list_tasks), each with a one-line reason. Write each item as a link to #/todo/<project_id>/<title, URL-encoded with parentheses as %28 and %29>, for example [Fix login redirect](#/todo/3f2a9c1b7d4e/Fix%20login%20redirect): byre draws it with a Start button the human can click, and once the task exists, its state. Start one with start_todo only when the human picks it. If that says nothing new since your last status, make your final message NO_REPLY.
|
||||
- Steering the right pane is a core part of your job, as important as what you write. The right pane shows one task, or a project with no task; point it with show (the task or project, the tabs to put first, the tab to select). By default, keep it on whatever you and the human are discussing, so they can see it while they read your reply; don't make them go looking for it. Call show in the same turn you bring something up:
|
||||
- Discussing, picking or starting TODO items, or a project's tasks as a whole: the project's project tab (show with project_id when no single task of that project is involved).
|
||||
- A question that needs the human: that task's inbox tab, with one line naming it and what you recommend.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
@@ -33,7 +34,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
|
||||
return []mcp.Tool{
|
||||
tool("list_tasks",
|
||||
"List every project and its active tasks: id, title, worktree path, the agent's state and model, and open inbox items.",
|
||||
"List every project and its active tasks (id, title, worktree path, the agent's state and model, and open inbox items), "+
|
||||
"and the titles of its TODO.md items on the default branch that no active task was started from.",
|
||||
`{"type": "object", "properties": {}}`,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
return m.listTasksText(ctx)
|
||||
@@ -518,11 +520,15 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
listed := false
|
||||
started := map[string]bool{}
|
||||
for _, t := range tasks {
|
||||
if t.Status != api.TaskStatusActive {
|
||||
continue
|
||||
}
|
||||
listed = true
|
||||
if t.TodoItem != nil {
|
||||
started[*t.TodoItem] = true
|
||||
}
|
||||
agent := "no agent"
|
||||
sess, found, err := m.liveSession(ctx, t.Id)
|
||||
if err != nil {
|
||||
@@ -557,6 +563,24 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
|
||||
if !listed {
|
||||
b.WriteString("- no active tasks\n")
|
||||
}
|
||||
src, found, err := git.ReadFile(p.RepoPath, p.DefaultBranch, todo.File)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
titles := []string{}
|
||||
for _, item := range todo.Parse(src) {
|
||||
if !started[item.Title] {
|
||||
titles = append(titles, item.Title)
|
||||
}
|
||||
}
|
||||
if len(titles) == 0 {
|
||||
fmt.Fprintf(b, "%s items not started: none\n", todo.File)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(b, "%s items not started: %s\n", todo.File, strings.Join(titles, "; "))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
Vendored
+10
@@ -1,6 +1,7 @@
|
||||
import { copyable, h } from "./dom.js";
|
||||
import { diagram } from "./mermaid.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { todoLink } from "./todolink.js";
|
||||
const choiceMarker = /^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$/;
|
||||
const detailsOpen = /^\s*<details>\s*(?:<summary>(.*?)<\/summary>)?\s*$/i;
|
||||
const summaryLine = /^\s*<summary>(.*?)<\/summary>\s*$/i;
|
||||
@@ -275,6 +276,15 @@ function link(href, children) {
|
||||
if (task) {
|
||||
return h("a", { href, class: "task-link", style: taskStyle(task[1]) }, ...children);
|
||||
}
|
||||
const todo = /^#\/todo\/(\w+)\/(.+)$/.exec(href);
|
||||
if (todo) {
|
||||
try {
|
||||
return todoLink(todo[1], decodeURIComponent(todo[2]), children);
|
||||
}
|
||||
catch {
|
||||
return h("span", {}, ...children);
|
||||
}
|
||||
}
|
||||
if (href.startsWith("#/")) {
|
||||
return h("a", { href }, ...children);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -5,6 +5,7 @@ import { previewURL } from "./preview.js";
|
||||
import { findingCount, reviewCounts } from "./reviews.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { fillTodoLinks } from "./todolink.js";
|
||||
export const models = [
|
||||
"claude-opus-5-5",
|
||||
"claude-sonnet-5-5",
|
||||
@@ -302,6 +303,7 @@ export function mountChat(root, src, makeHead) {
|
||||
stream.timer = 0;
|
||||
stream.el.hidden = ctx.held !== undefined || quietReply.startsWith(stream.text.trim());
|
||||
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
|
||||
fillTodoLinks(stream.el, models[0]);
|
||||
pin();
|
||||
}, 100);
|
||||
};
|
||||
@@ -414,6 +416,7 @@ export function mountChat(root, src, makeHead) {
|
||||
renderComposer();
|
||||
}
|
||||
}
|
||||
fillTodoLinks(transcript, models[0]);
|
||||
pin();
|
||||
};
|
||||
const renderAll = () => {
|
||||
@@ -442,6 +445,7 @@ export function mountChat(root, src, makeHead) {
|
||||
}),
|
||||
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh)),
|
||||
onEach([src.streamTopic], (ev) => appendDelta(ev)),
|
||||
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0])),
|
||||
];
|
||||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||||
renderAll();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { currentSession, focusTask, state } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
const starting = new Set();
|
||||
export function todoLink(projectId, title, children) {
|
||||
return h("span", { class: "todo-link", data: { project: projectId, title } }, h("span", { class: "todo-title" }, ...children), " ", h("span", { class: "todo-action" }));
|
||||
}
|
||||
export function fillTodoLinks(root, model) {
|
||||
for (const el of root.querySelectorAll(".todo-link")) {
|
||||
fill(el, model);
|
||||
}
|
||||
}
|
||||
function fill(el, model) {
|
||||
const projectId = el.dataset["project"];
|
||||
const title = el.dataset["title"];
|
||||
const action = el.querySelector(".todo-action");
|
||||
const titleEl = el.querySelector(".todo-title");
|
||||
const task = (state.tasks.get(projectId) ?? []).find((t) => t.status === "active" && t.todo_item === title);
|
||||
if (task) {
|
||||
const sess = currentSession(task.id);
|
||||
el.setAttribute("style", taskStyle(task.id));
|
||||
titleEl.classList.add("task-name");
|
||||
replace(action, h("a", {
|
||||
class: "todo-task",
|
||||
href: `#/task/${task.id}`,
|
||||
onclick: (e) => {
|
||||
e.preventDefault();
|
||||
focusTask(task.id);
|
||||
},
|
||||
}, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), sess?.state ?? "no agent"));
|
||||
return;
|
||||
}
|
||||
el.removeAttribute("style");
|
||||
titleEl.classList.remove("task-name");
|
||||
const key = `${projectId}\n${title}`;
|
||||
const busy = starting.has(key);
|
||||
const button = h("button", { disabled: busy }, busy ? "Starting…" : "Start");
|
||||
button.onclick = async () => {
|
||||
starting.add(key);
|
||||
fill(el, model);
|
||||
try {
|
||||
await post("/api/projects/{projectId}/todo/tasks", { path: { projectId }, body: { title, model } });
|
||||
}
|
||||
catch (err) {
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
starting.delete(key);
|
||||
fill(el, model);
|
||||
};
|
||||
replace(action, button);
|
||||
}
|
||||
//# sourceMappingURL=todolink.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"todolink.js","sourceRoot":"","sources":["../src/todolink.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;AAEnC,MAAM,UAAU,QAAQ,CAAC,SAAiB,EAAE,KAAa,EAAE,QAAgB;IACzE,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,EAAE,GAAG,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC;AAC3K,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,IAAiB,EAAE,KAAa;IAC5D,KAAK,MAAM,EAAE,IAAI,IAAI,CAAC,gBAAgB,CAAc,YAAY,CAAC,EAAE,CAAC;QAClE,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,SAAS,IAAI,CAAC,EAAe,EAAE,KAAa;IAC1C,MAAM,SAAS,GAAG,EAAE,CAAC,OAAO,CAAC,SAAS,CAAE,CAAC;IACzC,MAAM,KAAK,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAE,CAAC;IACnC,MAAM,MAAM,GAAG,EAAE,CAAC,aAAa,CAAc,cAAc,CAAE,CAAC;IAC9D,MAAM,OAAO,GAAG,EAAE,CAAC,aAAa,CAAc,aAAa,CAAE,CAAC;IAC9D,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,CAAC,SAAS,KAAK,KAAK,CAAC,CAAC;IAE5G,IAAI,IAAI,EAAE,CAAC;QACT,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACrC,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;QAC7C,OAAO,CAAC,SAAS,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QACnC,OAAO,CACL,MAAM,EACN,CAAC,CACC,GAAG,EACH;YACE,KAAK,EAAE,WAAW;YAClB,IAAI,EAAE,UAAU,IAAI,CAAC,EAAE,EAAE;YACzB,OAAO,EAAE,CAAC,CAAa,EAAE,EAAE;gBACzB,CAAC,CAAC,cAAc,EAAE,CAAC;gBACnB,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACrB,CAAC;SACF,EACD,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,OAAO,IAAI,EAAE,KAAK,IAAI,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,YAAY,EAAE,CAAC,EACxF,IAAI,EAAE,KAAK,IAAI,UAAU,CAC1B,CACF,CAAC;QACF,OAAO;IACT,CAAC;IAED,EAAE,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC5B,OAAO,CAAC,SAAS,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;IACtC,MAAM,GAAG,GAAG,GAAG,SAAS,KAAK,KAAK,EAAE,CAAC;IACrC,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,MAAM,MAAM,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IAC7E,MAAM,CAAC,OAAO,GAAG,KAAK,IAAI,EAAE;QAC1B,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAClB,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;QAChB,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,sCAAsC,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,CAAC;QACtG,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,KAAK,SAAS,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;QACjC,CAAC;QACD,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACrB,IAAI,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;IAClB,CAAC,CAAC;IACF,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAC1B,CAAC"}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { copyable, h } from "./dom.js";
|
||||
import { diagram } from "./mermaid.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { todoLink } from "./todolink.js";
|
||||
|
||||
type Block = HTMLElement;
|
||||
|
||||
@@ -315,6 +316,14 @@ function link(href: string, children: Node[]): Node {
|
||||
if (task) {
|
||||
return h("a", { href, class: "task-link", style: taskStyle(task[1]!) }, ...children);
|
||||
}
|
||||
const todo = /^#\/todo\/(\w+)\/(.+)$/.exec(href);
|
||||
if (todo) {
|
||||
try {
|
||||
return todoLink(todo[1]!, decodeURIComponent(todo[2]!), children);
|
||||
} catch {
|
||||
return h("span", {}, ...children);
|
||||
}
|
||||
}
|
||||
if (href.startsWith("#/")) {
|
||||
return h("a", { href }, ...children);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { previewURL } from "./preview.js";
|
||||
import { findingCount, reviewCounts } from "./reviews.js";
|
||||
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
import { fillTodoLinks } from "./todolink.js";
|
||||
|
||||
export type Usage = { used: number; window: number; model: string };
|
||||
|
||||
@@ -380,6 +381,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
stream.timer = 0;
|
||||
stream.el.hidden = ctx.held !== undefined || quietReply.startsWith(stream.text.trim());
|
||||
replace(stream.el, markdown(stream.text, "text md", { taskId: ctx.taskId }));
|
||||
fillTodoLinks(stream.el, models[0]!);
|
||||
pin();
|
||||
}, 100);
|
||||
};
|
||||
@@ -493,6 +495,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
renderComposer();
|
||||
}
|
||||
}
|
||||
fillTodoLinks(transcript, models[0]!);
|
||||
pin();
|
||||
};
|
||||
|
||||
@@ -523,6 +526,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
|
||||
}),
|
||||
onEach([src.transcriptTopic], (fresh) => appendEvents(fresh as ByreEvent[])),
|
||||
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
|
||||
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
|
||||
];
|
||||
|
||||
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { currentSession, focusTask, state } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
const starting = new Set<string>();
|
||||
|
||||
export function todoLink(projectId: string, title: string, children: Node[]): HTMLElement {
|
||||
return h("span", { class: "todo-link", data: { project: projectId, title } }, h("span", { class: "todo-title" }, ...children), " ", h("span", { class: "todo-action" }));
|
||||
}
|
||||
|
||||
export function fillTodoLinks(root: HTMLElement, model: string): void {
|
||||
for (const el of root.querySelectorAll<HTMLElement>(".todo-link")) {
|
||||
fill(el, model);
|
||||
}
|
||||
}
|
||||
|
||||
function fill(el: HTMLElement, model: string): void {
|
||||
const projectId = el.dataset["project"]!;
|
||||
const title = el.dataset["title"]!;
|
||||
const action = el.querySelector<HTMLElement>(".todo-action")!;
|
||||
const titleEl = el.querySelector<HTMLElement>(".todo-title")!;
|
||||
const task = (state.tasks.get(projectId) ?? []).find((t) => t.status === "active" && t.todo_item === title);
|
||||
|
||||
if (task) {
|
||||
const sess = currentSession(task.id);
|
||||
el.setAttribute("style", taskStyle(task.id));
|
||||
titleEl.classList.add("task-name");
|
||||
replace(
|
||||
action,
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
class: "todo-task",
|
||||
href: `#/task/${task.id}`,
|
||||
onclick: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
focusTask(task.id);
|
||||
},
|
||||
},
|
||||
h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }),
|
||||
sess?.state ?? "no agent",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
el.removeAttribute("style");
|
||||
titleEl.classList.remove("task-name");
|
||||
const key = `${projectId}\n${title}`;
|
||||
const busy = starting.has(key);
|
||||
const button = h("button", { disabled: busy }, busy ? "Starting…" : "Start");
|
||||
button.onclick = async () => {
|
||||
starting.add(key);
|
||||
fill(el, model);
|
||||
try {
|
||||
await post("/api/projects/{projectId}/todo/tasks", { path: { projectId }, body: { title, model } });
|
||||
} catch (err) {
|
||||
void showAlert(errorText(err));
|
||||
}
|
||||
starting.delete(key);
|
||||
fill(el, model);
|
||||
};
|
||||
replace(action, button);
|
||||
}
|
||||
@@ -461,6 +461,16 @@ a.task-link {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.todo-link .todo-task {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.todo-link button {
|
||||
padding: 1px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.project h2 {
|
||||
font-size: 13px;
|
||||
margin: 16px 0 6px;
|
||||
|
||||
Reference in New Issue
Block a user