Mark unread turn summaries in the switcher and Project rows
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user