feat(lanes): surface child worktrees on a lane card + detect Superpowers skills
Adopting the main repo as its own lane now gets stage detection (cwd matches, same as any other lane), and its card lists every managed-worktree lane provisioned from it with a jump-to link. Also add the two missing Skill-tool detect rules (implement, ship) so detection covers all four Superpowers workflow phases, not just plan/review.
This commit is contained in:
@@ -180,9 +180,17 @@ function since(sec: number | null): string {
|
|||||||
export default function LaneCard({
|
export default function LaneCard({
|
||||||
lane,
|
lane,
|
||||||
onAction,
|
onAction,
|
||||||
|
childWorktrees,
|
||||||
|
onSelectLane,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||||
|
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
||||||
|
* when this lane is itself a source repo (typically an adopted one) that
|
||||||
|
* other lanes were provisioned as worktrees from. */
|
||||||
|
childWorktrees?: Lane[];
|
||||||
|
/** Jumps the Workspace page's selection to another lane's card. */
|
||||||
|
onSelectLane?: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
||||||
@@ -452,6 +460,31 @@ export default function LaneCard({
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{childWorktrees && childWorktrees.length > 0 && (
|
||||||
|
<div
|
||||||
|
data-testid="lane-child-worktrees"
|
||||||
|
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
<div className="text-fg-muted">
|
||||||
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{childWorktrees.map((w) => (
|
||||||
|
<li key={w.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectLane?.(w.id)}
|
||||||
|
className="truncate text-left text-blue-400 hover:underline"
|
||||||
|
title={w.cwd}
|
||||||
|
>
|
||||||
|
#{w.id} {w.title || w.cwd} · {w.status}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{integrations && (
|
{integrations && (
|
||||||
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
||||||
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
|
|||||||
cwd: "/lanes/repo__feature",
|
cwd: "/lanes/repo__feature",
|
||||||
branch: "feat/feature",
|
branch: "feat/feature",
|
||||||
kind: "managed",
|
kind: "managed",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
cwd: "/work/demo",
|
cwd: "/work/demo",
|
||||||
branch: "lane/demo",
|
branch: "lane/demo",
|
||||||
kind: "adopted",
|
kind: "adopted",
|
||||||
|
source_repo: null,
|
||||||
pipeline: "default",
|
pipeline: "default",
|
||||||
session_id: null,
|
session_id: null,
|
||||||
run_id: null,
|
run_id: null,
|
||||||
@@ -90,6 +91,31 @@ describe("LaneCard status badge", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneCard child worktrees", () => {
|
||||||
|
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||||||
|
const onSelectLane = vi.fn();
|
||||||
|
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
||||||
|
render(
|
||||||
|
<LaneCard
|
||||||
|
lane={makeLane({ id: 1 })}
|
||||||
|
onAction={vi.fn()}
|
||||||
|
childWorktrees={[worktree]}
|
||||||
|
onSelectLane={onSelectLane}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
||||||
|
expect(onSelectLane).toHaveBeenCalledWith(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when there are no child worktrees", () => {
|
||||||
|
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||||||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||||||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"addLaneSetupProfile": "Profile",
|
"addLaneSetupProfile": "Profile",
|
||||||
"addLaneSetupTitle": "Setup",
|
"addLaneSetupTitle": "Setup",
|
||||||
"addLaneTitleLabel": "Title",
|
"addLaneTitleLabel": "Title",
|
||||||
|
"worktrees.heading_one": "{{count}} worktree",
|
||||||
|
"worktrees.heading_other": "{{count}} worktrees",
|
||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"autoStage": "auto: {{stage}}",
|
"autoStage": "auto: {{stage}}",
|
||||||
"cardId": "Lane {{id}}",
|
"cardId": "Lane {{id}}",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
"addLaneSetupProfile": "Profile",
|
"addLaneSetupProfile": "Profile",
|
||||||
"addLaneSetupTitle": "Thiết lập",
|
"addLaneSetupTitle": "Thiết lập",
|
||||||
"addLaneTitleLabel": "Tiêu đề",
|
"addLaneTitleLabel": "Tiêu đề",
|
||||||
|
"worktrees.heading_one": "{{count}} worktree",
|
||||||
|
"worktrees.heading_other": "{{count}} worktree",
|
||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"autoStage": "tự động: {{stage}}",
|
"autoStage": "tự động: {{stage}}",
|
||||||
"cardId": "Làn đường {{id}}",
|
"cardId": "Làn đường {{id}}",
|
||||||
|
|||||||
@@ -2319,6 +2319,9 @@ export interface Lane {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
kind: "managed" | "adopted";
|
kind: "managed" | "adopted";
|
||||||
|
/** For a managed worktree lane, the repo it was provisioned from. Null for
|
||||||
|
* an adopted lane (it IS a source repo, not a worktree of one). */
|
||||||
|
source_repo: string | null;
|
||||||
pipeline: string;
|
pipeline: string;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
run_id: string | null;
|
run_id: string | null;
|
||||||
|
|||||||
@@ -1079,6 +1079,10 @@ export function Workspace() {
|
|||||||
<LaneCard
|
<LaneCard
|
||||||
lane={currentLane}
|
lane={currentLane}
|
||||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||||
|
childWorktrees={lanes.filter(
|
||||||
|
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||||
|
)}
|
||||||
|
onSelectLane={setSelectedLaneId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-f
|
|||||||
|
|
||||||
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
|
`--title`, `--base`, and `--slug` are optional. The CLI waits for background provisioning to finish and reports either the ready lane or its failure notes.
|
||||||
|
|
||||||
|
Adopting your main repo (`ccam lanes add --cwd $(pwd)`) is also how you get stage detection working for sessions that work directly in it rather than in a worktree — see "Superpowers skill invocations" below. Once adopted, that lane's own card lists every managed-worktree lane whose `source_repo` matches its `cwd`, each a clickable link to jump to that lane.
|
||||||
|
|
||||||
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
Adding a lane through the dashboard's "+ Add lane" flow also auto-runs, best-effort, in parallel: `ccam lanes profile init` (only if a Node.js project is detected — most repos won't be, and that's a normal outcome, not a failure), `ccam lanes agents install`, and `ccam lanes mcp sync`. None of the three blocks the lane from being created or from each other — a lane whose repo has no MCP servers configured, for instance, still gets created and is still usable, just without a synced `.mcp.json`. The modal shows a ✓/✗ summary of the three results and stays open until dismissed (Cancel/X) — it does not auto-close. Run any of the three manually later (from the lane's own card, or the CLI) if the automatic attempt didn't apply.
|
||||||
|
|
||||||
## Destructive lane actions
|
## Destructive lane actions
|
||||||
@@ -672,6 +674,23 @@ to the flattened input. Either way the result is capped at 120 characters
|
|||||||
lane's tooltip sometimes shows a file path or a skill name rather than a shell
|
lane's tooltip sometimes shows a file path or a skill name rather than a shell
|
||||||
command: it's whichever of the fields above the matching rule's `tool` carried.
|
command: it's whichever of the fields above the matching rule's `tool` carried.
|
||||||
|
|
||||||
|
### Superpowers skill invocations
|
||||||
|
|
||||||
|
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship`
|
||||||
|
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
|
||||||
|
Superpowers workflow skill names (`brainstorming`/`writing-plans`,
|
||||||
|
`executing-plans`/`subagent-driven-development`, `code-review`/
|
||||||
|
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of
|
||||||
|
these skills is a much stronger signal than a matched Bash command, but it is
|
||||||
|
still detection, not declaration — it renders dashed amber and never `done`,
|
||||||
|
same as every other detected stage.
|
||||||
|
|
||||||
|
Detection only ever attributes to a lane whose `cwd` matches the hook's
|
||||||
|
session `cwd` (see "Which lane a signal is credited to" above). A session
|
||||||
|
working directly in a source repo that was never itself adopted as a lane —
|
||||||
|
run `ccam lanes add --cwd $(pwd)` from that repo to fix that — gets no
|
||||||
|
detection at all, because no lane owns that `cwd`.
|
||||||
|
|
||||||
### Detection expires
|
### Detection expires
|
||||||
|
|
||||||
Forward-only would otherwise park a lane at the highest stage it ever touched:
|
Forward-only would otherwise park a lane at the highest stage it ever touched:
|
||||||
|
|||||||
@@ -45,6 +45,10 @@
|
|||||||
"build"
|
"build"
|
||||||
],
|
],
|
||||||
"detect": [
|
"detect": [
|
||||||
|
{
|
||||||
|
"tool": "Skill",
|
||||||
|
"match": "executing-plans|subagent-driven-development"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"tool": "Edit"
|
"tool": "Edit"
|
||||||
},
|
},
|
||||||
@@ -118,6 +122,10 @@
|
|||||||
"push"
|
"push"
|
||||||
],
|
],
|
||||||
"detect": [
|
"detect": [
|
||||||
|
{
|
||||||
|
"tool": "Skill",
|
||||||
|
"match": "finishing-a-development-branch"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"tool": "Bash",
|
"tool": "Bash",
|
||||||
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b"
|
"match": "\\bgit\\b(?:\\s+-c\\s+[\\w.-]+=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+)|\\s+-{1,2}[\\w.-]+(?:=(?:'[^']*'|\\\"[^\\\"]*\\\"|\\S+))?)*\\s+push\\b|\\bgh\\b[^;&|]*\\bpr create\\b"
|
||||||
|
|||||||
Reference in New Issue
Block a user