Order equal-priority tasks in the switcher by least recently visited

This commit is contained in:
Ian Gulliver
2026-10-14 23:23:25 -07:00
parent c6e57a4740
commit 4dc86e5479
7 changed files with 38 additions and 9 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen
Messages byre itself sends an agent (actor `byre`, such as the restart notice) show as a muted note, not a user bubble. The transcript view has an accent border while the session is `running`, and a braille spinner on its last line, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over an accent background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer is the same whether or not the task has a live session: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a "sending…" bubble, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; otherwise the order is the server's (projects by name, tasks newest first). Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task here or in the Tasks view selects it and, if neither pane shows the Transcript tab, switches a pane that isn't showing Tasks to it.
The task switcher (⌘K, or the navbar crumb) lists active tasks with their session state and open inbox item count, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. Typing filters them and adds a last row that creates a task with the typed text as its title, in the current task's project (the first project when no task is selected); Enter picks the highlighted row. Picking a task here or in the Tasks view selects it and, if neither pane shows the Transcript tab, switches a pane that isn't showing Tasks to it.
## Inbox
+4 -3
View File
@@ -1,7 +1,7 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript } from "./panes.js";
import { activeTasks, currentSession, findTask, onEach, state } from "./state.js";
import { activeTasks, currentSession, findTask, lastVisits, onEach, state } from "./state.js";
export function mountNavbar(root, show) {
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
crumb.onclick = openSwitcher;
@@ -79,10 +79,11 @@ function openSwitcher() {
const title = input.value.trim();
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 }) => `${project.name} ${task.title}`.toLowerCase().includes(q))
.map((m) => ({ ...m, count: inbox(m.task.id) }))
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0));
.map((m) => ({ ...m, count: inbox(m.task.id), visited: 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 sess = currentSession(task.id);
const el = h("li", {}, h("span", { class: `dot ${sess?.state ?? "none"}`, title: sess?.state ?? "no session" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), h("span", { class: "task-state" }, sess?.state ?? "no agent"));
+1 -1
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -72,7 +72,19 @@ export async function loadInbox() {
notify("inbox");
}
let liveDuringLoad;
const visitsKey = "byre.visits";
export function lastVisits() {
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}");
}
function markVisited(taskId) {
if (!taskId) {
return;
}
localStorage.setItem(visitsKey, JSON.stringify({ ...lastVisits(), [taskId]: Date.now() }));
}
export async function selectTask(taskId) {
markVisited(state.selectedTaskId);
markVisited(taskId);
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -1,7 +1,7 @@
import { post } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { openTranscript, type View } from "./panes.js";
import { activeTasks, currentSession, findTask, onEach, state } from "./state.js";
import { activeTasks, currentSession, findTask, lastVisits, onEach, state } from "./state.js";
export function mountNavbar(root: HTMLElement, show: (view: View) => void): void {
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
@@ -93,10 +93,11 @@ function openSwitcher(): void {
const title = input.value.trim();
const q = title.toLowerCase();
const inbox = (taskId: string) => state.inbox.filter((item) => item.task_id === taskId).length;
const visits = lastVisits();
const matches = activeTasks()
.filter(({ project, task }) => `${project.name} ${task.title}`.toLowerCase().includes(q))
.map((m) => ({ ...m, count: inbox(m.task.id) }))
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0));
.map((m) => ({ ...m, count: inbox(m.task.id), visited: 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 sess = currentSession(task.id);
const el = h(
+15
View File
@@ -89,7 +89,22 @@ export async function loadInbox(): Promise<void> {
let liveDuringLoad: ByreEvent[] | undefined;
const visitsKey = "byre.visits";
export function lastVisits(): Record<string, number> {
return JSON.parse(localStorage.getItem(visitsKey) ?? "{}") as Record<string, number>;
}
function markVisited(taskId: string | undefined): void {
if (!taskId) {
return;
}
localStorage.setItem(visitsKey, JSON.stringify({ ...lastVisits(), [taskId]: Date.now() }));
}
export async function selectTask(taskId: string | undefined): Promise<void> {
markVisited(state.selectedTaskId);
markVisited(taskId);
state.selectedTaskId = taskId;
state.transcript = [];
state.lastTranscriptSeq = 0;