Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
a worktree expands its group. The browser demo supports these worktree actions with fictional
checkouts too. ([#521](https://github.com/devswha/herdr-web-ui/pull/521))

- A linked worktree's row in the sidebar is titled by its workspace name, the one its owner gave it
or the one herdr made from the branch, instead of the branch; the branch follows in dim text
where the name does not already say it, and in the row's tooltip and menu. The sidebar's mark
for an agent waiting for an answer is a red question mark instead of a filled speech bubble;
a finished one keeps its green dot.
([#557](https://github.com/devswha/herdr-web-ui/pull/557))

### Fixed
- A pane whose agent has just started is listed as an agent's pane within a moment, where the
list on this PC could take up to 5 seconds to say so. A device that opened the pane in that
Expand Down
15 changes: 8 additions & 7 deletions scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,8 +930,8 @@ try {
const initialWorktree = await initialResponse.json() as WorktreeOpened;
worktreeWorkspaces.push(initialWorktree.workspace_id);
const initialRow = page.locator(`.worktree-children .workspace-group[data-workspace="${initialWorktree.workspace_id}"]`);
await until(async () => await initialRow.locator(".workspace-name").textContent() === "browser/initial", "the list API discovers an existing worktree's exact branch");
assert.equal(await initialRow.locator(".worktree-workspace-label").textContent(), "Existing checkout", "the workspace label is secondary to its branch");
await until(async () => await initialRow.locator(".workspace-name").textContent() === "Existing checkout", "a named worktree's row is titled by its workspace name");
assert.equal(await initialRow.locator(".worktree-workspace-label").textContent(), "browser/initial", "the list API discovers an existing worktree's exact branch, said after the name");
assert.equal(await repoRow.locator(".workspace-name").textContent(), "herdr-web-ui-test-repo", "the parent keeps its workspace name");
// A list read from before creation must not erase the branch supplied by the create response.
let releaseWorktreeList!: () => void;
Expand Down Expand Up @@ -988,16 +988,17 @@ try {
assert.equal(await childHeader.locator(".sidebar-drag-handle").count(), 0, "the branch workspace has no reorder grip column");
assert.equal(await repoRow.locator(".workspace-select").getAttribute("draggable"), "true", "the parent workspace row supports dragging");
assert.equal(await childHeader.locator(".workspace-select").getAttribute("draggable"), "true", "the branch workspace row supports dragging");
await until(async () => await childHeader.locator(".workspace-name").textContent() === "browser/sidebar", "a newly created child uses its exact branch including the slash");
assert.equal(await childHeader.locator(".worktree-workspace-label").textContent(), "Sidebar redesign");
await until(async () => await childHeader.locator(".workspace-name").textContent() === "Sidebar redesign", "a newly created child is titled by the name it was given");
assert.equal(await childHeader.locator(".worktree-workspace-label").textContent(), "browser/sidebar", "its exact branch, slash included, follows the name");
assert.equal(await repoRow.locator(".workspace-toggle").getAttribute("aria-expanded"), "true", "creation expands a previously folded parent");
assert.equal(await page.locator(`.workspace-group[data-workspace="${repoWorkspace.workspace.workspace_id}"] + .worktree-children > .workspace-list > .workspace-group`).count(), 2,
"existing and newly created branches share their repository workspace");
assert.equal(await childHeader.locator('.workspace-select[aria-current="true"]').count(), 1, "the created branch is selected");
assert.equal(staleWorktreeListFinished, false, "the create response displays the branch before the pending list finishes");
releaseWorktreeList();
await until(() => staleWorktreeListFinished && freshWorktreeListFinished, "the stale inventory is followed by a fresh one");
assert.equal(await childHeader.locator(".workspace-name").textContent(), "browser/sidebar", "a pre-create inventory cannot erase the new branch");
assert.equal(await childHeader.locator(".worktree-workspace-label").textContent(), "browser/sidebar", "a pre-create inventory cannot erase the new branch");
assert.equal(await childHeader.locator(".workspace-name").textContent(), "Sidebar redesign", "nor the name it was given");
await page.unroute("**/api/worktree/list?*");
await childHeader.hover();
await childHeader.locator(".row-menu-toggle").click();
Expand All @@ -1006,9 +1007,9 @@ try {
assert.equal(await childHeader.locator(".workspace-select").getAttribute("draggable"), "false", "an editing field disables row dragging");
await worktreeRename.fill("Sidebar renamed");
await worktreeRename.press("Enter");
await until(async () => await childHeader.locator(".worktree-workspace-label").textContent() === "Sidebar renamed", "the child workspace label is renamed");
await until(async () => await childHeader.locator(".workspace-name").textContent() === "Sidebar renamed", "the child workspace is renamed in its row title");
assert.equal(await childHeader.locator(".workspace-select").getAttribute("draggable"), "true", "row dragging returns after editing finishes");
assert.equal(await childHeader.locator(".workspace-name").textContent(), "browser/sidebar", "renaming a workspace preserves its actual git branch");
assert.equal(await childHeader.locator(".worktree-workspace-label").textContent(), "browser/sidebar", "renaming a workspace preserves its actual git branch");
if (process.env.UI_EVIDENCE_DIR) await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "worktree-branches-sidebar.png") });
// a workspace row's menu also opens from a right-click on the row
await repoRow.locator(".workspace-select").click({ button: "right" });
Expand Down
13 changes: 9 additions & 4 deletions src/components/Sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -453,11 +453,16 @@ button.sidebar-section-label:focus-visible { outline-offset: calc(var(--ring-off
animation: sidebar-working var(--dur-pulse) steps(8, end) infinite;
}

/* Weight follows urgency. Waiting for an answer is the one filled glyph; finished and not yet
looked at is a dot, as an unread mark is; running moves, so it needs no color. Waiting and
done keep the colors they have on every other surface. */
/* A question mark asks for an answer; a dot, as an unread mark is, has finished and was not
looked at; running moves, so it needs no color. Waiting and done keep the colors they have
on every other surface. */
.sidebar-status[data-status="working"] { color: var(--text-dim); }
.sidebar-status[data-status="blocked"] svg { fill: currentColor; }

.sidebar-status-mark {
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1;
}

.sidebar-status-dot {
width: var(--space-2);
Expand Down
27 changes: 16 additions & 11 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from "react";
import { Ellipsis, Folder, FolderOpen, GitBranch, Layers, LoaderCircle, MessageCircle, Pencil, Plus, Terminal, Trash2, TriangleAlert, X } from "lucide-react";
import { Ellipsis, Folder, FolderOpen, GitBranch, Layers, LoaderCircle, Pencil, Plus, Terminal, Trash2, TriangleAlert, X } from "lucide-react";

import "./Sidebar.css";

Expand Down Expand Up @@ -80,10 +80,10 @@ export function StatusBadge({ status, compact = false }: { status?: AgentStatus;
const value = knownStatus(status);
const label = t(STATUS_WORD[value]);
const description = t("Agent {status}", { status: label });
// a compact cell draws only the states that ask for a look, each as heavy as it is urgent: a filled
// bubble waits for an answer, a dot has finished and was not looked at, a dim arc runs. Ready and
// unknown keep the cell, its label and its tooltip
const Icon = { idle: null, working: LoaderCircle, blocked: MessageCircle, done: null, unknown: null }[value];
// a compact cell draws only the states that ask for a look: a red question mark while the agent
// waits for an answer, a green dot once it has finished and was not looked at, a dim arc while
// it runs. Ready and unknown keep the cell, its label and its tooltip
const Icon = { idle: null, working: LoaderCircle, blocked: null, done: null, unknown: null }[value];
return (
<span
className={`badge badge-${value}${compact ? " sidebar-status" : ""}`}
Expand All @@ -93,6 +93,7 @@ export function StatusBadge({ status, compact = false }: { status?: AgentStatus;
title={description}
>
{compact && Icon && <Icon aria-hidden="true" />}
{compact && value === "blocked" && <span className="sidebar-status-mark" aria-hidden="true">?</span>}
{compact && value === "done" && <span className="sidebar-status-dot" aria-hidden="true" />}
<span className={compact ? "visually-hidden" : undefined}>{label}</span>
</span>
Expand Down Expand Up @@ -614,22 +615,26 @@ export function Sidebar({ snapshot, online, selectedPaneId, actions }: SidebarPr
const repoKey = workspace.worktree?.repo_key;
const branch = workspace.worktree?.is_linked_worktree ? branches.get(workspace.workspace_id) : undefined;
const branchTitle = branch?.branch ?? (branch?.isDetached ? t("Detached HEAD") : null);
const rowTitle = branchTitle ?? workspace.label;
const secondaryWorkspaceLabel = branchTitle && workspace.label !== branchTitle && workspace.label !== worktreeLabel(branchTitle) ? workspace.label : null;
// the row says the workspace's name, the one its owner gave it or herdr made from the branch; the
// branch itself is the tooltip's and the menu's, where the name does not already say it
const rowTitle = workspace.label;
const secondaryBranch = branchTitle && workspace.label !== branchTitle && workspace.label !== worktreeLabel(branchTitle) ? branchTitle : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// the tooltip and the menu keep the exact branch (its slashes) even where the row does not repeat it
const branchNote = branchTitle && branchTitle !== workspace.label ? branchTitle : null;
const paths = [...new Set([workspace.worktree?.checkout_path, pane.cwd].filter((path): path is string => Boolean(path)))];
const collapsed = children.length > 0 && repoKey !== undefined && collapsedWorktrees.has(repoKey);
// a folded group's parent stands for its worktrees too, as herdr's collapsed parent does:
// a checkout that waits or has finished must not hide behind the fold
const statusPanes = collapsed ? [...panes, ...children.flatMap((child) => roster.filter((candidate) => candidate.workspace_id === child.workspace_id))] : panes;
// on two lines the row says what its pane is doing, and the workspace it has named until now sits under that
const lines = twoLine ? taskRowLines({ paneTitle: displayPaneTitle(pane), labelled: Boolean(pane.label?.trim()), folder: cwdBasename(pane.cwd), workspace: rowTitle, alias: secondaryWorkspaceLabel }) : null;
const lines = twoLine ? taskRowLines({ paneTitle: displayPaneTitle(pane), labelled: Boolean(pane.label?.trim()), folder: cwdBasename(pane.cwd), workspace: rowTitle, alias: secondaryBranch }) : null;
// a folded group still shows the checkout that is open; the count is of the ones put away
const foldedCount = collapsed ? children.filter((child) => !roster.some((candidate) => candidate.workspace_id === child.workspace_id && candidate.pane_id === selectedPaneId)).length : 0;
const editingWorkspace = editingWorkspaceId === `\u0000${workspace.workspace_id}`;
const editingPane = editingPaneId === pane.pane_id;
const menuOpen = menu?.workspace.workspace_id === workspace.workspace_id && menu.scope === "" && menu.kind === "workspace";
const contentsId = `${rosterId}-worktrees-${encodeURIComponent(repoKey ?? workspace.workspace_id)}`;
const toggleMenu = (anchor: HTMLElement): void => setMenu(menuOpen ? null : { anchor, workspace, pane, scope: "", title: rowTitle, place: [secondaryWorkspaceLabel, ...paths].filter(Boolean).join(" · ") || workspace.label, kind: "workspace" });
const toggleMenu = (anchor: HTMLElement): void => setMenu(menuOpen ? null : { anchor, workspace, pane, scope: "", title: rowTitle, place: [branchNote, ...paths].filter(Boolean).join(" · ") || workspace.label, kind: "workspace" });
return <li
className={`workspace workspace-group pane-item${selected ? " is-selected" : ""}${collapsed ? " is-collapsed" : ""}${dragWorkspaceId === workspace.workspace_id ? " is-dragging" : ""}`}
key={workspace.workspace_id}
Expand Down Expand Up @@ -660,7 +665,7 @@ export function Sidebar({ snapshot, online, selectedPaneId, actions }: SidebarPr
aria-description={`${t("Reorder workspace {name}", { name: workspace.label })} · ${t("Drag to reorder · Alt+↑/↓")}`}
data-pane={pane.pane_id}
aria-current={selected ? "true" : undefined}
title={[`${pane.pane_id} — ${rowTitle}`, rowTitle !== workspace.label ? workspace.label : null, ...paths, paneTitle(pane), agentsTitle(workspace)].filter(Boolean).join(" — ")}
title={[`${pane.pane_id} — ${rowTitle}`, branchNote, ...paths, paneTitle(pane), agentsTitle(workspace)].filter(Boolean).join(" — ")}
onClick={() => actions.selectPane(pane.pane_id)}
onKeyDown={(event) => {
onRowKeyDown(event, workspace.workspace_id);
Expand Down Expand Up @@ -708,7 +713,7 @@ export function Sidebar({ snapshot, online, selectedPaneId, actions }: SidebarPr
{lines.place && <span className="workspace-place">{lines.place}</span>}
</span> : <span className="workspace-copy">
<span className="workspace-name">{rowTitle}</span>
{secondaryWorkspaceLabel && <span className="worktree-workspace-label">{secondaryWorkspaceLabel}</span>}
{secondaryBranch && <span className="worktree-workspace-label">{secondaryBranch}</span>}
{foldedCount > 0 && <span className="workspace-fold-count" aria-hidden="true">+{foldedCount}</span>}
</span>}
<span className="sidebar-pane-meta">
Expand Down
8 changes: 4 additions & 4 deletions src/lib/paneName.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ it("draws a two-line row as the pane's title over its place, without repeating a
// a pane the user named keeps its name, also when it is the folder's
expect(taskRowLines({ paneTitle: "api", labelled: true, folder: "api", workspace: "checkout-api" }))
.toEqual({ title: "api", place: "checkout-api" });
// a linked worktree is placed by its branch, then its own workspace name
expect(taskRowLines({ paneTitle: "session-list", labelled: false, folder: "fix-session-list", workspace: "fix/session-list", alias: "Hotfix" }))
.toEqual({ title: "session-list", place: "fix/session-list · Hotfix · fix-session-list" });
// a linked worktree is placed by its workspace name, then its branch where the name does not say it
expect(taskRowLines({ paneTitle: "session-list", labelled: false, folder: "fix-session-list", workspace: "Hotfix", alias: "fix/session-list" }))
.toEqual({ title: "session-list", place: "Hotfix · fix/session-list · fix-session-list" });
// a pane named as its worktree's workspace does not have that name said again under it
expect(taskRowLines({ paneTitle: "Hotfix", labelled: true, folder: "fix-payment", workspace: "fix/payment", alias: "Hotfix" }))
expect(taskRowLines({ paneTitle: "Hotfix", labelled: true, folder: "fix-payment", workspace: "Hotfix", alias: "fix/payment" }))
.toEqual({ title: "Hotfix", place: "fix/payment · fix-payment" });
});
4 changes: 2 additions & 2 deletions src/lib/paneName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export function taskRowLines({ paneTitle, labelled, folder, workspace, alias }:
labelled: boolean;
/** the last folder of the pane's working directory */
folder: string;
/** the workspace's row name: a linked worktree's branch, else its label */
/** the workspace's row name: its label, which herdr makes from the branch until its owner names it */
workspace: string;
/** a linked worktree's own workspace name, when it differs from its branch */
/** a linked worktree's branch, when the label does not already say it */
alias?: string | null;
}): { title: string; place: string } {
const title = !labelled && paneTitle === folder ? workspace : paneTitle;
Expand Down