From a548520b02d723a2a88f3de085cd1f55b4f78187 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Thu, 4 Jun 2026 21:53:38 -0500 Subject: [PATCH 1/7] fix(gsd): open built-in planner app --- docs/user-docs/commands.md | 1 + .../extensions/gsd/commands/catalog.ts | 7 +- .../extensions/gsd/commands/handlers/core.ts | 106 +++++ .../extensions/gsd/planner-handoff.ts | 106 ++--- .../gsd/tests/planner-handoff.test.ts | 121 ++++- .../web-command-parity-contract.test.ts | 19 +- src/tests/integration/web-mode-cli.test.ts | 15 + .../web-project-tab-preservation.test.ts | 1 + src/web-mode.ts | 26 +- web/components/gsd/app-shell.tsx | 31 +- web/components/gsd/planner-view.tsx | 438 ++++++++++++++++++ web/components/gsd/sidebar.tsx | 3 + web/lib/browser-slash-command-dispatch.ts | 13 +- web/lib/workflow-action-execution.ts | 2 +- 14 files changed, 794 insertions(+), 95 deletions(-) create mode 100644 web/components/gsd/planner-view.tsx diff --git a/docs/user-docs/commands.md b/docs/user-docs/commands.md index 081519d30a..fc527bd2e9 100644 --- a/docs/user-docs/commands.md +++ b/docs/user-docs/commands.md @@ -15,6 +15,7 @@ | `/gsd discuss` | Discuss architecture and decisions (stop auto-mode first with `/gsd stop`) | | `/gsd status` | Open the status dashboard | | `/gsd widget` | Cycle dashboard widget: full / small / min / off | +| `/gsd planner [MID]` | Open Planner to review or customize a planned milestone before implementation | | `/gsd notifications` | View, filter, and clear persistent notification history | | `/gsd queue` | Queue and reorder future milestones (`pending`, `queued`, and legacy `planned`; safe during auto mode) | | `/gsd capture` | Fire-and-forget thought capture (works during auto mode) | diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 37eceb48ae..c423e39968 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -17,7 +17,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Git Ship Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|brief|report|queue|quick|discuss|capture|triage|dispatch|verdict|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|closeout|rebuild|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|debug|logs|forensics|changelog|migrate|remote|steer|knowledge|memory|new-milestone|new-project|parallel|cmux|park|unpark|init|setup|onboarding|inspect|extensions|update|upgrade|fast|mcp|rethink|workflow|codebase|notifications|ship|do|usage|context|session-report|backlog|pr-branch|add-tests|scan|language|worktree|eval-review"; + "GSD — Git Ship Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|planner|brief|report|queue|quick|discuss|capture|triage|dispatch|verdict|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|closeout|rebuild|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|debug|logs|forensics|changelog|migrate|remote|steer|knowledge|memory|new-milestone|new-project|parallel|cmux|park|unpark|init|setup|onboarding|inspect|extensions|update|upgrade|fast|mcp|rethink|workflow|codebase|notifications|ship|do|usage|context|session-report|backlog|pr-branch|add-tests|scan|language|worktree|eval-review"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -28,6 +28,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "status", desc: "Open the status dashboard" }, { cmd: "widget", desc: "Cycle widget: full → small → min → off" }, { cmd: "visualize", desc: "Open 10-tab workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)" }, + { cmd: "planner", desc: "Open Planner to review or customize the current plan before implementation" }, { cmd: "brief", desc: "Generate a visual HTML brief: diagram, plan, diff review, recap, table, or slides" }, { cmd: "queue", desc: "Queue and reorder future milestones" }, { cmd: "quick", desc: "Execute a quick task without full planning overhead" }, @@ -331,6 +332,10 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "--text", desc: "Plain-text breakdown" }, { cmd: "--json", desc: "Machine-readable JSON output" }, ], + planner: [ + { cmd: "--dry-run", desc: "Preview the built-in Planner route without opening it" }, + { cmd: "M001", desc: "Open Planner for a specific milestone ID" }, + ], backlog: [ { cmd: "add", desc: "Add item to backlog" }, { cmd: "promote", desc: "Promote backlog item to active slice" }, diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index 33e993f5ce..81920de88b 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -18,6 +18,14 @@ import { getVisualBriefOutputDir } from "../../../visual-brief/artifact-policy.j import { buildVisualBriefPrompt, parseVisualBriefArgs, VISUAL_BRIEF_USAGE } from "../../../visual-brief/prompts.js"; import { GSD_CORE_IMPLEMENTED_CATALOG } from "../../commands-gsd-core.js"; import { GSD_CORE_ALIAS_CATALOG } from "../gsd-core-aliases.js"; +import { + buildGsdPlannerLaunchPlan, + formatGsdPlannerLaunchTarget, + formatPlannerLaunchUnavailable, + LEGACY_GSD_PLANNER_COMMAND, + launchGsdPlanner, + markPlannerHandoffOffered, +} from "../../planner-handoff.js"; export function showHelp(ctx: ExtensionCommandContext, args = ""): void { const summaryLines = [ @@ -33,6 +41,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void { ` /gsd status Status dashboard (${formattedShortcutPair("dashboard")})`, ` /gsd parallel watch Parallel monitor (${formattedShortcutPair("parallel")})`, ` /gsd notifications Notification history (${formattedShortcutPair("notifications")})`, + " /gsd planner Open Planner to review the current plan", " /gsd visualize Workflow visualizer", " /gsd report Generate all HTML reports and open browser", " /gsd brief Visual HTML brief (diagram, plan, diff, recap, table, slides)", @@ -81,6 +90,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void { " /gsd stop Stop auto-mode gracefully", " /gsd pause Pause auto-mode (preserves state, /gsd auto to resume)", " /gsd discuss Start guided milestone/slice discussion", + " /gsd planner Open Planner to customize a planned milestone before implementation", " /gsd new-milestone Create milestone from headless context (used by gsd headless)", " /gsd new-project Bootstrap a new project (use --deep for staged project-level discovery)", " /gsd quick Execute a quick task without full planning overhead", @@ -92,6 +102,7 @@ export function showHelp(ctx: ExtensionCommandContext, args = ""): void { "VISIBILITY", ` /gsd status Status dashboard (${formattedShortcutPair("dashboard")})`, ` /gsd parallel watch Open parallel worker monitor (${formattedShortcutPair("parallel")})`, + " /gsd planner Open Planner for plan review/customization [Mxxx] [--dry-run]", " /gsd widget Cycle status widget [full|small|min|off]", " /gsd visualize Workflow visualizer (progress, timeline, deps, metrics, health, agent, changes, knowledge, captures, export)", " /gsd brief Generate a visual HTML brief [diagram|plan|diff|recap|table|slides] [topic] [--slides]", @@ -506,6 +517,101 @@ async function handleModel( ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}${pinNote}`, "info"); } +const PLANNER_MILESTONE_RE = /^M\d+(?:-[a-z0-9]{6})?$/i; + +function normalizePlannerMilestone(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && PLANNER_MILESTONE_RE.test(trimmed) ? trimmed.toUpperCase() : null; +} + +function plannerArgValue(parts: string[], idx: number): string | undefined { + const value = parts[idx + 1]; + return value && !value.startsWith("--") ? value : undefined; +} + +function parsePlannerArgs(args: string): { dryRun: boolean; milestoneId: string | null } { + const parts = args.trim().split(/\s+/).filter(Boolean); + let dryRun = false; + let milestoneId: string | null = null; + + for (let idx = 0; idx < parts.length; idx++) { + const part = parts[idx]; + if (!part) continue; + if (idx === 0 && part === LEGACY_GSD_PLANNER_COMMAND) { + continue; + } + if (part === "--dry-run") { + dryRun = true; + continue; + } + if (part === "--project") { + if (plannerArgValue(parts, idx)) idx++; + continue; + } + if (part.startsWith("--project=")) { + continue; + } + if (part === "--milestone") { + const value = plannerArgValue(parts, idx); + const parsedMilestoneId = normalizePlannerMilestone(value); + if (!milestoneId && parsedMilestoneId) milestoneId = parsedMilestoneId; + if (value) idx++; + continue; + } + if (part.startsWith("--milestone=")) { + const parsedMilestoneId = normalizePlannerMilestone(part.slice("--milestone=".length)); + if (!milestoneId && parsedMilestoneId) milestoneId = parsedMilestoneId; + continue; + } + const parsedMilestoneId = normalizePlannerMilestone(part); + if (!milestoneId && parsedMilestoneId) { + milestoneId = parsedMilestoneId; + continue; + } + } + + return { dryRun, milestoneId }; +} + +async function handlePlanner(args: string, ctx: ExtensionCommandContext): Promise { + const basePath = projectRoot(); + const parsed = parsePlannerArgs(args); + let milestoneId = parsed.milestoneId; + if (!milestoneId) { + const state = await deriveState(basePath); + milestoneId = state.activeMilestone?.id ?? null; + } + const plan = buildGsdPlannerLaunchPlan({ + basePath, + milestoneId, + }); + + if (parsed.dryRun) { + ctx.ui.notify(formatGsdPlannerLaunchTarget(plan), "info"); + return; + } + + const result = await launchGsdPlanner({ + basePath, + milestoneId, + }); + + if (result.status === "failed") { + ctx.ui.notify(formatPlannerLaunchUnavailable(result.plan, result.error), "warning"); + return; + } + + if (milestoneId) { + markPlannerHandoffOffered(basePath, milestoneId, "command"); + } + ctx.ui.notify( + milestoneId + ? `Opened Planner for ${milestoneId}. Run /gsd auto when you are ready to continue.` + : "Opened Planner. Run /gsd auto when you are ready to continue.", + "info", + ); +} + export async function handleCoreCommand( trimmed: string, ctx: ExtensionCommandContext, diff --git a/src/resources/extensions/gsd/planner-handoff.ts b/src/resources/extensions/gsd/planner-handoff.ts index 8850c1eb63..4fc9f65b47 100644 --- a/src/resources/extensions/gsd/planner-handoff.ts +++ b/src/resources/extensions/gsd/planner-handoff.ts @@ -1,39 +1,37 @@ // Project/App: gsd-pi -// File Purpose: Optional gsd-planner handoff after milestone planning. +// File Purpose: Optional built-in planner handoff after milestone planning. -import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { agentDir as defaultAgentDir, sessionsDir as defaultSessionsDir } from "../../../app-paths.js"; +import { getProjectSessionsDir } from "../../../project-sessions.js"; +import { launchWebMode, type WebModeLaunchStatus } from "../../../web-mode.js"; import { gsdRoot } from "./paths.js"; -export const PLANNER_HANDOFF_RULE_NAME = "planning review handoff -> gsd-planner"; -export const GSD_PLANNER_COMMAND = "gsd-planner"; +export const PLANNER_HANDOFF_RULE_NAME = "planning review handoff -> /gsd planner"; +export const GSD_PLANNER_VIEW = "planner"; +export const LEGACY_GSD_PLANNER_COMMAND = "gsd-planner"; -export interface GsdPlannerSpawnPlan { - command: string; - args: string[]; +export interface GsdPlannerLaunchPlan { cwd: string; + initialPath: string; + milestoneId: string | null; } export interface GsdPlannerLaunchInput { basePath: string; milestoneId?: string | null; - extraArgs?: string[]; } export type GsdPlannerLaunchResult = - | { status: "launched"; plan: GsdPlannerSpawnPlan } - | { status: "failed"; plan: GsdPlannerSpawnPlan; error: Error }; - -type SpawnLike = ( - command: string, - args: readonly string[], - options: SpawnOptions, -) => ChildProcess; + | { status: "launched"; plan: GsdPlannerLaunchPlan; webStatus: WebModeLaunchStatus } + | { status: "failed"; plan: GsdPlannerLaunchPlan; webStatus: WebModeLaunchStatus; error: Error }; export interface GsdPlannerLaunchDeps { - spawn?: SpawnLike; + launchWebMode?: typeof launchWebMode; + agentDir?: string; + sessionsDir?: string; } function handoffDir(basePath: string): string { @@ -69,15 +67,19 @@ export function markPlannerHandoffOffered( ); } -export function buildGsdPlannerSpawnPlan(input: GsdPlannerLaunchInput): GsdPlannerSpawnPlan { - const args = ["--project", input.basePath]; +export function buildGsdPlannerInitialPath(milestoneId?: string | null): string { + const params = new URLSearchParams({ view: GSD_PLANNER_VIEW }); + const normalizedMilestoneId = milestoneId?.trim(); + if (normalizedMilestoneId) params.set("milestone", normalizedMilestoneId); + return `/?${params.toString()}`; +} + +export function buildGsdPlannerLaunchPlan(input: GsdPlannerLaunchInput): GsdPlannerLaunchPlan { const milestoneId = input.milestoneId?.trim(); - if (milestoneId) args.push("--milestone", milestoneId); - args.push(...(input.extraArgs ?? [])); return { - command: GSD_PLANNER_COMMAND, - args, cwd: input.basePath, + initialPath: buildGsdPlannerInitialPath(milestoneId), + milestoneId: milestoneId || null, }; } @@ -85,65 +87,45 @@ function quoteArg(arg: string): string { return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : JSON.stringify(arg); } -export function formatGsdPlannerCommand(plan: GsdPlannerSpawnPlan): string { - return [plan.command, ...plan.args].map(quoteArg).join(" "); +export function formatGsdPlannerLaunchTarget(plan: GsdPlannerLaunchPlan): string { + return `GSD Planner route: ${plan.initialPath}`; } export async function launchGsdPlanner( input: GsdPlannerLaunchInput, deps: GsdPlannerLaunchDeps = {}, ): Promise { - const plan = buildGsdPlannerSpawnPlan(input); - const spawn = deps.spawn ?? spawnChild; - - let child: ChildProcess; - try { - child = spawn(plan.command, plan.args, { - cwd: plan.cwd, - detached: true, - stdio: "ignore", - windowsHide: true, - }); - } catch (err) { + const plan = buildGsdPlannerLaunchPlan(input); + const webStatus = await (deps.launchWebMode ?? launchWebMode)({ + cwd: plan.cwd, + projectSessionsDir: getProjectSessionsDir(plan.cwd, deps.sessionsDir ?? defaultSessionsDir), + agentDir: deps.agentDir ?? defaultAgentDir, + initialPath: plan.initialPath, + }); + + if (!webStatus.ok) { return { status: "failed", plan, - error: err instanceof Error ? err : new Error(String(err)), + webStatus, + error: new Error(webStatus.failureReason), }; } - return new Promise((resolve) => { - let settled = false; - const settle = (result: GsdPlannerLaunchResult) => { - if (settled) return; - settled = true; - resolve(result); - }; - - child.once("error", (err) => { - settle({ - status: "failed", - plan, - error: err instanceof Error ? err : new Error(String(err)), - }); - }); - child.once("spawn", () => { - child.unref(); - settle({ status: "launched", plan }); - }); - }); + return { status: "launched", plan, webStatus }; } export function formatPlannerHandoffPauseReason(milestoneId: string): string { return [ `Milestone ${milestoneId} is planned. Review or customize the plan before implementation if needed.`, - `Run /gsd planner to launch ${GSD_PLANNER_COMMAND}, or run /gsd auto to continue without planner changes.`, + "Run /gsd planner to open the built-in Planner, or run /gsd auto to continue without planner changes.", ].join(" "); } -export function formatPlannerLaunchUnavailable(plan: GsdPlannerSpawnPlan, error: Error): string { +export function formatPlannerLaunchUnavailable(plan: GsdPlannerLaunchPlan, error: Error): string { return [ - `Could not launch ${GSD_PLANNER_COMMAND}: ${error.message}`, - `Install ${GSD_PLANNER_COMMAND} or run it manually: ${formatGsdPlannerCommand(plan)}`, + `Could not launch GSD Planner: ${error.message}`, + `Open the built-in web app manually: ${["gsd", "--web", plan.cwd].map(quoteArg).join(" ")}`, + "Continue without planner edits: /gsd auto", ].join("\n"); } diff --git a/src/resources/extensions/gsd/tests/planner-handoff.test.ts b/src/resources/extensions/gsd/tests/planner-handoff.test.ts index 8df278fd44..7540c08007 100644 --- a/src/resources/extensions/gsd/tests/planner-handoff.test.ts +++ b/src/resources/extensions/gsd/tests/planner-handoff.test.ts @@ -8,13 +8,28 @@ import { GSD_COMMAND_DESCRIPTION, getGsdArgumentCompletions, TOP_LEVEL_SUBCOMMAN import { handleCoreCommand } from "../commands/handlers/core.ts"; import { DISPATCH_RULES } from "../auto-dispatch.ts"; import { - buildGsdPlannerSpawnPlan, - formatGsdPlannerCommand, + buildGsdPlannerLaunchPlan, + formatGsdPlannerLaunchTarget, + formatPlannerLaunchUnavailable, + LEGACY_GSD_PLANNER_COMMAND, + launchGsdPlanner, hasPlannerHandoffBeenOffered, markPlannerHandoffOffered, PLANNER_HANDOFF_RULE_NAME, } from "../planner-handoff.ts"; +function createMockCommandCtx() { + const notifications: Array<{ message: string; level?: string }> = []; + return { + notifications, + ui: { + notify(message: string, level?: string) { + notifications.push({ message, level }); + }, + }, + }; +} + describe("planner handoff command catalog", () => { test("/gsd planner is hidden from description and completions", () => { assert.doesNotMatch(GSD_COMMAND_DESCRIPTION, /\|planner(?:\||$)/); @@ -41,40 +56,106 @@ describe("planner handoff command catalog", () => { }); describe("planner handoff command handler", () => { - test("/gsd planner falls through to the unknown-command path", async () => { - const notifications: Array<{ message: string; level?: string }> = []; - const ctx = { - ui: { - notify(message: string, level?: string) { - notifications.push({ message, level }); - }, - }, - }; + test("/gsd planner dry-run prints the built-in Planner route", async () => { + const ctx = createMockCommandCtx(); const handled = await handleCoreCommand("planner M001 --dry-run --inspect", ctx as any); - assert.equal(handled, false); - assert.deepEqual(notifications, []); + assert.equal(handled, true); + assert.equal(ctx.notifications.length, 1); + assert.equal(ctx.notifications[0]?.level, "info"); + assert.equal(ctx.notifications[0]?.message, "GSD Planner route: /?view=planner&milestone=M001"); + }); + + test("/gsd planner ignores pasted launcher context flags", async () => { + const ctx = createMockCommandCtx(); + + const handled = await handleCoreCommand( + `planner ${LEGACY_GSD_PLANNER_COMMAND} --project /tmp/ignored --milestone M002 --dry-run --inspect --project /tmp/also-ignored --milestone`, + ctx as any, + ); + + assert.equal(handled, true); + assert.equal(ctx.notifications.length, 1); + const message = ctx.notifications[0]?.message ?? ""; + assert.equal((message.match(/--project/g) ?? []).length, 0); + assert.equal((message.match(/--milestone/g) ?? []).length, 0); + assert.match(message, /milestone=M002/); + assert.doesNotMatch(message, /\/tmp\/ignored/); + assert.doesNotMatch(message, /\/tmp\/also-ignored/); }); }); describe("planner handoff launcher", () => { - test("builds gsd-planner command with project and milestone context", () => { - const plan = buildGsdPlannerSpawnPlan({ + test("builds built-in Planner route with milestone context", () => { + const plan = buildGsdPlannerLaunchPlan({ basePath: "/tmp/project with spaces", milestoneId: "M001", - extraArgs: ["--inspect"], }); assert.deepEqual(plan, { - command: "gsd-planner", - args: ["--project", "/tmp/project with spaces", "--milestone", "M001", "--inspect"], cwd: "/tmp/project with spaces", + initialPath: "/?view=planner&milestone=M001", + milestoneId: "M001", }); assert.equal( - formatGsdPlannerCommand(plan), - 'gsd-planner --project "/tmp/project with spaces" --milestone M001 --inspect', + formatGsdPlannerLaunchTarget(plan), + "GSD Planner route: /?view=planner&milestone=M001", + ); + }); + + test("launches Planner through built-in web mode", async () => { + let launchOptions: { + cwd: string; + projectSessionsDir: string; + agentDir: string; + initialPath?: string; + } | undefined; + const result = await launchGsdPlanner( + { + basePath: "/tmp/project", + milestoneId: "M002", + }, + { + agentDir: "/tmp/agent", + sessionsDir: "/tmp/sessions", + launchWebMode: async (options) => { + launchOptions = options; + return { + mode: "web", + ok: true, + cwd: options.cwd, + projectSessionsDir: options.projectSessionsDir, + host: "127.0.0.1", + port: 45123, + url: "http://127.0.0.1:45123", + hostKind: "source-dev", + hostPath: "/tmp/web/package.json", + hostRoot: "/tmp/web", + }; + }, + }, ); + + assert.equal(result.status, "launched"); + assert.equal(launchOptions?.cwd, "/tmp/project"); + assert.equal(launchOptions?.agentDir, "/tmp/agent"); + assert.equal(launchOptions?.initialPath, "/?view=planner&milestone=M002"); + assert.match(String(launchOptions?.projectSessionsDir), /tmp\/sessions/); + }); + + test("launch failure guidance points to built-in web mode", () => { + const plan = buildGsdPlannerLaunchPlan({ + basePath: "/tmp/project", + milestoneId: "M002", + }); + + const message = formatPlannerLaunchUnavailable(plan, new Error("boot-ready: timed out")); + + assert.match(message, /Could not launch GSD Planner: boot-ready: timed out/); + assert.match(message, /Open the built-in web app manually: gsd --web \/tmp\/project/); + assert.match(message, /Continue without planner edits: \/gsd auto/); + assert.doesNotMatch(message, /gsd-planner/); }); test("records one-shot handoff markers per milestone", () => { diff --git a/src/tests/integration/web-command-parity-contract.test.ts b/src/tests/integration/web-command-parity-contract.test.ts index 4b20b21c27..a1bcc77822 100644 --- a/src/tests/integration/web-command-parity-contract.test.ts +++ b/src/tests/integration/web-command-parity-contract.test.ts @@ -230,9 +230,11 @@ test("current GSD command family samples dispatch to correct outcomes after S02" }) const EXPECTED_GSD_OUTCOMES = new Map([ - // Surface commands (19) + // Browser view navigation ["status", "view-navigate"], ["visualize", "view-navigate"], + ["planner", "view-navigate"], + // Surface commands (19) ["forensics", "surface"], ["doctor", "surface"], ["skill-health", "surface"], @@ -269,8 +271,8 @@ const EXPECTED_GSD_OUTCOMES = new Map { assert.equal( EXPECTED_GSD_OUTCOMES.size, - 31, - "EXPECTED_GSD_OUTCOMES must cover all 31 GSD subcommands (19 surface + 2 view-navigate + 9 passthrough + 1 help)", + 32, + "EXPECTED_GSD_OUTCOMES must cover all 32 GSD subcommands (19 surface + 3 view-navigate + 9 passthrough + 1 help)", ) for (const [subcommand, expectedKind] of EXPECTED_GSD_OUTCOMES) { @@ -305,9 +307,10 @@ test("every registered /gsd subcommand has an explicit browser dispatch outcome" } if (expectedKind === "view-navigate") { - await t.test(`/gsd ${subcommand} navigates to the visualizer view`, () => { + await t.test(`/gsd ${subcommand} navigates to the expected view`, () => { const outcome = dispatchBrowserSlashCommand(`/gsd ${subcommand}`) as any - assert.equal(outcome.view, "visualize", `/gsd ${subcommand} should navigate to the visualizer view`) + const expectedView = subcommand === "planner" ? "planner" : "visualize" + assert.equal(outcome.view, expectedView, `/gsd ${subcommand} should navigate to the ${expectedView} view`) }) } } @@ -405,6 +408,12 @@ test("/gsd status and /gsd visualize dispatch as view-navigate to the visualizer } }) +test("/gsd planner dispatches as view-navigate to the planner view", () => { + const outcome = dispatchBrowserSlashCommand("/gsd planner") + assert.equal(outcome.kind, "view-navigate") + assert.equal(outcome.view, "planner") +}) + test("slash /settings and sidebar settings click open the same shared surface contract", () => { const currentContext = { onboardingLocked: false, diff --git a/src/tests/integration/web-mode-cli.test.ts b/src/tests/integration/web-mode-cli.test.ts index 5957acbad4..98c4c69d4f 100644 --- a/src/tests/integration/web-mode-cli.test.ts +++ b/src/tests/integration/web-mode-cli.test.ts @@ -79,6 +79,21 @@ test('web CLI branch preserves cwd-scoped launch inputs', async (t) => { }) }) +test('web authenticated URL keeps initial route before token fragment', () => { + assert.equal( + webMode.buildAuthenticatedWebUrl('http://127.0.0.1:45123', 'abc123', '/?view=planner&milestone=M002'), + 'http://127.0.0.1:45123/?view=planner&milestone=M002#token=abc123', + ) + assert.equal( + webMode.buildAuthenticatedWebUrl('http://127.0.0.1:45123', 'abc123', 'planner'), + 'http://127.0.0.1:45123/planner#token=abc123', + ) + assert.equal( + webMode.buildAuthenticatedWebUrl('http://127.0.0.1:45123', 'abc123', '//example.com/escape'), + 'http://127.0.0.1:45123/#token=abc123', + ) +}) + test('launchWebMode prefers the packaged standalone host and opens the resolved URL', async (t) => { const tmp = mkdtempSync(join(tmpdir(), 'gsd-web-host-')) const standaloneRoot = join(tmp, 'dist', 'web', 'standalone') diff --git a/src/tests/integration/web-project-tab-preservation.test.ts b/src/tests/integration/web-project-tab-preservation.test.ts index 4b7b5d2d14..58ae02d077 100644 --- a/src/tests/integration/web-project-tab-preservation.test.ts +++ b/src/tests/integration/web-project-tab-preservation.test.ts @@ -46,6 +46,7 @@ const KNOWN_VIEWS = new Set([ "power", "chat", "roadmap", + "planner", "files", "activity", "visualize", diff --git a/src/web-mode.ts b/src/web-mode.ts index 41099fd4db..a952c94bbb 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -46,6 +46,8 @@ export interface WebModeLaunchOptions { packageRoot?: string host?: string port?: number + /** Initial browser path to open after the local web host is ready. */ + initialPath?: string /** Additional allowed origins for CORS (forwarded as GSD_WEB_ALLOWED_ORIGINS). */ allowedOrigins?: string[] /** Disable web bearer-token auth for externally protected deployments. */ @@ -407,6 +409,24 @@ function isLoopbackHost(host: string): boolean { return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]' || (isIP(h) === 4 && h.startsWith('127.')) } +export function normalizeWebInitialPath(initialPath?: string): string { + const trimmed = initialPath?.trim() + if (!trimmed) return '/' + + const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + try { + const parsed = new URL(path, 'http://local.gsd') + if (parsed.origin !== 'http://local.gsd') return '/' + return `${parsed.pathname}${parsed.search}` + } catch { + return '/' + } +} + +export function buildAuthenticatedWebUrl(baseUrl: string, authToken: string, initialPath?: string): string { + return `${baseUrl}${normalizeWebInitialPath(initialPath)}#token=${authToken}` +} + function buildSpawnSpec( resolution: ResolvedWebHostBootstrap, host: string, @@ -676,6 +696,9 @@ export async function launchWebMode( const port = options.port ?? await (deps.resolvePort ?? reserveWebPort)(host) const authToken = noAuth ? null : randomBytes(32).toString('hex') const url = `http://${host}:${port}` + const browserUrl = authToken + ? buildAuthenticatedWebUrl(url, authToken, options.initialPath) + : `${url}${normalizeWebInitialPath(options.initialPath)}` const env: NodeJS.ProcessEnv = { ...baseEnv, HOSTNAME: host, @@ -849,7 +872,6 @@ export async function launchWebMode( // Register in multi-instance registry registerInstance(options.cwd, { pid, port, url }, deps.registryPath) } - const browserUrl = authToken ? `${url}/#token=${authToken}` : url try { ;(deps.openBrowser ?? openBrowser)(browserUrl) } catch (browserError) { @@ -873,7 +895,7 @@ export async function launchWebMode( return failure } - const readyUrl = authToken ? `${url}/#token=${authToken}` : url + const readyUrl = browserUrl const success: WebModeLaunchSuccess = { mode: 'web', ok: true, diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx index eb0f5e07b8..326fedded8 100644 --- a/web/components/gsd/app-shell.tsx +++ b/web/components/gsd/app-shell.tsx @@ -32,7 +32,7 @@ import { ProjectsPanel, ProjectSelectionGate } from "@/components/gsd/projects-v import { UpdateBanner } from "@/components/gsd/update-banner" import { getAuthToken, authFetch } from "@/lib/auth" -const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "files", "activity", "visualize"]) +const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "planner", "files", "activity", "visualize"]) const ViewLoading = () => (
@@ -44,6 +44,10 @@ const Roadmap = dynamic(() => import("@/components/gsd/roadmap").then((mod) => m loading: ViewLoading, ssr: false, }) +const PlannerView = dynamic(() => import("@/components/gsd/planner-view").then((mod) => mod.PlannerView), { + loading: ViewLoading, + ssr: false, +}) const FilesView = dynamic(() => import("@/components/gsd/files-view").then((mod) => mod.FilesView), { loading: ViewLoading, ssr: false, @@ -69,6 +73,12 @@ function viewStorageKey(projectCwd: string): string { return `gsd-active-view:${projectCwd}` } +function getInitialRouteView(): string | null { + if (typeof window === "undefined") return null + const view = new URLSearchParams(window.location.search).get("view") + return view && KNOWN_VIEWS.has(view) ? view : null +} + function WorkspaceChrome() { const [activeView, setActiveView] = useState("dashboard") const [isTerminalExpanded, setIsTerminalExpanded] = useState(false) @@ -87,6 +97,7 @@ function WorkspaceChrome() { const [projectsPanelOpen, setProjectsPanelOpen] = useState(false) const [mobileNavOpen, setMobileNavOpen] = useState(false) const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false) + const initialRouteViewRef = useRef(null) const workspace = useGSDWorkspaceState() const { refreshBoot } = useGSDWorkspaceActions() @@ -97,8 +108,17 @@ function WorkspaceChrome() { const scopeLabel = getCurrentScopeLabel(workspace.boot?.workspace) const visibleError = getVisibleWorkspaceError(workspace) + useEffect(() => { + const view = getInitialRouteView() + if (!view) return + initialRouteViewRef.current = view + setActiveView(view) + setViewRestored(true) + }, []) + // Restore persisted view once boot provides projectCwd useEffect(() => { + if (getInitialRouteView()) return if (viewRestored || !projectPath) return const restoreTimer = window.setTimeout(() => { try { @@ -120,7 +140,13 @@ function WorkspaceChrome() { useEffect(() => { if (prevProjectPath.current !== projectPath) { prevProjectPath.current = projectPath - setViewRestored(false) + setViewRestored(Boolean(initialRouteViewRef.current)) + } + }, [projectPath]) + + useEffect(() => { + if (projectPath) { + initialRouteViewRef.current = null } }, [projectPath]) @@ -473,6 +499,7 @@ function WorkspaceChrome() { )} {activeView === "power" && } {activeView === "roadmap" && } + {activeView === "planner" && } {activeView === "files" && } {activeView === "activity" && } {activeView === "visualize" && } diff --git a/web/components/gsd/planner-view.tsx b/web/components/gsd/planner-view.tsx new file mode 100644 index 0000000000..9d08bb00cc --- /dev/null +++ b/web/components/gsd/planner-view.tsx @@ -0,0 +1,438 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { AlertCircle, CheckCircle2, FileText, RefreshCw, Save } from "lucide-react" +import { toast } from "sonner" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { authFetch } from "@/lib/auth" +import { buildProjectPath } from "@/lib/project-url" +import { + getLiveWorkspaceIndex, + useGSDWorkspaceActions, + useGSDWorkspaceState, +} from "@/lib/gsd-workspace-store" +import type { WorkspaceMilestoneTarget } from "@/lib/workspace-types" + +type PlannerDocKind = "roadmap" | "slice" + +interface PlannerDocDescriptor { + key: string + kind: PlannerDocKind + id: string + title: string + path: string +} + +interface PlannerDocState { + content: string + savedContent: string + loading: boolean + saving: boolean + error: string | null +} + +function getRequestedMilestoneId(): string | null { + if (typeof window === "undefined") return null + const milestoneId = new URLSearchParams(window.location.search).get("milestone")?.trim() + return milestoneId || null +} + +function toGsdRelativePath(path: string | undefined): string | null { + if (!path) return null + const normalized = path.replace(/\\/g, "/") + const gsdIndex = normalized.indexOf(".gsd/") + if (gsdIndex >= 0) return normalized.slice(gsdIndex + ".gsd/".length) + if (normalized.startsWith("/")) return null + return normalized.replace(/^\.\//, "") +} + +function fallbackRoadmapPath(milestoneId: string): string { + return `milestones/${milestoneId}/${milestoneId}-ROADMAP.md` +} + +function fallbackSlicePlanPath(milestoneId: string, sliceId: string): string { + return `milestones/${milestoneId}/slices/${sliceId}/${sliceId}-PLAN.md` +} + +function buildDocDescriptors(milestone: WorkspaceMilestoneTarget): PlannerDocDescriptor[] { + const roadmapPath = toGsdRelativePath(milestone.roadmapPath) ?? fallbackRoadmapPath(milestone.id) + const docs: PlannerDocDescriptor[] = [ + { + key: `roadmap:${roadmapPath}`, + kind: "roadmap", + id: milestone.id, + title: "Roadmap", + path: roadmapPath, + }, + ] + + for (const slice of milestone.slices) { + const planPath = toGsdRelativePath(slice.planPath) ?? fallbackSlicePlanPath(milestone.id, slice.id) + docs.push({ + key: `slice:${planPath}`, + kind: "slice", + id: slice.id, + title: slice.title, + path: planPath, + }) + } + + return docs +} + +function emptyDocState(): PlannerDocState { + return { + content: "", + savedContent: "", + loading: true, + saving: false, + error: null, + } +} + +async function loadPlannerFile(projectCwd: string, path: string): Promise { + const url = buildProjectPath(`/api/files?root=gsd&path=${encodeURIComponent(path)}`, projectCwd) + const response = await authFetch(url, { cache: "no-store" }) + if (response.status === 404) return "" + const data = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(typeof data.error === "string" ? data.error : `Load failed (${response.status})`) + } + return typeof data.content === "string" ? data.content : "" +} + +async function savePlannerFile(projectCwd: string, path: string, content: string): Promise { + const response = await authFetch(buildProjectPath("/api/files", projectCwd), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ root: "gsd", path, content }), + }) + if (response.ok) return + + const data = await response.json().catch(() => ({})) + throw new Error(typeof data.error === "string" ? data.error : `Save failed (${response.status})`) +} + +function docTone(state: PlannerDocState | undefined): "error" | "dirty" | "saved" | "loading" | "saving" { + if (!state || state.loading) return "loading" + if (state.saving) return "saving" + if (state.error) return "error" + if (state.content !== state.savedContent) return "dirty" + return "saved" +} + +function docToneLabel(tone: ReturnType): string { + if (tone === "error") return "Error" + if (tone === "dirty") return "Unsaved" + if (tone === "saving") return "Saving" + if (tone === "loading") return "Loading" + return "Saved" +} + +export function PlannerView() { + const workspace = useGSDWorkspaceState() + const { refreshBoot } = useGSDWorkspaceActions() + const liveWorkspace = getLiveWorkspaceIndex(workspace) + const milestones = liveWorkspace?.milestones ?? [] + const activeMilestoneId = liveWorkspace?.active.milestoneId ?? null + const projectCwd = workspace.boot?.project.cwd + + const [selectedMilestoneId, setSelectedMilestoneId] = useState(null) + const [selectedDocKey, setSelectedDocKey] = useState(null) + const [docStates, setDocStates] = useState>({}) + + useEffect(() => { + if (selectedMilestoneId && milestones.some((milestone) => milestone.id === selectedMilestoneId)) return + const requestedMilestoneId = getRequestedMilestoneId() + const requested = milestones.find((milestone) => milestone.id === requestedMilestoneId) + const active = milestones.find((milestone) => milestone.id === activeMilestoneId) + const nextMilestone = requested ?? active ?? milestones[0] ?? null + setSelectedMilestoneId(nextMilestone?.id ?? null) + }, [activeMilestoneId, milestones, selectedMilestoneId]) + + const selectedMilestone = useMemo( + () => milestones.find((milestone) => milestone.id === selectedMilestoneId) ?? null, + [milestones, selectedMilestoneId], + ) + const docDescriptors = useMemo( + () => (selectedMilestone ? buildDocDescriptors(selectedMilestone) : []), + [selectedMilestone], + ) + const docDescriptorKey = docDescriptors.map((doc) => doc.key).join("|") + + useEffect(() => { + const firstDocKey = docDescriptors[0]?.key ?? null + setSelectedDocKey((current) => { + if (current && docDescriptors.some((doc) => doc.key === current)) return current + return firstDocKey + }) + }, [docDescriptorKey, docDescriptors]) + + const loadDocuments = useCallback(async () => { + if (!projectCwd || docDescriptors.length === 0) return + + setDocStates((current) => { + const next = { ...current } + for (const doc of docDescriptors) { + next[doc.key] = { ...(next[doc.key] ?? emptyDocState()), loading: true, error: null } + } + return next + }) + + const loaded = await Promise.all( + docDescriptors.map(async (doc) => { + try { + const content = await loadPlannerFile(projectCwd, doc.path) + return { + key: doc.key, + state: { + content, + savedContent: content, + loading: false, + saving: false, + error: null, + }, + } + } catch (error) { + return { + key: doc.key, + state: { + content: "", + savedContent: "", + loading: false, + saving: false, + error: error instanceof Error ? error.message : String(error), + }, + } + } + }), + ) + + setDocStates((current) => { + const next = { ...current } + for (const entry of loaded) { + next[entry.key] = entry.state + } + return next + }) + }, [docDescriptors, projectCwd]) + + useEffect(() => { + void loadDocuments() + }, [loadDocuments]) + + const selectedDoc = docDescriptors.find((doc) => doc.key === selectedDocKey) ?? null + const selectedState = selectedDoc ? docStates[selectedDoc.key] : null + const dirtyDocs = docDescriptors.filter((doc) => { + const state = docStates[doc.key] + return state && !state.loading && state.content !== state.savedContent + }) + + const updateSelectedContent = useCallback((content: string) => { + if (!selectedDoc) return + setDocStates((current) => ({ + ...current, + [selectedDoc.key]: { + ...(current[selectedDoc.key] ?? emptyDocState()), + content, + loading: false, + error: null, + }, + })) + }, [selectedDoc]) + + const saveDocument = useCallback(async (doc: PlannerDocDescriptor) => { + if (!projectCwd) return + const state = docStates[doc.key] + if (!state || state.loading) return + + setDocStates((current) => ({ + ...current, + [doc.key]: { ...(current[doc.key] ?? state), saving: true, error: null }, + })) + + try { + await savePlannerFile(projectCwd, doc.path, state.content) + setDocStates((current) => ({ + ...current, + [doc.key]: { + ...(current[doc.key] ?? state), + savedContent: state.content, + saving: false, + error: null, + }, + })) + toast.success(`Saved ${doc.id}`) + await refreshBoot({ soft: true }) + } catch (error) { + setDocStates((current) => ({ + ...current, + [doc.key]: { + ...(current[doc.key] ?? state), + saving: false, + error: error instanceof Error ? error.message : String(error), + }, + })) + } + }, [docStates, projectCwd, refreshBoot]) + + const saveDirtyDocuments = useCallback(async () => { + for (const doc of dirtyDocs) { + await saveDocument(doc) + } + }, [dirtyDocs, saveDocument]) + + return ( +
+
+
+

Planner

+
+ {selectedMilestone?.id ?? "No milestone"} + {selectedMilestone?.title && {selectedMilestone.title}} +
+
+
+ + + +
+
+ + {milestones.length === 0 ? ( +
+ No planned milestones found. +
+ ) : ( +
+
+
+ {docDescriptors.map((doc) => { + const state = docStates[doc.key] + const tone = docTone(state) + return ( + + ) + })} +
+
+ +
+ {selectedDoc && selectedState ? ( + <> +
+
+
+

{selectedDoc.title}

+ + {selectedDoc.kind === "roadmap" ? "Roadmap" : "Slice Plan"} + +
+

+ .gsd/{selectedDoc.path} +

+
+ +
+ + {selectedState.error && ( +
+ + {selectedState.error} +
+ )} + +
+