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