chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted

Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
This commit is contained in:
2026-10-23 16:41:45 +07:00
parent 65a001dad4
commit c8bc4a95a9
54 changed files with 2521 additions and 112 deletions
+111 -17
View File
@@ -69,8 +69,10 @@ import {
ChartColumn,
ChevronDown,
ChevronRight,
GitBranch,
} from "lucide-react";
import { api } from "../lib/api";
import { api, type GitSource } from "../lib/api";
import { useInnerReposMode } from "../lib/innerReposPref";
import { usePermissions } from "../components/AuthGate";
import type {
CwdSuggestion,
@@ -940,6 +942,43 @@ export function Workspace() {
);
}, [panelLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
// The repos INSIDE the workspace folder (a parent folder of several repos):
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
// with the panel's own refresh and every minute, so a clone shows up) —
// unless the account chose the folder's own git only (Settings → Git →
// Changes panel), which neither walks the folder nor shows them.
const innerMode = useInnerReposMode();
const [innerRepos, setInnerRepos] = useState<{
laneId: number;
root: boolean;
items: { path: string; name: string }[];
} | null>(null);
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
api.lanes.repos(laneId, refresh).then(
(v) =>
setInnerRepos((prev) =>
prev?.laneId === laneId &&
prev.root === v.root &&
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
? prev
: { laneId, root: v.root, items: v.items }
),
() => setInnerRepos({ laneId, root: true, items: [] })
);
}, []);
useEffect(() => {
if (panelLaneId === null || innerMode === "own") return;
loadInnerRepos(panelLaneId);
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
return () => clearInterval(timer);
}, [panelLaneId, loadInnerRepos, innerMode]);
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
const repos = inner?.items ?? [];
// A folder that is no repo itself but holds some: its own section would only
// say "not a git repository", so the repos stand alone.
const showRoot = !inner || inner.root || repos.length === 0;
// A file the chat asks for lies in the inner repo whose path prefixes it.
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// Right-click on a section: close extra folders — which REMOVES them from the
@@ -990,15 +1029,19 @@ export function Workspace() {
null
);
const panelLaneRef = useRef<number | null>(null);
const repoOfRef = useRef(repoOf);
repoOfRef.current = repoOf;
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: unfold that section.
// Its path is the workspace folder's: unfold that section (or the
// inner repo's that holds it).
const repo = repoOfRef.current(path);
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete("");
next.delete(repo ? `repo:${repo.path}` : "");
return next;
});
setChangesOpen(true);
@@ -1612,9 +1655,43 @@ export function Workspace() {
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
...(showRoot
? [
{
key: "",
source: undefined as GitSource | undefined,
name:
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
currentLane.cwd,
root: undefined as string | undefined,
},
]
: []),
...repos.map((r) => ({
key: `repo:${r.path}`,
source: r.path as GitSource,
name: r.name,
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
})),
...folders.map((f) => ({
key: String(f.id),
source: f.id as GitSource,
name: f.name,
root: f.path,
})),
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
// The chat's file request goes to the section holding it.
const req =
source === undefined
? changesRequest
: typeof source === "string" && changesRequest
? changesRequest.path.startsWith(`${source}/`)
? {
path: changesRequest.path.slice(source.length + 1),
nonce: changesRequest.nonce,
}
: null
: null;
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
@@ -1627,8 +1704,8 @@ export function Workspace() {
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
filesChanged={source ? 0 : filesChanged}
openRequest={req}
filesChanged={source === undefined ? filesChanged : 0}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
@@ -1641,23 +1718,20 @@ export function Workspace() {
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// One repo only: the one panel, as it always was.
if (sections.length === 1) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
// A row dragged from an extra folder lies outside
// the pane's folder: `dragProjectPath` makes it absolute.
data-drag-root={source ? folders[i - 1]!.path : undefined}
data-drag-root={dragRoot}
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
@@ -1680,7 +1754,12 @@ export function Workspace() {
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
{typeof source === "string" && (
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="truncate" title={dragRoot}>
{name}
</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
@@ -1695,7 +1774,18 @@ export function Workspace() {
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
{innerMode === "all" && (
<MenuItem
testId="panel-git-source-rescan"
onSelect={() => {
sourceCtx.close();
loadInnerRepos(panelAt.laneId, true);
}}
>
{tLanes("changes.rescanRepos")}
</MenuItem>
)}
{/^\d+$/.test(sourceCtx.menu.item) && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
@@ -1777,7 +1867,11 @@ export function Workspace() {
</span>
</button>
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
[
...(showRoot ? [undefined] : []),
...repos.map((r): GitSource => r.path),
...folders.map((f): GitSource => f.id),
].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}