Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@
background: #0891b2;
}

.session-tree-pinned-dot.is-awaiting {
background: #d97706;
}

/* Session row: title, branch, time; actions take the time's place on
hover. Hover and selection are a rounded box inset from the sidebar's
edges, never a full-width band. The title starts in the text column,
Expand Down Expand Up @@ -654,6 +658,7 @@ a.session-tree-main {
}

.session-tree-summary-running,
.session-tree-summary-awaiting,
.session-tree-summary-unread {
display: inline-flex;
align-items: center;
Expand All @@ -672,6 +677,10 @@ a.session-tree-main {
background: #0891b2;
}

.session-tree-summary-dot.is-awaiting {
background: #d97706;
}

.session-tree-group-actions {
display: none;
flex: none;
Expand Down
27 changes: 24 additions & 3 deletions components/SessionTree.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ function sessionRow(root, { context = "group", status = {}, archivedAt = null, r

function groupRow(extra = {}, projectExtra = {}) {
const groupProject = { ...project, ...projectExtra };
return { kind: "group", key: `group:${groupProject.key}`, project: groupProject, expanded: true, running: 0, unread: 0, ...extra };
return { kind: "group", key: `group:${groupProject.key}`, project: groupProject, expanded: true, running: 0, awaiting: 0, unread: 0, ...extra };
}

const footer = [{ kind: "footer-open", key: "footer-open" }];

test("the pinned header toggles the section and shows activity dots only while collapsed", () => {
const collapsed = rowMarkup(render({
rows: [{ kind: "pinned-header", key: "pinned-header", count: 3, collapsed: true, running: 1, unread: 2 }],
rows: [{ kind: "pinned-header", key: "pinned-header", count: 3, collapsed: true, running: 1, awaiting: 0, unread: 2 }],
}), "pinned-header");
// A group header's height: both are section headers.
assert.match(collapsed, /^<div class="session-tree-row session-tree-pinned-header" style="top:0;height:28px" data-row-key="pinned-header">/);
Expand All @@ -123,16 +123,37 @@ test("the pinned header toggles the section and shows activity dots only while c
assert.doesNotMatch(collapsed, /session-tree-group-icon/);
assert.match(collapsed, /class="session-tree-pinned-dot is-running" role="img" title="Agent running…" aria-label="Agent running… \(1\)"/);
assert.match(collapsed, /class="session-tree-pinned-dot is-unread" role="img" title="New session activity" aria-label="New session activity \(2\)"/);
assert.doesNotMatch(collapsed, /session-tree-pinned-dot is-awaiting/);
assert.doesNotMatch(collapsed, /session-tree-chevron is-open/);

const expanded = rowMarkup(render({
rows: [{ kind: "pinned-header", key: "pinned-header", count: 3, collapsed: false, running: 1, unread: 2 }],
rows: [{ kind: "pinned-header", key: "pinned-header", count: 3, collapsed: false, running: 1, awaiting: 1, unread: 2 }],
}), "pinned-header");
assert.match(expanded, /aria-expanded="true"/);
assert.match(expanded, /session-tree-chevron is-open/);
assert.doesNotMatch(expanded, /session-tree-pinned-dot/);
});

test("a collapsed pinned header shows an awaiting dot of its own, without the running one", () => {
const collapsed = rowMarkup(render({
rows: [{ kind: "pinned-header", key: "pinned-header", count: 2, collapsed: true, running: 0, awaiting: 2, unread: 0 }],
}), "pinned-header");
assert.match(collapsed, /class="session-tree-pinned-dot is-awaiting" role="img" title="Agent waiting for your input" aria-label="Agent waiting for your input \(2\)"/);
assert.doesNotMatch(collapsed, /session-tree-pinned-dot is-running/);
});

test("a collapsed group shows awaiting families apart from running ones", () => {
const mixed = rowMarkup(render({ rows: [groupRow({ expanded: false, running: 1, awaiting: 2, unread: 0 })] }), "group:/work/app");
assert.match(mixed, /aria-label="Agent running… \(1\)"/);
assert.match(mixed, /<span class="session-tree-summary-awaiting" role="img" title="Agent waiting for your input" aria-label="Agent waiting for your input \(2\)"><span class="session-tree-summary-dot is-awaiting"><\/span>2<\/span>/);

// Only awaiting: the amber count, no spinner.
const awaiting = rowMarkup(render({ rows: [groupRow({ expanded: false, running: 0, awaiting: 1, unread: 0 })] }), "group:/work/app");
assert.match(awaiting, /session-tree-summary-awaiting/);
assert.doesNotMatch(awaiting, /session-tree-summary-running/);
assert.doesNotMatch(awaiting, /sidebar-spin/);
});

test("a collapsed group shows its running and unread counts and labelled actions", () => {
const html = rowMarkup(render({
rows: [groupRow({ expanded: false, running: 2, unread: 1 }, { pinned: true, current: false })],
Expand Down
30 changes: 25 additions & 5 deletions components/SessionTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -811,11 +811,12 @@ function DeleteConfirm({
}

/**
* Spinner + count and dot + count; the label carries the count for screen
* readers. Also a project's badge in the project menus (ProjectWorktreePicker).
* Spinner + count, amber dot + count (awaiting input) and dot + count; the
* label carries the count for screen readers. Also a project's badge in the
* project menus (ProjectWorktreePicker), which counts no awaiting.
*/
export function ActivitySummary({ running, unread, t }: { running: number; unread: number; t: (key: string) => string }) {
if (running === 0 && unread === 0) return null;
export function ActivitySummary({ running, awaiting = 0, unread, t }: { running: number; awaiting?: number; unread: number; t: (key: string) => string }) {
if (running === 0 && awaiting === 0 && unread === 0) return null;
return (
<span className="session-tree-summary">
{running > 0 && (
Expand All @@ -829,6 +830,17 @@ export function ActivitySummary({ running, unread, t }: { running: number; unrea
{running}
</span>
)}
{awaiting > 0 && (
<span
className="session-tree-summary-awaiting"
role="img"
title={t("sidebar.agentAwaitingInput")}
aria-label={`${t("sidebar.agentAwaitingInput")} (${awaiting})`}
>
<span className="session-tree-summary-dot is-awaiting" />
{awaiting}
</span>
)}
{unread > 0 && (
<span
className="session-tree-summary-unread"
Expand Down Expand Up @@ -929,7 +941,7 @@ const GroupRowView = memo(function GroupRowView({
<ChevronIcon size={12} strokeWidth={3} className={`session-tree-chevron${expanded ? " is-open" : ""}`} />
<span className="session-tree-group-name">{project.name}</span>
{project.pinned && <PinIcon size={10} className="session-tree-group-pin" label={t("sidebar.pinnedProject")} />}
{!expanded && <ActivitySummary running={row.running} unread={row.unread} t={t} />}
{!expanded && <ActivitySummary running={row.running} awaiting={row.awaiting} unread={row.unread} t={t} />}
</button>
<span className="session-tree-group-actions">
<button
Expand Down Expand Up @@ -1001,6 +1013,14 @@ const PlainRowView = memo(function PlainRowView({
aria-label={`${t("sidebar.agentRunning")} (${row.running})`}
/>
)}
{row.collapsed && row.awaiting > 0 && (
<span
className="session-tree-pinned-dot is-awaiting"
role="img"
title={t("sidebar.agentAwaitingInput")}
aria-label={`${t("sidebar.agentAwaitingInput")} (${row.awaiting})`}
/>
)}
{row.collapsed && row.unread > 0 && (
<span
className="session-tree-pinned-dot is-unread"
Expand Down
2 changes: 1 addition & 1 deletion docs/agents/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ A displayed custom message starts expanded: pi's TUI draws its default custom me

## Running state polling + reconciliation
- The sidebar polls `/api/agent/running` every 2.5 s while the tab is visible; the session-list response is the initial fallback.
- The same poll carries `awaitingInputSessionIds`: the running sessions with an unanswered blocking `extension_ui_request` (`isBlockingExtensionUiRequest()`, `lib/extension-ui-queue.ts` — select / confirm / input / editor, or a custom overlay that has not closed), as `AgentSessionWrapper.isAwaitingInput()` reads `pendingUiRequests`. The row shows that in the spinner's slot as an amber breathing dot (the unread dot's shape and animation) and stays a running row everywhere else: same `is-running` class, no archive button, group and pinned-header counts unchanged, the archive predicate untouched. The wrapper checks `isRunning()` first, so a custom overlay that outlives its turn is not a parked run. `/api/agent/running` is the only source: a background session's own SSE stream has no listener, which is why the browser notification (`#431`'s `handleAttentionNeeded`) cannot cover this.
- The same poll carries `awaitingInputSessionIds`: the running sessions with an unanswered blocking `extension_ui_request` (`isBlockingExtensionUiRequest()`, `lib/extension-ui-queue.ts` — select / confirm / input / editor, or a custom overlay that has not closed), as `AgentSessionWrapper.isAwaitingInput()` reads `pendingUiRequests`. The row shows that in the spinner's slot as an amber breathing dot (the unread dot's shape and animation) and stays a running row everywhere else: same `is-running` class, no archive button, the archive predicate untouched. Folded headers count what their rows show (#1400): a family awaiting input is an amber dot and count in a group's summary and an amber dot on the pinned section's header, apart from the spinner's count of the families still working (`activityCounts()`, `lib/session-tree.ts`). The project menus' badges count runs only. The wrapper checks `isRunning()` first, so a custom overlay that outlives its turn is not a parked run. `/api/agent/running` is the only source: a background session's own SSE stream has no listener, which is why the browser notification (`#431`'s `handleAttentionNeeded`) cannot cover this.
- The list's `firstMessage` is a row title, not the prompt: `catalogFirstMessage()` sends a skill expansion as its `/skill:` command and cuts the rest to 500 code units (persisted rows and live ones from `getRpcSessionInfos()`). Whole first messages made the list 3.2 MB (one row 325 KB), fetched again by every tab after each run. Fork naming reads the file itself and is unaffected.
- A run's end costs one list rebuild: the wrapper invalidates the list at `agent_end`, before the event reaches any browser, so nothing after it forces one. The tab showing the session reloads through `refreshKey` (`handleAgentEnd`; the reload's row reaches the selected session through `handleSessionsChange` → `mergeCatalogRow()`, which also clears a fresh session's `transient`), the others through the version poll and the background-completion reload, all plain `loadSessions()`. A `force=1` there rescanned again and bumped the version, so each tab not showing the session made every other tab reload once more (5 list requests and 3 rebuilds per run with two tabs). Only the first page load's details hydration still forces, to pick up what the pi CLI appended meanwhile.
- A name set through `AgentSession.setSessionName()` invalidates the list as well, on the wrapper's `session_info_changed`: an extension's title (pi-autoname, pi's `examples/extensions/session-name.ts`) lands after `agent_end` or with no run at all, and the sidebar kept the old name until something else bumped the version (#1237). The open chat's name (`selectedSession.name`) follows through the version poll and `handleSessionsChange`; the state route carries none.
Expand Down
30 changes: 26 additions & 4 deletions lib/session-tree.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function describeRows(rows) {
});
}

test("an awaiting member marks its family; the tree's own counts stay the running ones", () => {
test("an awaiting member marks its family; the group header counts it as awaiting, not running", () => {
const sessions = [session("root"), subagent("child", "root")];
const currentProject = { key: "/work/alpha", root: "/work/alpha" };
const viewing = (overrides) => buildSessionTree(input({ sessions, currentProject, ...overrides }));
Expand All @@ -126,11 +126,33 @@ test("an awaiting member marks its family; the tree's own counts stay the runnin
const row = awaiting.rows.find((item) => item.kind === "session");
assert.equal(row.status.running, true, "an awaiting run is still running");
assert.equal(row.status.awaiting, true);
// Waiting is not a count of its own: the group header keeps counting runs.
assert.equal(awaiting.rows.find((item) => item.kind === "group").running, 1);
// The header counts what the row shows: the amber dot, not the spinner.
const group = awaiting.rows.find((item) => item.kind === "group");
assert.equal(group.running, 0);
assert.equal(group.awaiting, 1);

const running = viewing({ runningIds: new Set(["child"]) });
assert.equal(running.rows.find((item) => item.kind === "session").status.awaiting, false);
assert.equal(running.rows.find((item) => item.kind === "group").running, 1);
assert.equal(running.rows.find((item) => item.kind === "group").awaiting, 0);
});

test("folded headers split running and awaiting families, pinned section included", () => {
const sessions = [session("a"), session("b"), session("c"), session("p"), session("q")];
const model = buildSessionTree(input({
sessions,
uiState: uiState({ sessions: { p: { pinnedAt: 1 }, q: { pinnedAt: 2 } } }),
runningIds: new Set(["a", "b", "c", "p", "q"]),
awaitingIds: new Set(["b", "c", "q"]),
unreadIds: new Set(["a"]),
}));
const header = model.rows.find((item) => item.kind === "pinned-header");
assert.equal(header.running, 1);
assert.equal(header.awaiting, 1);
const group = model.rows.find((item) => item.kind === "group");
assert.equal(group.running, 1);
assert.equal(group.awaiting, 2);
assert.equal(group.unread, 1);
});

test("family ids list the root first, then every subagent", () => {
Expand Down Expand Up @@ -552,7 +574,7 @@ test("pinned families move to the global section, newest pin first", () => {
"group:alpha", "group:a1", "-",
"footer-open",
]);
assert.deepEqual(model.rows[0], { kind: "pinned-header", key: "pinned-header", count: 2, collapsed: false, running: 0, unread: 1 });
assert.deepEqual(model.rows[0], { kind: "pinned-header", key: "pinned-header", count: 2, collapsed: false, running: 0, awaiting: 0, unread: 1 });
const pinnedRow = model.rows[1];
assert.equal(pinnedRow.project.name, "beta", "pinned rows carry their own project");
assert.equal(model.projects.some((project) => project.key === "/work/beta"), false, "a project with only pinned families has no group");
Expand Down
39 changes: 23 additions & 16 deletions lib/session-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ export const PINNED_MORE_KEY = "pinned";
export interface SidebarProject { key: string; root: string; name: string; customName: string | null; pinned: boolean; current: boolean }
export interface SidebarFamilyStatus { running: boolean; awaiting: boolean; unread: boolean; selected: boolean; transient: boolean }

// A folded header's counts follow its rows' right column: a family awaiting
// input counts as awaiting, not running.
export type SidebarRow =
| { kind: "pinned-header"; key: "pinned-header"; count: number; collapsed: boolean; running: number; unread: number }
| { kind: "pinned-header"; key: "pinned-header"; count: number; collapsed: boolean; running: number; awaiting: number; unread: number }
| { kind: "session"; key: string; family: SessionFamily; context: "pinned" | "group" | "archive"; project: SidebarProject; status: SidebarFamilyStatus; archivedAt: number | null }
| { kind: "pinned-more"; key: "pinned-more"; hidden: number; canShowLess: boolean; nextShown: number }
| { kind: "group"; key: string; project: SidebarProject; expanded: boolean; running: number; unread: number }
| { kind: "group"; key: string; project: SidebarProject; expanded: boolean; running: number; awaiting: number; unread: number }
| { kind: "group-more"; key: string; projectKey: string; hidden: number; canShowLess: boolean; nextShown: number }
| { kind: "group-empty"; key: string; project: SidebarProject }
| { kind: "spacer"; key: "pinned-spacer" | "footer-spacer" }
Expand Down Expand Up @@ -245,6 +247,21 @@ function familyStatus(family: SessionFamily, input: FamilyFlagsInput): SidebarFa
};
}

function activityCounts(
families: readonly SessionFamily[],
input: Pick<SessionTreeInput, "runningIds" | "awaitingIds" | "unreadIds">,
): { running: number; awaiting: number; unread: number } {
let running = 0;
let awaiting = 0;
let unread = 0;
for (const family of families) {
if (input.awaitingIds && anyMemberIn(family, input.awaitingIds)) awaiting++;
else if (anyMemberIn(family, input.runningIds)) running++;
if (anyMemberIn(family, input.unreadIds)) unread++;
}
return { running, awaiting, unread };
}

function familyProjectKey(family: SessionFamily): string {
return workspaceKeyOf(family.root);
}
Expand Down Expand Up @@ -442,13 +459,8 @@ export function buildSessionTree(input: SessionTreeInput): SessionTreeModel {
.map((family, index) => ({ family, index }))
.sort((a, b) => compareDesc(pinnedAt(a.family), pinnedAt(b.family)) || a.index - b.index)
.map(({ family }) => family);
let running = 0;
let unread = 0;
for (const family of sorted) {
if (anyMemberIn(family, runningIds)) running++;
if (anyMemberIn(family, input.unreadIds)) unread++;
}
rows.push({ kind: "pinned-header", key: "pinned-header", count: sorted.length, collapsed: input.pinnedCollapsed, running, unread });
const { running, awaiting, unread } = activityCounts(sorted, input);
rows.push({ kind: "pinned-header", key: "pinned-header", count: sorted.length, collapsed: input.pinnedCollapsed, running, awaiting, unread });
if (!input.pinnedCollapsed) {
const { visible, revealed, nextShown } = visibleFamilies(sorted, PINNED_VISIBLE_LIMIT, shownMoreFor(input.moreShown, PINNED_MORE_KEY), input);
for (const family of visible) {
Expand Down Expand Up @@ -512,13 +524,8 @@ export function buildSessionTree(input: SessionTreeInput): SessionTreeModel {
for (const project of orderedProjects) {
const groupFamilies = projectFamilies.get(project.key) ?? [];
const expanded = isGroupExpanded(project, input.groupExpansion);
let running = 0;
let unread = 0;
for (const family of groupFamilies) {
if (anyMemberIn(family, runningIds)) running++;
if (anyMemberIn(family, input.unreadIds)) unread++;
}
rows.push({ kind: "group", key: `group:${project.key}`, project, expanded, running, unread });
const { running, awaiting, unread } = activityCounts(groupFamilies, input);
rows.push({ kind: "group", key: `group:${project.key}`, project, expanded, running, awaiting, unread });

if (expanded) {
if (groupFamilies.length === 0) {
Expand Down
Loading