Mark unread turn summaries in the switcher and Project rows

This commit is contained in:
Ian Gulliver
2026-10-16 11:08:57 -07:00
parent ec169583ff
commit 34db128afe
24 changed files with 755 additions and 239 deletions
+95
View File
@@ -123,6 +123,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/summary-reads": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getSummaryReads"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/me/pane-focus": {
parameters: {
query?: never;
@@ -268,6 +284,25 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/summary-read": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
get?: never;
/** @description Records that the calling user has read the task's turn summaries up to seq. A seq at or below the one already recorded changes nothing; a higher one emits summary.read, owned by the user. */
put: operations["markSummaryRead"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/workflow/run": {
parameters: {
query?: never;
@@ -957,6 +992,11 @@ export interface components {
parent_task_id?: string;
/** @description Whether a merge of this subtask into its parent was requested and the parent hasn't merged it yet. */
merging_into_parent: boolean;
/**
* Format: int64
* @description The seq of the task's latest turn.ended event with summary: true; 0 when it has none.
*/
summary_seq: number;
};
Todo: {
/** @description Whether the default branch has a TODO.md at its root. */
@@ -1350,6 +1390,13 @@ export interface components {
/** Format: date-time */
updated_at: string;
};
SummaryRead: {
/**
* Format: int64
* @description The seq of the turn.ended event whose summary was shown.
*/
seq: number;
};
PaneFocus: {
/** @description True while the browser window has focus and the focused element is inside the right pane. */
focused: boolean;
@@ -1659,6 +1706,28 @@ export interface operations {
404: components["responses"]["Error"];
};
};
getSummaryReads: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description For each active task the calling user has read a turn summary of, the highest seq read. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: number;
};
};
};
};
};
setPaneFocus: {
parameters: {
query?: never;
@@ -1899,6 +1968,32 @@ export interface operations {
404: components["responses"]["Error"];
};
};
markSummaryRead: {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SummaryRead"];
};
};
responses: {
/** @description Recorded. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["Error"];
404: components["responses"]["Error"];
};
};
getWorkflowRun: {
parameters: {
query?: never;
+2 -2
View File
@@ -2,7 +2,7 @@ import { get, put, subscribe } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { loadDrafts } from "./drafts.js";
import { leadPane, taskPane } from "./panes.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, notify, on, selectTask, setFocus, startLeadStream, state, tabs } from "./state.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, loadSummaryReads, notify, on, selectTask, setFocus, startLeadStream, state, tabs } from "./state.js";
import { bindShortcuts } from "./switcher.js";
function identityBanner(root: HTMLElement): void {
@@ -53,7 +53,7 @@ async function main(): Promise<void> {
const banner = h("div", { class: "banner", hidden: true });
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions()]);
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions(), loadSummaryReads()]);
loadDrafts();
const linked = location.hash;
await loadFocus();
+12 -3
View File
@@ -2,7 +2,7 @@ import { get, post, type ByreEvent, type Project, type Schemas, type Task } from
import { errorText, h, replace, showAlert } from "./dom.js";
import { markdown } from "./markdown.js";
import { models } from "./task.js";
import { findTask, focusedProject, focusTask, on, onEach, state, taskState, type TaskState } from "./state.js";
import { findTask, focusedProject, focusTask, on, onEach, openSummary, state, taskState, unreadSummary, type TaskState } from "./state.js";
import { taskStyle } from "./taskcolor.js";
type Todo = Schemas["Todo"];
@@ -79,7 +79,7 @@ export function mountProject(root: HTMLElement): () => void {
void load(p.id);
}
}),
onEach(["tasks", "sessions", "inbox"], render),
onEach(["tasks", "sessions", "inbox", "summaries"], render),
];
render();
@@ -148,13 +148,18 @@ function tasksBlock(active: Task[]): HTMLElement {
href: `#/task/${task.id}`,
onclick: (e: MouseEvent) => {
e.preventDefault();
focusTask(task.id);
if (unreadSummary(task)) {
openSummary(task);
} else {
focusTask(task.id);
}
},
},
task.parent_task_id !== undefined && h("span", { class: "subtask-mark", title: `Subtask of ${findTask(task.parent_task_id)?.title ?? task.parent_task_id}` }, "↳"),
stateMark(ts),
h("span", { class: "task-title 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),
),
);
@@ -162,6 +167,10 @@ function tasksBlock(active: Task[]): HTMLElement {
);
}
export function summaryMark(task: Task): HTMLElement | false {
return unreadSummary(task) && h("span", { class: "task-state summary-unread", title: "A turn summary you haven't read", ariaLabel: `New summary from ${task.title}, unread` }, "new summary");
}
function todoBlock(project: Project, todo: Todo | string | undefined, active: Task[], starting: Set<string>, start: (p: Project, item: TodoItem, discuss: boolean) => Promise<void>): HTMLElement[] {
if (todo === undefined) {
return [h("p", { class: "empty-row" }, "Loading…")];
+46 -2
View File
@@ -19,7 +19,9 @@ export type Topic =
| "lead.transcript"
| "lead.stream"
| "ready"
| "lead.ready";
| "lead.ready"
| "summaries"
| "jump";
export const tabs: TaskTab[] = ["transcript", "changes", "files", "terminal", "inbox", "reviews", "workflow", "project"];
@@ -37,6 +39,8 @@ export const state = {
focus: { tabs: ["transcript"], tab: "transcript" } as Focus,
leadSessions: [] as AgentSession[],
leadTranscript: [] as ByreEvent[],
jump: undefined as { taskId: string; seq: number } | undefined,
summaryReads: {} as Record<string, number>,
};
const listeners = new Map<Topic, Set<(detail?: unknown) => void>>();
@@ -355,6 +359,37 @@ function markVisited(taskId: string | undefined): void {
localStorage.setItem(visitsKey, JSON.stringify({ ...lastVisits(), [taskId]: Date.now() }));
}
export async function loadSummaryReads(): Promise<void> {
state.summaryReads = await get("/api/me/summary-reads");
notify("summaries");
}
export function unreadSummary(task: Task): boolean {
return task.summary_seq > (state.summaryReads[task.id] ?? 0);
}
function noteSummaryRead(taskId: string, seq: number): boolean {
if ((state.summaryReads[taskId] ?? 0) >= seq) {
return false;
}
state.summaryReads[taskId] = seq;
notify("summaries");
return true;
}
export function markSummaryRead(taskId: string, seq: number): void {
if (!noteSummaryRead(taskId, seq)) {
return;
}
put("/api/tasks/{taskId}/summary-read", { path: { taskId }, body: { seq } }).catch((err: unknown) => console.error("mark summary read", err));
}
export function openSummary(task: Task): void {
state.jump = { taskId: task.id, seq: task.summary_seq };
focusTask(task.id, "transcript");
notify("jump");
}
export function selectTask(taskId: string | undefined): void {
markVisited(state.selectedTaskId);
markVisited(taskId);
@@ -419,8 +454,17 @@ export function handleEvent(ev: ByreEvent): void {
case "inbox.updated":
void loadInbox();
break;
case "turn.ended":
case "turn.ended": {
notifyTurn(ev);
const task = ev.payload["summary"] === true && ev.task_id ? findTask(ev.task_id) : undefined;
if (task && ev.seq > task.summary_seq) {
task.summary_seq = ev.seq;
notify("summaries");
}
break;
}
case "summary.read":
noteSummaryRead(String(ev.payload["task_id"]), Number(ev.payload["seq"]));
break;
case "review.started":
case "review.finished":
+12 -3
View File
@@ -1,7 +1,8 @@
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";
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";
@@ -99,7 +100,7 @@ export function openSwitcher(): void {
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 offs = onEach(["projects", "tasks", "sessions", "inbox", "summaries"], () => render());
const onEscape = (e: KeyboardEvent) => {
if (e.key !== "Escape") {
return;
@@ -126,7 +127,14 @@ export function openSwitcher(): void {
const go = (taskId: string) => {
close();
focusTask(taskId, state.inbox.some((item) => item.task_id === taskId) ? "inbox" : "transcript");
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: string, title: string, workflow?: string) => {
@@ -231,6 +239,7 @@ export function openSwitcher(): void {
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) };
+56 -1
View File
@@ -4,7 +4,7 @@ import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts, severityCounts, unignoredFindings } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading, type Topic } from "./state.js";
import { currentSession, findTask, focusTab, focusTask, markSummaryRead, on, onEach, pickReview, pinSnapshot, setFocus, showReview, state, transcriptLoading, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -220,6 +220,51 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const mutations = new MutationObserver(pin);
mutations.observe(transcript, { childList: true, subtree: true, characterData: true });
const shown = new Set<HTMLElement>();
const markShown = () => {
const taskId = ctx.taskId;
if (!taskId || document.visibilityState !== "visible") {
return;
}
for (const el of shown) {
markSummaryRead(taskId, Number(el.dataset["seq"]));
}
};
const sightings = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
shown.add(e.target as HTMLElement);
} else {
shown.delete(e.target as HTMLElement);
}
}
markShown();
},
{ root: transcript },
);
document.addEventListener("visibilitychange", markShown);
const jump = () => {
const target = state.jump;
if (!target || target.taskId !== ctx.taskId) {
return;
}
const end = transcript.querySelector<HTMLElement>(`.turn-end[data-seq="${target.seq}"]`);
if (!end) {
return;
}
state.jump = undefined;
const msg = end.previousElementSibling;
const box = transcript.getBoundingClientRect();
if (msg?.classList.contains("agent")) {
transcript.scrollTop += msg.getBoundingClientRect().top - box.top - 8;
} else {
transcript.scrollTop += end.getBoundingClientRect().bottom - box.bottom + 8;
}
stuck = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 80;
};
const newCtx = (): Ctx => ({
tools: new Map(),
subagents: new Map(),
@@ -517,6 +562,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
if (ev.kind === "user.message" && p["mid_turn"] !== true) {
ctx.open = true;
}
if (ev.kind === "turn.ended" && p["summary"] === true && ctx.taskId) {
el.dataset["seq"] = String(ev.seq);
sightings.observe(el);
}
put(el);
}
syncRunning();
@@ -529,12 +578,15 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
}
fillTodoLinks(transcript, models[0]!);
pin();
jump();
};
const renderAll = () => {
stuck = true;
dropStream();
pending = [];
sightings.disconnect();
shown.clear();
ctx = newCtx();
context = new Map();
focusStart = true;
@@ -563,6 +615,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
onEach([src.streamTopic], (ev) => appendDelta(ev as ByreEvent)),
onEach([src.readyTopic], () => loading.remove()),
onEach(["tasks", "sessions"], () => fillTodoLinks(transcript, models[0]!)),
on("jump", jump),
];
replace(root, h("div", { class: "transcript-view" }, head, transcript, composer));
@@ -574,6 +627,8 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
headImpl.dispose?.();
resizes.disconnect();
mutations.disconnect();
sightings.disconnect();
document.removeEventListener("visibilitychange", markShown);
dropStream();
const input = composer.querySelector("textarea");
if (input) {