feat(p2): sidebar branch rows behind the session's expand control

Branch rows are hidden until the session row's expand control (the
subagents' chevron) opens them, above the subagents; the control also
appears for a session with two or more branches and no subagents, with
the branch count beside the subagent count. Showing a subagent still
expands the list; showing any other non-main conversation no longer
does (right after a /tree switch the page shows the new branch before
the row names it main).
This commit is contained in:
Mario Zechner 2026-11-04 16:07:30 +01:00
parent 6515a79772
commit d8981c7ba0
5 changed files with 79 additions and 18 deletions

View file

@ -115,7 +115,9 @@ node:
- Web: "Edit" beside the owner's view of a user message (on hover; always shown on touch screens) and "Branch from
here" under an answer, in the main conversation. Under the session in the sidebar, from two branches on, one row per
branch (`Sessions` rows' `branches`: the stored labels, null for the session title, newest first in a stable order,
the current one marked in place); choosing one switches the session to its end (`SessionTree.select`). The worker
the current one marked in place), behind the session row's expand control, which also shows its subagents
(collapsed by default, expanded while a subagent is shown); choosing one switches the session to its end
(`SessionTree.select`). The worker
reports `p2.main` (`treeSelf`: `main` and the branches with their labels) at start and whenever a commit changes it.
- Search indexes each branch's own entries (after its fork point).

View file

@ -71,8 +71,8 @@ function ThemeButton() {
}
/**
* A session's `/tree` branches (docs/tree.md), from two on, newest first in a stable order: switching only moves the
* "current" mark. Choosing another branch switches the session to it (`onBranch`, the owner's); the pencil beside a
* A session's `/tree` branches (docs/tree.md), newest first in a stable order: switching only moves the "current" mark.
* Shown from two branches on, behind the session row's expand control (`hidden` while collapsed). Choosing another branch switches the session to it (`onBranch`, the owner's); the pencil beside a
* row renames it (`onRenameBranch`). A branch without a label (conversation 1's default) shows the session title.
*/
function BranchRows({
@ -82,6 +82,7 @@ function BranchRows({
onOpen,
onBranch,
onRenameBranch,
hidden,
}: {
session: SessionSummary;
conversationId: number;
@ -89,10 +90,10 @@ function BranchRows({
onOpen: (id: string, conversationId?: number) => void;
onBranch: (id: string, conversationId: number) => void;
onRenameBranch?: (id: string, conversationId: number) => void;
hidden: boolean;
}) {
if (session.branches.length < 2) return null;
return (
<div className="branch-rows">
<div className="branch-rows" id={`branches-${session.id}`} hidden={hidden}>
{session.branches.map((branch) => {
const current = branch.conversation === session.main;
const label = branch.label ?? session.title;
@ -151,11 +152,13 @@ function SessionRow({
archived?: boolean;
children: ReactNode;
}) {
// A subagent shown expands its session's list.
const [expanded, setExpanded] = useState(conversationId !== session.main);
// One control expands a session's branch rows and its subagents; a subagent shown expands them. Not any conversation
// but the main one: right after a `/tree` switch the page shows the new branch before the row names it main.
const showsSubagent = session.subagents.some((agent) => agent.conversationId === conversationId);
const [expanded, setExpanded] = useState(showsSubagent);
useEffect(() => {
if (conversationId !== session.main) setExpanded(true);
}, [conversationId, session.main]);
if (showsSubagent) setExpanded(true);
}, [showsSubagent]);
// One-shot agents are opened from their script's tool card; archived subagents wait behind their own row.
const listed = (archived ? [] : session.subagents)
.filter((agent) => agent.kind !== "agent")
@ -164,6 +167,8 @@ function SessionRow({
const stored = listed.filter((agent) => agent.archived);
const [showStored, setShowStored] = useState(false);
const storedOpen = showStored || stored.some((agent) => agent.conversationId === conversationId);
const branches = onBranch !== undefined && !archived && session.branches.length >= 2;
const nested = [branches && "branches", listed.length > 0 && "subagents"].filter(Boolean).join(" and ");
const row = (agent: (typeof listed)[number]) => (
<button
type="button"
@ -190,25 +195,33 @@ function SessionRow({
);
return (
<div className="session-row">
<div className={`session-header ${listed.length ? "has-subagents" : ""}`}>
<div className={`session-header ${nested ? "has-subagents" : ""}`}>
{children}
{listed.length > 0 && (
{nested && (
<button
type="button"
className="subagent-toggle"
aria-label={expanded ? "Collapse subagents" : "Expand subagents"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${nested}`}
aria-expanded={expanded}
aria-controls={`subagents-${session.id}`}
aria-controls={[branches && `branches-${session.id}`, listed.length > 0 && `subagents-${session.id}`]
.filter(Boolean)
.join(" ")}
onClick={() => setExpanded(!expanded)}
>
<span>{agents.length}</span>
{listed.length > 0 && <span>{agents.length}</span>}
{branches && (
<span className="toggle-branches" title={`${session.branches.length} branches`}>
<BranchIcon size={11} />
{session.branches.length}
</span>
)}
<span className={`chev ${expanded ? "open" : ""}`}>
<ChevronIcon size={12} />
</span>
</button>
)}
</div>
{onBranch && !archived && (
{branches && (
<BranchRows
session={session}
conversationId={conversationId}
@ -216,6 +229,7 @@ function SessionRow({
onOpen={onOpen}
onBranch={onBranch}
onRenameBranch={onRenameBranch}
hidden={!expanded}
/>
)}
{listed.length > 0 && (

View file

@ -2845,6 +2845,12 @@ button.diff-number:hover {
.subagent-toggle:hover {
color: var(--text);
}
/* The branch count beside the subagent count: the fork icon before it. */
.toggle-branches {
display: inline-flex;
align-items: center;
gap: 2px;
}
.subagent-row {
min-height: var(--control-sm);
padding-top: 2px;

View file

@ -17,6 +17,7 @@ import { Catalog, Sessions } from "../../plugins/sessions/contract.ts";
import { Sharing } from "../../plugins/sharing/contract.ts";
import { SessionStatus } from "../../plugins/status/contract.ts";
import { Subagents } from "../../plugins/subagents/contract.ts";
import { SessionTree } from "../../plugins/tree/contract.ts";
import { Browser, browserTests } from "./browser.ts";
import { type Fixture, holdIn, poll, startFixture, until } from "./helpers.ts";
@ -184,6 +185,29 @@ describe("drawer and new-session parity", { skip: !browserTests }, () => {
const status = await holdIn(owner, SessionStatus, {}, "reader");
await childChat.stop(BACKGROUND_CONTEXT);
await until(status.statuses, (states) => !states?.[pinned.id]?.busy.includes(child.conversationId));
// With `/tree` branches as well, the one control shows the branch rows, then the subagents, and hides both.
await until(status.statuses, (states) => states?.[pinned.id]?.tasks === 0);
const tree = await holdIn(owner, SessionTree, { session: pinned.id }, "owner");
const first = (await tree.tree(BACKGROUND_CONTEXT)).nodes.find((node) => node.role === "user")!;
await tree.navigate({ entry: first.entry }, BACKGROUND_CONTEXT);
await tree.select({ conversation: 1 }, BACKGROUND_CONTEXT);
tree.release();
const toggle = `document.querySelector('.subagent-toggle[aria-controls="branches-${pinned.id} subagents-${pinned.id}"]')`;
await browser.wait(`${toggle}?.getAttribute('aria-label')==='Collapse branches and subagents'`);
assert.deepEqual(
await browser.evaluate<string[]>(
`[...document.getElementById('branches-${pinned.id}').parentElement.children].slice(1).map(e=>e.id+(e.hidden?':hidden':''))`,
),
[`branches-${pinned.id}`, `subagents-${pinned.id}`],
);
await browser.evaluate(`${toggle}.click()`);
await browser.wait(
`document.getElementById('branches-${pinned.id}').hidden && document.getElementById('subagents-${pinned.id}').hidden`,
);
assert.equal(
await browser.evaluate<string>(`${toggle}.getAttribute('aria-label')`),
"Expand branches and subagents",
);
});
it("preserves rename, pin and archive after moving actions out of session rows", async () => {

View file

@ -45,8 +45,9 @@ describe("branches in the browser", { skip: !browserTests }, () => {
await browser.metrics(1280, 900);
await browser.open(`http://127.0.0.1:${fixture.server.port}/s/${id}`);
await browser.wait(`document.querySelector('.messages')?.textContent.includes('Echo: second')`);
// One branch: no branch rows yet.
// One branch: no branch rows and no expand control yet.
assert.equal(await browser.evaluate<number>(`document.querySelectorAll('.branch-row').length`), 0);
assert.equal(await browser.evaluate<number>(`document.querySelectorAll('.subagent-toggle').length`), 0);
await browser.evaluate(
`[...document.querySelectorAll('.user')].find(e=>e.textContent.includes('second')).querySelector('.edit-button').click()`,
);
@ -58,8 +59,16 @@ describe("branches in the browser", { skip: !browserTests }, () => {
await browser.wait(
`document.querySelector('.messages')?.textContent.includes('Echo: first') && !document.querySelector('.messages').textContent.includes('Echo: second')`,
);
// The fork has a row at once, newest first, labelled with the edited message; conversation 1 shows the title.
// The fork has a row at once, newest first, labelled with the edited message; conversation 1 shows the title. The
// rows wait collapsed behind the session's expand control (here without subagents), which shows them.
await browser.wait(`document.querySelectorAll('.branch-row').length===2`);
const branches = `document.getElementById('branches-${id}')`;
const toggle = `document.querySelector('.subagent-toggle[aria-controls="branches-${id}"]')`;
assert.equal(await browser.evaluate<boolean>(`${branches}.hidden`), true);
assert.equal(await browser.evaluate<string>(`${toggle}.getAttribute('aria-label')`), "Expand branches");
assert.equal(await browser.evaluate<string>(`${toggle}.textContent`), "2");
await browser.evaluate(`${toggle}.click()`);
await browser.wait(`!${branches}.hidden && ${toggle}.getAttribute('aria-expanded')==='true'`);
assert.deepEqual(
await browser.evaluate<string[]>(`[...document.querySelectorAll('.branch-row')].map(e=>e.textContent)`),
["secondcurrent", "Branches"],
@ -72,10 +81,16 @@ describe("branches in the browser", { skip: !browserTests }, () => {
await browser.wait(`location.pathname==='/s/${id}'`);
await browser.wait(`document.querySelector('.messages')?.textContent.includes('Echo: second')`);
assert.equal((await row()).main, 1);
// The rows keep their order: only the "current" mark moves.
// The rows keep their order: only the "current" mark moves. They stay expanded across the switch.
await browser.wait(
`[...document.querySelectorAll('.branch-row')].map(e=>e.textContent).join('|')==='second|Branchescurrent'`,
);
assert.equal(await browser.evaluate<boolean>(`${branches}.hidden`), false);
// Collapsing hides them again.
await browser.evaluate(`${toggle}.click()`);
await browser.wait(`${branches}.hidden && ${toggle}.getAttribute('aria-label')==='Expand branches'`);
await browser.evaluate(`${toggle}.click()`);
await browser.wait(`!${branches}.hidden`);
});
it("renames a branch from its row; conversation 1 follows the session title until renamed", async () => {