feat(ui): finish the workspace-sessions plan — tool filter, session search, explicit Resume

- Sidebar: a tool filter (shown once workspaces of two tools exist, kept in
  agenthub.sidebar.projects → tool; reordering under it keeps the hidden
  workspaces' places), and search that also finds a workspace by one of its
  session names, showing only the matching sessions.
- Workspace home: the repository it belongs to (root, main branch, worktree
  count) under the title, and each recent's cost.
- Transcript page: an explicit Resume for a Claude session started in a
  workspace's own folder (SessionResumeButton). It warns that a resume re-reads
  the whole transcript, re-reads the live panes, then starts in the first free
  pane and opens it; a live session offers Open pane instead.
This commit is contained in:
2026-10-02 01:35:34 +07:00
parent d0535af626
commit 5b269698f6
15 changed files with 597 additions and 36 deletions
+74 -26
View File
@@ -19,6 +19,7 @@ import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import {
moveLane,
readLaneOrder,
@@ -90,19 +91,45 @@ export function WorkspaceTree() {
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
const sorted = useMemo(() => sortByStoredOrder(lanes, order), [lanes, order]);
// The tool filter, offered once workspaces of two tools exist; a stored
// tool no workspace has any more filters nothing.
const aiTools = useAiTools();
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
[lanes, order, toolFilter]
);
const q = query.trim().toLowerCase();
const shown = q
? sorted.filter(
(l) => workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q)
)
: sorted;
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
// A search also finds a workspace by a session's name: every workspace's
// list stays mounted (hidden) to load and count its matches.
const [hits, setHits] = useState<Record<number, number>>({});
const onMatches = useCallback(
(id: number, n: number) => setHits((h) => (h[id] === n ? h : { ...h, [id]: n })),
[]
);
// A cleared search unmounts the collapsed lists; their old counts must not
// surface workspaces in the next search before its fetch answers.
useEffect(() => {
if (!q) setHits({});
}, [q]);
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
const reorder = (from: number, to: number) => {
const ids = sorted.map((l) => l.id);
const moved = moveLane(ids, from, to);
if (moved === ids) return;
writeLaneOrder(moved);
// Under a tool filter the shown ids go back into the slots they held in
// the full order, so the hidden workspaces keep their places.
const shownIds = new Set(ids);
let k = 0;
const full = sortByStoredOrder(lanes, order).map((l) =>
shownIds.has(l.id) ? (moved[k++] ?? l.id) : l.id
);
writeLaneOrder(full);
setOrderTick((n) => n + 1);
};
@@ -148,6 +175,22 @@ export function WorkspaceTree() {
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
{t("tree.title")}
</span>
{tools.length > 1 && (
<select
value={toolFilter ?? ""}
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
aria-label={t("tree.toolFilter")}
title={t("tree.toolFilter")}
className="max-w-[6.5rem] rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] text-fg-secondary"
>
<option value="">{t("tree.allTools")}</option>
{tools.map((id) => (
<option key={id} value={id}>
{aiTools.label(id)}
</option>
))}
</select>
)}
<button
type="button"
onClick={() => {
@@ -191,31 +234,36 @@ export function WorkspaceTree() {
)}
<div className="space-y-px px-1">
{shown.map((lane, i) => (
{sorted.map((lane, i) => (
<div key={lane.id}>
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={reorder}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
{visible(lane) && (
<WorkspaceRow
lane={lane}
index={i}
selected={selected.lane === lane.id && !selected.run}
expanded={expanded(lane.id)}
renaming={renamingId === lane.id}
onSelect={() => {
select(lane.id);
if (!expanded(lane.id)) setExpanded(lane.id, true);
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={reorder}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
query={query}
// A workspace found by its name shows all of its sessions.
query={q && nameHit(lane) ? "" : query}
selectedRunId={selected.lane === lane.id ? selected.run : null}
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
hidden={!visible(lane)}
/>
)}
</div>