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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user