From 5d692d89c235fc910f60acc8eb8a87ae489c8ad9 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Fri, 14 Aug 2026 12:55:05 +0700 Subject: [PATCH] feat(pm): add multi-ticket TUI workspaces --- packages/pm/index.ts | 653 ++++--- .../pm/lib/components/NoTicketsEmptyState.tsx | 30 + packages/pm/lib/components/TicketSidebar.tsx | 135 ++ .../pm/lib/components/interactive.test.ts | 136 +- packages/pm/lib/components/interactive.tsx | 1539 ++++++++++------- packages/pm/lib/ticket-workspaces.test.ts | 290 ++++ packages/pm/lib/ticket-workspaces.ts | 307 ++++ 7 files changed, 2172 insertions(+), 918 deletions(-) create mode 100644 packages/pm/lib/components/NoTicketsEmptyState.tsx create mode 100644 packages/pm/lib/components/TicketSidebar.tsx create mode 100644 packages/pm/lib/ticket-workspaces.test.ts create mode 100644 packages/pm/lib/ticket-workspaces.ts diff --git a/packages/pm/index.ts b/packages/pm/index.ts index a67c973..60fc7dd 100755 --- a/packages/pm/index.ts +++ b/packages/pm/index.ts @@ -13,6 +13,12 @@ import { loadConfig, loadSupabaseConfig, migrateLegacyConfigDir } from "./lib/co import { createEngine, DEFAULT_ISSUE_TYPES, EngineError } from "./lib/engine"; import type { PmEngine, SourceInput, StoryDraft } from "./lib/engine"; import { runInteractiveMode } from "./lib/components/interactive"; +import type { + InteractiveModeHandle, + InteractiveState, + InteractiveTicketAction, +} from "./lib/components/interactive"; +import { getTicket } from "./lib/ticket-workspaces"; import { initializeProject } from "./lib/init"; import { isInteractive, runPmInitWizard } from "./lib/init-wizard"; import { extractHarnessFlags, parseArgs, validateHarnessName } from "./lib/parse-args"; @@ -29,7 +35,6 @@ import { maybeOfferCliUpdate } from "@devintern/utils"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -// Version is injected at build time via --define; falls back to package.json for `bun run`. declare const __VERSION__: string; function readPackageVersion(): string { @@ -54,20 +59,24 @@ function lastStderrLine(chunk: string): string | undefined { } /** - * Show an interactive-mode error/success screen, wait for a key, then reset the wizard - * in-place (no remount) so create-another never leaves a blank terminal. + * Show a success/error screen on a specific ticket. The user restarts that ticket + * via any-key (or opens another from the sidebar) β€” restart is handled by the + * multi-ticket action loop, not here. * * @param handle - Active interactive mode handle. + * @param ticketId - Workspace to update. * @param message - Error or status text shown on the success screen. - * @throws If the user cancels with Ctrl+C while waiting. + * @param createdKey - Optional tracker key for sidebar identity. */ -async function showInteractiveMessageAndRestart( - handle: Awaited>, +function showTicketMessage( + handle: InteractiveModeHandle, + ticketId: string, message: string, -): Promise { - handle.showSuccess(message); - await handle.waitForRestart(); - handle.restart(); + createdKey?: string, +): void { + // Only update if the ticket is still open (user may have closed it mid-run). + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + handle.showSuccess(message, { ticketId, createdKey }); } /** True when an interactive waiter rejected because the user cancelled (Ctrl+C / unmount). */ @@ -75,6 +84,286 @@ function isInteractiveCancelled(error: unknown): boolean { return error instanceof Error && error.message === "Interactive mode cancelled"; } +/** + * Per-ticket draft cache so background agent runs can complete create/edit + * after the user switches away. + */ +type TicketDraftMap = Map; +type LoadedConfig = Awaited>; + +async function createTicketEngine( + config: LoadedConfig, + harnessName: string | undefined, +): Promise { + if (!harnessName || harnessName === config.agent.harness.name) { + return createEngine({ ...config, agent: config.agent }); + } + validateHarnessName(harnessName); + const resolved = resolveHarness({ harnessName }); + resolved.path = resolveExecutablePathStrict(resolved.path, resolved.harness.displayName); + return createEngine({ ...config, agent: resolved }); +} + +/** + * Run story generation for one ticket without blocking other tickets. + */ +async function runTicketGenerate(params: { + ticketId: string; + config: InteractiveState; + handle: InteractiveModeHandle; + appConfig: LoadedConfig; + drafts: TicketDraftMap; +}): Promise { + const { ticketId, config, handle, appConfig, drafts } = params; + if (!config.sourceType || !config.sourceContent) { + showTicketMessage(handle, ticketId, "Error: Incomplete ticket configuration"); + return; + } + + const source: SourceInput = { + type: config.sourceType, + content: config.sourceContent, + }; + const sourceTypeLabel = + source.type === "figma" + ? "Figma design" + : source.type === "log" + ? "error log" + : "free-form prompt"; + + handle.setGenerating(ticketId); + + try { + const ticketEngine = await createTicketEngine(appConfig, config.harnessName); + const storyData = await ticketEngine.generateStory( + { + source, + promptStyle: config.promptStyle, + epicKey: config.epicKey, + extraInstructions: config.customInstructions, + }, + { + onAgentChunk: (chunk, stream) => { + if (stream !== "stderr") return; + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + const line = lastStderrLine(chunk); + if (line) handle.setStatusMessage(line, ticketId); + }, + }, + ); + + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + drafts.set(ticketId, storyData); + handle.setPreviewData(storyData.summary, storyData.description, ticketId); + } catch (error) { + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + if (error instanceof EngineError && error.code === "agent-failed") { + const dumpHint = error.dumpFile ? `\nFull agent output: ${error.dumpFile}` : ""; + showTicketMessage( + handle, + ticketId, + `Error: Failed to analyze ${sourceTypeLabel}\n${error.detail}${dumpHint}`, + ); + return; + } + if (error instanceof EngineError && error.code === "parse-failed") { + const dumpHint = error.dumpFile ? `\nFull agent output: ${error.dumpFile}` : ""; + showTicketMessage( + handle, + ticketId, + `Error: Failed to parse story from agent output\n${error.message}${dumpHint}`, + ); + return; + } + showTicketMessage( + handle, + ticketId, + `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Run story edit for one ticket without blocking other tickets. + */ +async function runTicketEdit(params: { + ticketId: string; + editPrompt: string; + currentSummary: string; + currentDescription: string; + issueType: string; + handle: InteractiveModeHandle; + appConfig: LoadedConfig; + harnessName?: string; + drafts: TicketDraftMap; +}): Promise { + const { + ticketId, + editPrompt, + currentSummary, + currentDescription, + issueType, + handle, + appConfig, + harnessName, + drafts, + } = params; + + handle.setStatusMessage("Updating task description...", ticketId); + + try { + const ticketEngine = await createTicketEngine(appConfig, harnessName); + const storyData = await ticketEngine.editStory( + { + current: { summary: currentSummary, description: currentDescription }, + editPrompt, + issueType, + }, + { + onAgentChunk: (chunk, stream) => { + if (stream !== "stderr") return; + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + const line = lastStderrLine(chunk); + if (line) handle.setStatusMessage(line, ticketId); + }, + }, + ); + + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + drafts.set(ticketId, storyData); + handle.setPreviewData(storyData.summary, storyData.description, ticketId); + } catch (error) { + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + if (error instanceof EngineError && error.code === "agent-failed") { + handle.setStatusMessage(`Update failed: ${error.detail}`, ticketId); + // Return to preview so the user can retry edit or create + handle.setPreviewData(currentSummary, currentDescription, ticketId); + return; + } + const dump = + error instanceof EngineError && error.dumpFile + ? ` β€” full agent output: ${error.dumpFile}` + : ""; + handle.setStatusMessage(`Update failed to parse${dump}`, ticketId); + handle.setPreviewData(currentSummary, currentDescription, ticketId); + } +} + +/** + * Create the tracker task for one ticket from its cached draft. + */ +async function runTicketCreate(params: { + ticketId: string; + config: InteractiveState; + handle: InteractiveModeHandle; + engine: PmEngine; + drafts: TicketDraftMap; +}): Promise { + const { ticketId, config, handle, engine, drafts } = params; + const storyData = drafts.get(ticketId) ?? config.previewData; + if (!storyData) { + showTicketMessage(handle, ticketId, "Error: No draft available to create"); + return; + } + + const draft: StoryDraft = { + summary: storyData.summary, + description: storyData.description, + }; + + try { + const createResult = await engine.createTask(draft, { + issueType: config.issueType, + projectKey: config.projectKey, + epicKey: config.epicKey, + }); + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + + let message = `Task created: ${createResult.task.url}`; + if (createResult.epicLinkError) { + message += `\nWarning: Failed to link to epic: ${createResult.epicLinkError}`; + } + showTicketMessage(handle, ticketId, message, createResult.task.key); + drafts.delete(ticketId); + } catch (error) { + if (!getTicket(handle.getWorkspaces(), ticketId)) return; + showTicketMessage( + handle, + ticketId, + `Error: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Multi-ticket interactive session: listen for per-ticket actions and run + * agent/tracker work concurrently so switching never cancels background runs. + */ +async function runInteractiveSession(params: { + handle: InteractiveModeHandle; + engine: PmEngine; + config: LoadedConfig; +}): Promise { + const { handle, engine, config } = params; + const drafts: TicketDraftMap = new Map(); + + while (true) { + let action: InteractiveTicketAction; + try { + action = await handle.waitForAction(); + } catch (error) { + if (isInteractiveCancelled(error)) { + handle.cleanup(); + console.log("\nBye!"); + process.exit(0); + } + throw error; + } + + switch (action.type) { + case "generate": + // Fire-and-forget so other tickets can still generate/edit/create. + void runTicketGenerate({ + ticketId: action.ticketId, + config: action.config, + handle, + appConfig: config, + drafts, + }); + break; + case "edit": { + const ticket = getTicket(handle.getWorkspaces(), action.ticketId); + const issueType = ticket?.wizard.issueType ?? "Task"; + void runTicketEdit({ + ticketId: action.ticketId, + editPrompt: action.editPrompt, + currentSummary: action.currentSummary, + currentDescription: action.currentDescription, + issueType, + handle, + appConfig: config, + harnessName: ticket?.wizard.harnessName, + drafts, + }); + break; + } + case "create": + void runTicketCreate({ + ticketId: action.ticketId, + config: action.config, + handle, + engine, + drafts, + }); + break; + case "restart": + handle.restart(action.ticketId); + drafts.delete(action.ticketId); + break; + } + } +} + /** * CLI entry point: routes commands, runs interactive or batch task creation, * and orchestrates agent prompts with the configured task backend. @@ -98,19 +387,14 @@ async function main() { process.exit(0); } - // Extract harness flag up front so the harness can be validated once for - // both interactive and non-interactive modes. const harnessFlags = extractHarnessFlags(args); // Migrate legacy .claude-pm directory to .devintern-pm if needed await migrateLegacyConfigDir(); - // Parse arguments - null means interactive mode, 'init' means run initialization. - // Help exits inside parseArgs before any update check. + // Parse arguments - null means interactive mode, 'init' means run initialization const parsedArgs = parseArgs(args); - // Check npm for a newer global `@getdevintern/pm` before real work. - // Non-interactive sessions skip install by default. await maybeOfferCliUpdate({ packageName: "@getdevintern/pm", binName: "devpm", @@ -179,9 +463,7 @@ async function main() { return; } - // Validate the harness name (if any) only for modes that actually use it. validateHarnessName(harnessFlags.harness); - let source: SourceInput; let epicKey: string | undefined; let extraInstructions: string | undefined; @@ -191,15 +473,13 @@ async function main() { let model: string | undefined; let issueType: string; let projectKey: string | undefined; - let interactiveHandle: Awaited> | null = null; + let interactiveHandle: InteractiveModeHandle | null = null; let configForInteractive: Awaited> | undefined; try { // Load config early for all operational modes. Interactive use is free // under FSL, so pm performs no license check. - configForInteractive = await loadConfig({ - harnessName: harnessFlags.harness, - }); + configForInteractive = await loadConfig({ harnessName: harnessFlags.harness }); const engine: PmEngine = await createEngine(configForInteractive, { model: parsedArgs?.model, @@ -253,15 +533,9 @@ async function main() { const installedHarnesses = listInstalledHarnesses({ currentHarnessName: currentHarness.name, }); - // loadConfig() already validated the active harness; keep it in the - // picker even if detection via PATH alone would miss a custom path. const harnessesForPicker = installedHarnesses.some((h) => h.name === currentHarness.name) ? installedHarnesses : [currentHarness, ...installedHarnesses]; - const harnesses = harnessesForPicker.map((h) => ({ - name: h.name, - displayName: h.displayName, - })); interactiveHandle = await runInteractiveMode({ projects: projectsData, defaultProjectKey: engine.defaultProjectKey, @@ -270,8 +544,11 @@ async function main() { ? (projectKey: string) => engine.listIssueTypes(projectKey) : undefined, backendName: engine.backendName, - harnesses, - currentHarnessName: configForInteractive.agent.harness.name, + harnesses: harnessesForPicker.map((h) => ({ + name: h.name, + displayName: h.displayName, + })), + currentHarnessName: currentHarness.name, supportsEpicLinking: engine.supportsEpicLinking, }); } catch (error) { @@ -286,77 +563,13 @@ async function main() { process.exit(1); } - // Create-another loop: reuse the same Ink session. Remounting via main() - // previously left a blank screen (old instance not cleaned up + console.clear). - const config = configForInteractive; - - while (true) { - let interactiveConfig; - try { - interactiveConfig = await interactiveHandle.waitForCompletion(); - } catch (error) { - if (isInteractiveCancelled(error)) { - interactiveHandle.cleanup(); - console.log("\nBye!"); - process.exit(0); - } - throw error; - } - - if (!interactiveConfig.sourceType || !interactiveConfig.sourceContent) { - console.error("❌ Interactive mode was cancelled or incomplete"); - interactiveHandle.cleanup(); - process.exit(1); - } - - source = { - type: interactiveConfig.sourceType, - content: interactiveConfig.sourceContent, - }; - epicKey = interactiveConfig.epicKey; - extraInstructions = interactiveConfig.customInstructions; - promptStyle = interactiveConfig.promptStyle; - decompose = interactiveConfig.decompose; - confirm = false; // Interactive mode handles confirmation differently - model = undefined; - issueType = interactiveConfig.issueType; - projectKey = interactiveConfig.projectKey; - - // Re-resolve harness if user selected a different one in interactive mode. - // Engine reads config.agent at call time, so mutating config is enough. - if ( - interactiveConfig.harnessName && - interactiveConfig.harnessName !== config.agent.harness.name - ) { - validateHarnessName(interactiveConfig.harnessName); - const resolved = resolveHarness({ - harnessName: interactiveConfig.harnessName, - }); - resolved.path = resolveExecutablePathStrict(resolved.path, resolved.harness.displayName); - config.agent = resolved; - } - - const shouldContinue = await runCreateFlow({ - source, - epicKey, - extraInstructions, - promptStyle, - decompose, - confirm, - model, - issueType, - projectKey, - interactiveHandle, - config, - engine, - }); - - if (!shouldContinue) { - interactiveHandle.cleanup(); - return; - } - // handle.restart() already ran inside runCreateFlow; loop for next task - } + // Multi-ticket session: concurrent per-ticket agent runs, sidebar switch/close. + await runInteractiveSession({ + handle: interactiveHandle, + engine, + config: configForInteractive, + }); + return; } // CLI mode (one-shot) @@ -370,7 +583,6 @@ async function main() { model = cliArgs.model; issueType = cliArgs.issueType; projectKey = undefined; // CLI mode uses default project - const attachments = cliArgs.attachments; // Config already loaded and verified early const config = configForInteractive!; @@ -385,14 +597,14 @@ async function main() { model, issueType, projectKey, - attachments, - interactiveHandle: null, + attachments: cliArgs.attachments, config, engine, }); } catch (error) { if (isInteractiveCancelled(error)) { - interactiveHandle?.cleanup(); + const handle = interactiveHandle as InteractiveModeHandle | null; + handle?.cleanup(); console.log("\nBye!"); process.exit(0); } @@ -412,17 +624,14 @@ interface CreateFlowParams { issueType: string; projectKey?: string; attachments?: Array<{ path: string; name?: string }>; - interactiveHandle: Awaited> | null; config: Awaited>; engine: PmEngine; } /** - * Runs agent generation + task creation for one CLI or interactive create cycle. - * - * @returns `true` when interactive mode should loop for another task; `false` when done. + * Runs agent generation + task creation for one non-interactive CLI create cycle. */ -async function runCreateFlow(params: CreateFlowParams): Promise { +async function runCreateFlow(params: CreateFlowParams): Promise { const { source, epicKey, @@ -434,7 +643,6 @@ async function runCreateFlow(params: CreateFlowParams): Promise { issueType, projectKey, attachments, - interactiveHandle, config, engine, } = params; @@ -447,68 +655,45 @@ async function runCreateFlow(params: CreateFlowParams): Promise { : source.type === "log" ? "error log" : "free-form prompt"; - if (!interactiveHandle) { - console.log(`Step 1: Creating ${engine.backendName} story from ${sourceTypeLabel}\n`); - console.log(`Source type: ${source.type}`); - if (source.type === "figma") { - console.log(`Figma URL: ${source.content}`); - } else { - // Show first 100 chars of content - const preview = - source.content.length > 100 ? source.content.substring(0, 100) + "..." : source.content; - const label = source.type === "log" ? "Log preview" : "Prompt preview"; - console.log(`${label}: ${preview}`); - } - console.log(`Prompt style: ${promptStyle}`); - console.log(`Issue type: ${issueType}`); - if (model) { - console.log(`Model: ${model}`); - } - if (epicKey) { - console.log(`Epic: ${epicKey}`); - } - if (extraInstructions) { - console.log(`Custom instructions: ${extraInstructions}`); - } - if (attachments?.length) { - console.log(`Attachments: ${attachments.map((a) => a.path).join(", ")}`); - } - } - - // In interactive mode, show generating state - const interactiveUi = interactiveHandle; - if (interactiveUi) { - interactiveUi.setGenerating(); + console.log(`Step 1: Creating ${engine.backendName} story from ${sourceTypeLabel}\n`); + console.log(`Source type: ${source.type}`); + if (source.type === "figma") { + console.log(`Figma URL: ${source.content}`); } else { - console.log(`\nπŸ€– Running ${config.agent.harness.displayName}...\n`); + // Show first 100 chars of content + const preview = + source.content.length > 100 ? source.content.substring(0, 100) + "..." : source.content; + const label = source.type === "log" ? "Log preview" : "Prompt preview"; + console.log(`${label}: ${preview}`); } + console.log(`Prompt style: ${promptStyle}`); + console.log(`Issue type: ${issueType}`); + if (model) { + console.log(`Model: ${model}`); + } + if (epicKey) { + console.log(`Epic: ${epicKey}`); + } + if (extraInstructions) { + console.log(`Custom instructions: ${extraInstructions}`); + } + if (attachments?.length) { + console.log(`Attachments: ${attachments.map((attachment) => attachment.path).join(", ")}`); + } + + console.log(`\nπŸ€– Running ${config.agent.harness.displayName}...\n`); let storyData: StoryDraft; try { - storyData = await engine.generateStory( - { source, promptStyle, epicKey, extraInstructions, attachments }, - { - onAgentChunk: interactiveUi - ? (chunk, stream) => { - if (stream !== "stderr") return; - const line = lastStderrLine(chunk); - if (line) { - interactiveUi.setStatusMessage(line); - } - } - : undefined, - }, - ); + storyData = await engine.generateStory({ + source, + promptStyle, + epicKey, + extraInstructions, + attachments, + }); } catch (error) { if (error instanceof EngineError && error.code === "agent-failed") { - const dumpHint = error.dumpFile ? `\nFull agent output: ${error.dumpFile}` : ""; - if (interactiveHandle) { - await showInteractiveMessageAndRestart( - interactiveHandle, - `Error: Failed to analyze ${sourceTypeLabel}\n${error.detail}${dumpHint}`, - ); - return true; // continue create-another loop - } console.error(`❌ Failed to analyze ${sourceTypeLabel}`); console.error(error.detail); if (error.dumpFile) { @@ -517,14 +702,6 @@ async function runCreateFlow(params: CreateFlowParams): Promise { process.exit(1); } if (error instanceof EngineError && error.code === "parse-failed") { - const dumpHint = error.dumpFile ? `\nFull agent output: ${error.dumpFile}` : ""; - if (interactiveHandle) { - await showInteractiveMessageAndRestart( - interactiveHandle, - `Error: Failed to parse story from agent output\n${error.message}${dumpHint}`, - ); - return true; // continue create-another loop - } console.error("\n❌ Failed to parse story requirements from Agent output"); console.error("Error:", error.message); console.error("Output:", error.detail); @@ -536,68 +713,8 @@ async function runCreateFlow(params: CreateFlowParams): Promise { throw error; } - // In interactive mode, show preview and wait for confirmation or edits - if (interactiveHandle) { - const ui = interactiveHandle; - ui.setPreviewData(storyData.summary, storyData.description); - - // Edit loop - allow user to request edits multiple times - while (true) { - const editRequest = await Promise.race([ - ui.waitForCompletion().then(() => null), - ui.waitForEdit(), - ]); - - if (!editRequest) { - // User confirmed, break out of edit loop - break; - } - - // User requested an edit - ui.setStatusMessage("Updating task description..."); - - try { - storyData = await engine.editStory( - { - current: { - summary: editRequest.currentSummary, - description: editRequest.currentDescription, - }, - editPrompt: editRequest.editPrompt, - issueType, - }, - { - onAgentChunk: (chunk, stream) => { - if (stream !== "stderr") return; - const line = lastStderrLine(chunk); - if (line) { - ui.setStatusMessage(line); - } - }, - }, - ); - - // Show updated preview - ui.setPreviewData(storyData.summary, storyData.description); - } catch (error) { - if (error instanceof EngineError && error.code === "agent-failed") { - ui.setStatusMessage(`Update failed: ${error.detail}`); - continue; - } - console.error("❌ Failed to parse updated task from Agent"); - console.error("Error:", error instanceof Error ? error.message : error); - if (error instanceof EngineError && error.dumpFile) { - ui.setStatusMessage(`Update failed to parse β€” full agent output: ${error.dumpFile}`); - } - // Loop will retry - } - } - } - - if (!interactiveHandle) { - console.log(`\nπŸ“ Creating ${engine.backendName} ${issueType.toLowerCase()}...`); - console.log(` Title: ${storyData.summary}`); - } + console.log(`\nπŸ“ Creating ${engine.backendName} ${issueType.toLowerCase()}...`); + console.log(` Title: ${storyData.summary}`); // Create the task via backend (links to epic when supported by the tracker; // trackers without epic support skip linking silently so we never create a @@ -610,58 +727,41 @@ async function runCreateFlow(params: CreateFlowParams): Promise { }); const createdTask = createResult.task; - if (!interactiveHandle) { - console.log( - `\nβœ… ${engine.backendName} ${issueType.toLowerCase()} created: ${createdTask.url}`, - ); - } + console.log( + `\nβœ… ${engine.backendName} ${issueType.toLowerCase()} created: ${createdTask.url}`, + ); - if (createResult.epicLinked && !interactiveHandle) { + if (createResult.epicLinked) { console.log(`πŸ”— Linking story to epic ${epicKey}...`); console.log(`βœ… Story linked to epic ${epicKey}`); } if (createResult.epicLinkError) { console.error(`⚠️ Warning: Failed to link to epic: ${createResult.epicLinkError}`); - if (!interactiveHandle) { - console.log("Continuing with task decomposition..."); - } + console.log("Continuing with task decomposition..."); } if (createResult.labelsApplyError) { console.error(`⚠️ Warning: Failed to apply labels: ${createResult.labelsApplyError}`); } - if (createResult.attachmentsUploaded > 0 && !interactiveHandle) { + if (createResult.attachmentsUploaded > 0) { console.log(`πŸ“Ž Uploaded ${createResult.attachmentsUploaded} attachment(s)`); } if (createResult.attachmentErrors?.length) { - for (const err of createResult.attachmentErrors) { - console.error(`⚠️ Warning: Failed to upload attachment: ${err}`); + for (const error of createResult.attachmentErrors) { + console.error(`⚠️ Warning: Failed to upload attachment: ${error}`); } } - if (!interactiveHandle) { - console.log(); - } + console.log(); // Check if we should decompose into subtasks if (!decompose) { - if (!interactiveHandle) { - console.log(`βœ… ${issueType} created successfully!\n`); - console.log("Summary:"); - console.log(` ${issueType}: ${createdTask.url}`); - if (epicKey) { - console.log(` Epic: ${epicKey}`); - } - console.log("\nπŸŽ‰ Done!"); - } - - // In interactive mode, show success and wait for user to start another task - if (interactiveHandle) { - await showInteractiveMessageAndRestart( - interactiveHandle, - `Task created: ${createdTask.url}`, - ); - return true; // continue create-another loop (same Ink session) + console.log(`βœ… ${issueType} created successfully!\n`); + console.log("Summary:"); + console.log(` ${issueType}: ${createdTask.url}`); + if (epicKey) { + console.log(` Epic: ${epicKey}`); } - return false; + console.log("\nπŸŽ‰ Done!"); + return; } // Step 2: Run Agent to decompose the story into tasks @@ -773,26 +873,11 @@ async function runCreateFlow(params: CreateFlowParams): Promise { console.log(` Epic: ${epicKey}`); } console.log("\nπŸŽ‰ Done!"); - - // In interactive mode, show success and wait for user to start another task - if (interactiveHandle) { - await showInteractiveMessageAndRestart(interactiveHandle, `Task created: ${createdTask.url}`); - return true; // continue create-another loop (same Ink session) - } - return false; } catch (error) { if (isInteractiveCancelled(error)) { throw error; } console.error("\n❌ Error:", error instanceof Error ? error.message : error); - // In interactive mode, show error and wait for user to restart in-place - if (interactiveHandle) { - await showInteractiveMessageAndRestart( - interactiveHandle, - `Error: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - return true; // continue create-another loop - } process.exit(1); } } diff --git a/packages/pm/lib/components/NoTicketsEmptyState.tsx b/packages/pm/lib/components/NoTicketsEmptyState.tsx new file mode 100644 index 0000000..c1486d1 --- /dev/null +++ b/packages/pm/lib/components/NoTicketsEmptyState.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Box, Text } from "ink"; + +/** + * Shown when no ticket workspaces are open. + * Explains multi-ticket work and how to open the first ticket. + */ +export function NoTicketsEmptyState() { + return ( + + + No open tickets + + + + Each ticket is its own workspace β€” composer inputs, agent output, and progress stay + separate. Open several and switch from the sidebar while an agent is still running on + another ticket. + + + + Open your first ticket + + Press Ctrl+N (or n here) to start a + new ticket workspace. + + + + ); +} diff --git a/packages/pm/lib/components/TicketSidebar.tsx b/packages/pm/lib/components/TicketSidebar.tsx new file mode 100644 index 0000000..4835297 --- /dev/null +++ b/packages/pm/lib/components/TicketSidebar.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { + ticketAgentStatus, + ticketAgentStatusShort, + ticketSubtitle, + ticketTitle, +} from "../ticket-workspaces.ts"; +import type { TicketAgentStatus, TicketWorkspace } from "../ticket-workspaces.ts"; + +export interface TicketSidebarProps { + tickets: TicketWorkspace[]; + activeTicketId: string | null; + /** Highlight when sidebar focus mode is on (arrow navigation). */ + focused?: boolean; + /** Max title width for truncation in narrow terminals. */ + titleWidth?: number; +} + +function statusColor(status: TicketAgentStatus): string | undefined { + switch (status) { + case "running": + return "cyan"; + case "error": + return "red"; + case "done": + return "green"; + case "ready": + return "yellow"; + case "idle": + return undefined; + } +} + +function TicketRow({ + ticket, + active, + index, + titleWidth, +}: { + ticket: TicketWorkspace; + active: boolean; + index: number; + titleWidth: number; +}) { + const isError = + ticket.wizard.step === "success" && + Boolean(ticket.wizard.successMessage?.toLowerCase().startsWith("error")); + const status = ticketAgentStatus(ticket.wizard.step, isError); + const title = ticketTitle(ticket, titleWidth); + const subtitle = ticketSubtitle(ticket, Math.max(12, titleWidth - 4)); + const marker = active ? "β–Έ" : " "; + const num = String(index + 1); + + return ( + + + + {marker} + {num}{" "} + + + {title} + + + + [{ticketAgentStatusShort(status)}] + + + {subtitle && active ? ( + + + {subtitle} + + + ) : null} + + ); +} + +/** + * Sidebar of open ticket workspaces for the TUI. + * Switching does not cancel agent runs β€” each row shows that ticket's own status. + */ +export function TicketSidebar({ + tickets, + activeTicketId, + focused = false, + titleWidth = 18, +}: TicketSidebarProps) { + return ( + + + Open tickets + + {tickets.length === 0 ? "None yet" : `${tickets.length} open`} + + {tickets.length === 0 ? ( + + Open a ticket to start. Keep several open and switch while agents run. + + ) : ( + tickets.map((ticket, index) => ( + + )) + )} + + + Ctrl+N new + Ctrl+W close + Ctrl+↑/↓ switch + Ctrl+1..9 select + + + ); +} diff --git a/packages/pm/lib/components/interactive.test.ts b/packages/pm/lib/components/interactive.test.ts index bf9c8e3..2e397c2 100644 --- a/packages/pm/lib/components/interactive.test.ts +++ b/packages/pm/lib/components/interactive.test.ts @@ -245,11 +245,10 @@ describe("runInteractiveMode", () => { await waitFor(() => handle.getStep() === "regenerating"); const result = await editPromise; - expect(result).toEqual({ - editPrompt: "Make it shorter", - currentSummary: "Summary", - currentDescription: "Description", - }); + expect(result.editPrompt).toBe("Make it shorter"); + expect(result.currentSummary).toBe("Summary"); + expect(result.currentDescription).toBe("Description"); + expect(result.ticketId).toBe(handle.getActiveTicketId()!); }); test("Esc from preview after setPreviewData stays on preview with data (no blank step)", async () => { @@ -470,6 +469,133 @@ describe("runInteractiveMode", () => { }); }); +describe("multi-ticket workspaces", () => { + let handle: Awaited>; + let stdin: FakeStdin; + + beforeEach(async () => { + stdin = new FakeStdin(); + handle = await runInteractiveMode({ + stdin: stdin as unknown as NodeJS.ReadStream, + }); + // Allow the Ink tree to commit the seeded ticket before multi-ticket actions. + await waitFor(() => handle.getActiveTicketId() !== null); + }); + + afterEach(() => { + handle?.cleanup(); + }); + + test("session starts with one open ticket (smooth single-ticket path)", () => { + const ws = handle.getWorkspaces(); + expect(ws.tickets).toHaveLength(1); + expect(ws.activeTicketId).toBe(ws.tickets[0]!.id); + expect(handle.getStep()).toBe("source-type"); + }); + + test("opening a second ticket focuses it without losing the first", async () => { + const firstId = handle.getActiveTicketId()!; + handle.setPreviewData("First draft", "Body A", firstId); + await waitFor(() => handle.getStep() === "preview"); + + const secondId = handle.openTicket(); + await waitFor( + () => handle.getWorkspaces().tickets.length === 2 && handle.getStep() === "source-type", + ); + + const ws = handle.getWorkspaces(); + expect(ws.tickets).toHaveLength(2); + expect(ws.activeTicketId).toBe(secondId); + expect(handle.getStep()).toBe("source-type"); + + const first = ws.tickets.find((t) => t.id === firstId)!; + expect(first.wizard.step).toBe("preview"); + expect(first.wizard.previewData?.summary).toBe("First draft"); + }); + + test("closing the active ticket selects a sensible neighbor with state intact", async () => { + const firstId = handle.getActiveTicketId()!; + handle.setPreviewData("Ticket A", "Desc A", firstId); + await waitFor(() => handle.getStep() === "preview"); + + const secondId = handle.openTicket(); + await waitFor(() => handle.getActiveTicketId() === secondId); + + handle.closeTicket(secondId); + await waitFor(() => handle.getWorkspaces().tickets.length === 1); + + const ws = handle.getWorkspaces(); + expect(ws.tickets).toHaveLength(1); + expect(ws.tickets[0]!.id).toBe(firstId); + expect(ws.activeTicketId).toBe(firstId); + expect(ws.tickets[0]!.wizard.previewData?.summary).toBe("Ticket A"); + await waitFor(() => handle.getStep() === "preview"); + expect(handle.getPreviewData()?.summary).toBe("Ticket A"); + }); + + test("background ticket can finish while another is active", async () => { + const firstId = handle.getActiveTicketId()!; + handle.setGenerating(firstId); + await waitFor(() => handle.getStep() === "generating"); + + const secondId = handle.openTicket(); + await waitFor( + () => handle.getActiveTicketId() === secondId && handle.getStep() === "source-type", + ); + + // Complete generation on background ticket A + handle.setPreviewData("Done in background", "Body", firstId); + await waitFor(() => { + const t = handle.getWorkspaces().tickets.find((x) => x.id === firstId); + return t?.wizard.step === "preview" && t.wizard.previewData?.summary === "Done in background"; + }); + + // Active ticket still on source-type + expect(handle.getActiveTicketId()).toBe(secondId); + expect(handle.getStep()).toBe("source-type"); + + // Switch back to A and see completed preview + handle.activateTicket(firstId); + await waitFor(() => handle.getActiveTicketId() === firstId && handle.getStep() === "preview"); + expect(handle.getPreviewData()?.summary).toBe("Done in background"); + }); + + test("closing the last ticket shows empty session (no active ticket)", async () => { + const id = handle.getActiveTicketId()!; + expect(handle.getWorkspaces().tickets).toHaveLength(1); + + handle.closeTicket(id); + await waitFor(() => handle.getWorkspaces().tickets.length === 0); + await waitFor(() => handle.getActiveTicketId() === null); + + expect(handle.getWorkspaces().activeTicketId).toBeNull(); + // getStep falls back when empty + expect(handle.getStep()).toBe("source-type"); + }); + + test("waitForAction delivers create with ticketId", async () => { + const actionPromise = handle.waitForAction(); + + handle.setPreviewData("Title", "Body"); + await waitFor(() => handle.getStep() === "preview"); + + stdin.write("y"); + const action = await actionPromise; + expect(action.type).toBe("create"); + if (action.type === "create") { + expect(action.ticketId).toBeTruthy(); + expect(action.config.previewData?.summary).toBe("Title"); + } + }); + + test("Ctrl+N keyboard shortcut opens a new ticket", async () => { + await waitFor(() => handle.getActiveTicketId() !== null); + stdin.write("\x0e"); // Ctrl+N + await waitFor(() => handle.getWorkspaces().tickets.length === 2); + expect(handle.getWorkspaces().tickets.length).toBe(2); + }); +}); + describe("getPreviousStep / canNavigateBack", () => { test("mirrors forward skip edges when epic and issue-type are disabled", () => { const flags = { hasEpicStep: false, hasIssueTypeStep: false }; diff --git a/packages/pm/lib/components/interactive.tsx b/packages/pm/lib/components/interactive.tsx index ff2bdc3..089436a 100644 --- a/packages/pm/lib/components/interactive.tsx +++ b/packages/pm/lib/components/interactive.tsx @@ -1,74 +1,87 @@ -import React, { useState, useRef, useEffect } from "react"; -import { render, Box, Text, useInput, useApp } from "ink"; +import React, { useState, useRef, useEffect, useReducer, useCallback } from "react"; +import { render, Box, Text, useInput, useApp, useStdout } from "ink"; import { ScrollView } from "ink-scroll-view"; import type { ScrollViewRef } from "ink-scroll-view"; import { MarkdownText } from "./MarkdownText"; import { PromptInput } from "./PromptInput"; +import { TicketSidebar } from "./TicketSidebar"; +import { NoTicketsEmptyState } from "./NoTicketsEmptyState"; import { getDefaultIssueType, orderIssueTypes } from "../issue-types"; import { uiSymbols } from "../runtime/terminal.js"; +import { + createInitialWizard, + getActiveTicket, + getTicket, + initialTicketWorkspacesState, + isTicketBusy, + nextTicketId, + ticketWorkspacesReducer, +} from "../ticket-workspaces.ts"; +import type { TicketWizardState, TicketWorkspacesState, WizardStep } from "../ticket-workspaces.ts"; + +/** Wizard + ticket fields exposed to the CLI orchestrator. */ +export type InteractiveState = TicketWizardState; -interface Task { - summary: string; - description: string; - type: "Story" | "Task" | "Bug" | "Epic"; -} - -interface InteractiveState { - step: - | "project" - | "source-type" - | "source-input" - | "custom" - | "epic" - | "style" - | "issue-type" - | "harness" - | "confirm" - | "generating" - | "preview" - | "edit-prompt" - | "regenerating" - | "done" - | "success"; - projectKey?: string; - sourceType?: "figma" | "log" | "prompt"; - sourceContent?: string; - customInstructions?: string; - epicKey?: string; - promptStyle: "pm" | "technical"; - issueType: string; - harnessName?: string; - decompose: boolean; - tasks: Task[]; - previewData?: { - summary: string; - description: string; - }; - editPrompt?: string; - successMessage?: string; - statusMessage?: string; -} +/** + * User-driven actions from any open ticket. The orchestrator listens with + * `waitForAction` so multiple tickets can generate concurrently. + */ +export type InteractiveTicketAction = + | { type: "generate"; ticketId: string; config: InteractiveState } + | { type: "create"; ticketId: string; config: InteractiveState } + | { + type: "edit"; + ticketId: string; + editPrompt: string; + currentSummary: string; + currentDescription: string; + } + | { type: "restart"; ticketId: string }; export interface InteractiveModeHandle { - setGenerating: () => void; - setStatusMessage: (message: string) => void; - setPreviewData: (summary: string, description: string) => void; + /** Primary multi-ticket API: next user action on any open ticket. */ + waitForAction: () => Promise; + /** + * @deprecated Prefer waitForAction. Resolves on generate or create confirm + * for any ticket (tests / single-ticket paths). + */ waitForCompletion: () => Promise; + /** + * @deprecated Prefer waitForAction. Resolves when the user submits an edit + * prompt on any ticket. + */ waitForEdit: () => Promise<{ editPrompt: string; currentSummary: string; currentDescription: string; + ticketId: string; }>; - showSuccess: (message: string) => void; - waitForRestart: () => Promise; - restart: () => void; + /** + * @deprecated Prefer waitForAction. Resolves when the user restarts a ticket + * after success. + */ + waitForRestart: () => Promise<{ ticketId: string }>; + + setGenerating: (ticketId?: string) => void; + setStatusMessage: (message: string, ticketId?: string) => void; + setPreviewData: (summary: string, description: string, ticketId?: string) => void; + updatePreviewData: (summary: string, description: string, ticketId?: string) => void; + showSuccess: (message: string, options?: { ticketId?: string; createdKey?: string }) => void; + restart: (ticketId?: string) => void; cleanup: () => void; - getStep: () => InteractiveState["step"]; + getStep: () => WizardStep; getPreviewData: () => { summary: string; description: string } | undefined; - /** Current harness name selected in the wizard (if any). */ + /** Current harness selected for the active ticket. */ getHarnessName: () => string | undefined; - /** Updates preview data without changing the current step. */ - updatePreviewData: (summary: string, description: string) => void; + getActiveTicketId: () => string | null; + /** Snapshot of open workspaces (tests / debugging / orchestrator). */ + getWorkspaces: () => TicketWorkspacesState; + /** Open a new ticket workspace and focus it. */ + openTicket: () => string; + /** Close a ticket (busy tickets still close; UI may confirm first via keyboard). */ + closeTicket: (id: string) => void; + /** Switch the active ticket without cancelling background work. */ + activateTicket: (id: string) => void; } export interface InteractiveModeOptions { @@ -77,11 +90,9 @@ export interface InteractiveModeOptions { issueTypes?: string[]; fetchIssueTypes?: (projectKey: string) => Promise; backendName?: string; - /** @deprecated Prefer `harnesses` + `currentHarnessName` for on-the-fly switching. */ + /** @deprecated Prefer `harnesses` + `currentHarnessName`. */ harnessDisplayName?: string; - /** Installed harnesses offered in the Ctrl+G picker. */ harnesses?: Array<{ name: string; displayName: string }>; - /** Active harness name at wizard start. */ currentHarnessName?: string; /** * Whether the selected tracker can persist an epic/parent link. When @@ -91,7 +102,7 @@ export interface InteractiveModeOptions { stdin?: NodeJS.ReadStream; } -const TEXT_ENTRY_STEPS = new Set([ +const TEXT_ENTRY_STEPS = new Set([ "project", "source-input", "custom", @@ -99,8 +110,6 @@ const TEXT_ENTRY_STEPS = new Set([ "edit-prompt", ]); -type WizardStep = InteractiveState["step"]; - interface StepNavFlags { hasEpicStep: boolean; hasIssueTypeStep: boolean; @@ -137,13 +146,11 @@ export function getPreviousStep(step: WizardStep, flags: StepNavFlags): WizardSt return "custom"; case "confirm": return "style"; - case "harness": - // Modal step: Esc is handled via stepBeforeHarness, not the linear map. - return null; case "edit-prompt": return "preview"; - // Preview stays put: index.ts holds a waitForCompletion/waitForEdit race; - // navigating away without resolving either promise would strand the agent loop. + case "harness": + return null; + // Preview stays put: orchestrator holds waitForAction race. // Generating/regenerating/done: agent is in flight β€” Esc cannot cancel safely. // Success: any-key restart is handled separately in useInput. case "preview": @@ -163,13 +170,14 @@ export function canNavigateBack(step: WizardStep, flags: StepNavFlags): boolean } /** - * Launches the multi-step Ink interactive wizard for creating PM tasks. + * Launches the multi-ticket Ink interactive shell for creating PM tasks. * - * Renders the form, exposes imperative hooks for generation/preview/edit flows, - * and resolves when the user confirms or cancels. + * Renders a sidebar of open ticket workspaces plus the active ticket's wizard, + * exposes imperative hooks for generation/preview/edit flows, and supports + * concurrent agent runs across tickets. * * @param options - Optional projects, issue types, fetcher, and backend display name. - * @returns Handle with methods to drive generation, preview, edit, and restart cycles. + * @returns Handle with methods to drive generation, preview, edit, and multi-ticket actions. */ export async function runInteractiveMode( options?: InteractiveModeOptions, @@ -177,18 +185,47 @@ export async function runInteractiveMode( return new Promise((resolve, reject) => { let completed = false; let cancelled = false; - let updateState: ((updates: Partial) => void) | null = null; + + type DispatchFn = (action: Parameters[1]) => void; + let dispatchRef: DispatchFn | null = null; + let openTicketFn: (() => string) | null = null; + let closeTicketFn: ((id: string) => void) | null = null; + let activateTicketFn: ((id: string) => void) | null = null; + /** + * Immediate workspace snapshot for the orchestrator (ticket still open?). + * Updated on every dispatch so background agent completions see closes promptly. + */ + let workspacesRef: TicketWorkspacesState = initialTicketWorkspacesState; + /** + * Post-commit snapshot for getStep/getPreviewData. Updated only after React + * applies state so tests waiting on getStep() are synchronized with useInput + * handlers that close over the last render. + */ + let publishedWorkspaces: TicketWorkspacesState = initialTicketWorkspacesState; + /** Preview updates deferred while a ticket is on the edit-prompt step. */ + const previewBuffer = new Map(); + /** Patches queued before the Ink tree mounts and registers dispatch. */ + const pendingUpdates: Array<{ + ticketId: string; + updates: Partial; + }> = []; + + const actionQueue: InteractiveTicketAction[] = []; + let actionWaiter: ((action: InteractiveTicketAction) => void) | null = null; + let completePromiseResolve: ((config: InteractiveState) => void) | null = null; let completePromiseReject: ((error: Error) => void) | null = null; let editPromiseResolve: - | ((data: { editPrompt: string; currentSummary: string; currentDescription: string }) => void) + | ((data: { + editPrompt: string; + currentSummary: string; + currentDescription: string; + ticketId: string; + }) => void) | null = null; let editPromiseReject: ((error: Error) => void) | null = null; - let restartPromiseResolve: (() => void) | null = null; + let restartPromiseResolve: ((data: { ticketId: string }) => void) | null = null; let restartPromiseReject: ((error: Error) => void) | null = null; - let currentStep: InteractiveState["step"] = "source-type"; - let visiblePreviewDataRef: { summary: string; description: string } | null = null; - let currentHarnessNameRef: string | undefined = options?.currentHarnessName; const cancelError = () => new Error("Interactive mode cancelled"); @@ -196,6 +233,8 @@ export async function runInteractiveMode( const rejectPendingWaiters = () => { cancelled = true; const error = cancelError(); + actionWaiter = null; + actionQueue.length = 0; if (completePromiseReject) { completePromiseReject(error); completePromiseReject = null; @@ -213,6 +252,52 @@ export async function runInteractiveMode( } }; + const emitAction = (action: InteractiveTicketAction) => { + if (cancelled) return; + if (actionWaiter) { + const resolveAction = actionWaiter; + actionWaiter = null; + resolveAction(action); + } else { + actionQueue.push(action); + } + + if (action.type === "generate" || action.type === "create") { + if (completePromiseResolve) { + const resolveComplete = completePromiseResolve; + completePromiseResolve = null; + completePromiseReject = null; + resolveComplete(action.config); + } + } else if (action.type === "edit") { + if (editPromiseResolve) { + const resolveEdit = editPromiseResolve; + editPromiseResolve = null; + editPromiseReject = null; + resolveEdit({ + editPrompt: action.editPrompt, + currentSummary: action.currentSummary, + currentDescription: action.currentDescription, + ticketId: action.ticketId, + }); + } + } else if (action.type === "restart") { + if (restartPromiseResolve) { + const resolveRestart = restartPromiseResolve; + restartPromiseResolve = null; + restartPromiseReject = null; + resolveRestart({ ticketId: action.ticketId }); + } + } + }; + + const resolveTicketId = (ticketId?: string): string | null => { + if (ticketId) { + return getTicket(workspacesRef, ticketId) ? ticketId : null; + } + return workspacesRef.activeTicketId; + }; + // Use provided projects or empty array const allProjects = options?.projects || []; const defaultProjectKey = options?.defaultProjectKey; @@ -225,13 +310,8 @@ export async function runInteractiveMode( ] : allProjects; - // Whether to show the issue type selection step at all const hasIssueTypeStep = options?.issueTypes !== undefined; - - // Whether to show the epic linking step at all (skip for trackers that - // can't persist an epic/parent link). Defaults to true for compatibility. const hasEpicStep = options?.supportsEpicLinking ?? true; - const allHarnesses = options?.harnesses || []; const currentHarnessName = options?.currentHarnessName; const orderedHarnesses = currentHarnessName @@ -241,125 +321,143 @@ export async function runInteractiveMode( ] : allHarnesses; const hasHarnessStep = orderedHarnesses.length > 0; - currentHarnessNameRef = currentHarnessName; - - // Tracks the step the user was on before opening the harness modal via Ctrl+G, - // so ESC / Enter / number selection return there instead of always jumping - // to confirm or style. Reset on every transition out of "harness". - let stepBeforeHarness: InteractiveState["step"] | null = null; - - // First step after collecting custom instructions, accounting for skips. const stepAfterCustom = hasEpicStep ? "epic" : hasIssueTypeStep ? "issue-type" : "style"; - // Use provided issue types or default fallback const defaultIssueTypes = options?.issueTypes && options.issueTypes.length > 0 ? options.issueTypes : ["Story", "Task", "Bug", "Epic"]; + const makeFreshWizard = (): TicketWizardState => + createInitialWizard({ + projectKey: defaultProjectKey, + issueType: getDefaultIssueType(defaultIssueTypes), + harnessName: currentHarnessName, + }); + + // Seed one ticket synchronously so single-ticket use and getStep() work before paint. + const seededWorkspaces = ticketWorkspacesReducer(initialTicketWorkspacesState, { + type: "session-started", + id: nextTicketId(), + wizard: makeFreshWizard(), + }); + workspacesRef = seededWorkspaces; + publishedWorkspaces = seededWorkspaces; + /** - * Root Ink component for the interactive task-creation wizard. - * - * @returns Full-screen wizard UI with step-specific prompts and preview panes. + * Root Ink component: multi-ticket sidebar + active ticket wizard. */ - const InteractiveFormWithPreview: React.FC = () => { + const InteractiveShell: React.FC = () => { const { exit } = useApp(); - const initialIssueType = getDefaultIssueType(defaultIssueTypes); - const [state, setState] = useState({ - step: "source-type", - projectKey: defaultProjectKey, // Start with default project - promptStyle: "pm", - issueType: initialIssueType, - harnessName: currentHarnessName, - decompose: false, - tasks: [], - }); - const [input, setInput] = useState(""); + const { stdout } = useStdout(); + const terminalWidth = stdout?.columns ?? 80; + + const [workspaces, reactDispatch] = useReducer(ticketWorkspacesReducer, seededWorkspaces); + /** Keeps workspacesRef in lockstep with every UI and imperative update. */ + const dispatch = useCallback((action: Parameters[1]) => { + workspacesRef = ticketWorkspacesReducer(workspacesRef, action); + reactDispatch(action); + }, []); const [inputVersion, setInputVersion] = useState(0); const [issueTypes, setIssueTypes] = useState(defaultIssueTypes); const orderedIssueTypes = orderIssueTypes(issueTypes); const [isLoadingIssueTypes, setIsLoadingIssueTypes] = useState(false); + const [closeConfirmId, setCloseConfirmId] = useState(null); const scrollViewRef = useRef(null); const sym = uiSymbols(); - const bufferedPreviewData = useRef<{ summary: string; description: string } | null>(null); - const prevStepRef = useRef(state.step); - const stateRef = useRef(state); + const prevStepByTicket = useRef>(new Map()); + const prevActiveIdRef = useRef(seededWorkspaces.activeTicketId); const [elapsedSeconds, setElapsedSeconds] = useState(0); const generatingStartedAt = useRef(null); - - // Cache for issue types per project const issueTypesCache = useRef>(new Map()); + const stepBeforeHarness = useRef>(new Map()); - /** - * Resets the text input field and bumps the key to remount ink-text-input. - * - * @param nextValue - Value to seed into the input after reset (default empty string). - */ - const resetInput = (nextValue = "") => { - setInput(nextValue); - setInputVersion((version) => version + 1); - }; + const activeTicket = getActiveTicket(workspaces); + const activeWizard = activeTicket?.wizard; + const activeStep = activeWizard?.step; + + // Register dispatch; publish committed state for getStep/getPreviewData; + // flush pre-mount patches once. + useEffect(() => { + dispatchRef = dispatch; + publishedWorkspaces = workspaces; + if (pendingUpdates.length > 0) { + const queued = pendingUpdates.splice(0, pendingUpdates.length); + for (const item of queued) { + // Snapshot already has these patches; only push into React. + reactDispatch({ + type: "wizard-patched", + id: item.ticketId, + patch: item.updates, + }); + } + } + }, [dispatch, reactDispatch, workspaces]); - // Initialize cache with default project's issue types if available - React.useEffect(() => { + useEffect(() => { if (defaultProjectKey && defaultIssueTypes.length > 0) { issueTypesCache.current.set(defaultProjectKey, defaultIssueTypes); } }, []); - // Apply buffered previewData when leaving edit-prompt for any other step, - // and clear any stale buffer when entering edit-prompt. - React.useEffect(() => { - const prevStep = prevStepRef.current; - const nextStep = state.step; - - if (prevStep !== "edit-prompt" && nextStep === "edit-prompt") { - bufferedPreviewData.current = null; + // Remount text inputs when switching tickets so draftInput seeds correctly + useEffect(() => { + if (activeTicket?.id !== prevActiveIdRef.current) { + prevActiveIdRef.current = activeTicket?.id ?? null; + setInputVersion((v) => v + 1); } + }, [activeTicket?.id]); - // Apply buffered previewData on any transition out of edit-prompt so - // updates are not lost when the orchestrator transitions through - // intermediate states before reaching preview. - if ( - prevStep === "edit-prompt" && - nextStep !== "edit-prompt" && - bufferedPreviewData.current - ) { - const buffered = bufferedPreviewData.current; - bufferedPreviewData.current = null; - setState((prev) => ({ ...prev, previewData: buffered })); + // Apply buffered previewData when any ticket leaves edit-prompt (e.g. Esc). + useEffect(() => { + for (const ticket of workspaces.tickets) { + const prevStep = prevStepByTicket.current.get(ticket.id); + const nextStep = ticket.wizard.step; + + if (prevStep === "edit-prompt" && nextStep !== "edit-prompt") { + const buffered = previewBuffer.get(ticket.id); + if (buffered) { + previewBuffer.delete(ticket.id); + if ( + ticket.wizard.previewData?.summary !== buffered.summary || + ticket.wizard.previewData?.description !== buffered.description + ) { + dispatch({ + type: "wizard-patched", + id: ticket.id, + patch: { previewData: buffered }, + }); + } + } + } + + prevStepByTicket.current.set(ticket.id, nextStep); } + }, [dispatch, workspaces.tickets]); - prevStepRef.current = nextStep; - }, [state.step]); - - // If a skipped step is ever set (stale state / future callers), redirect - // to a reachable step so renderStep never shows an empty body. - React.useEffect(() => { - if (state.step === "epic" && !hasEpicStep) { - setState((prev) => ({ - ...prev, - step: hasIssueTypeStep ? "issue-type" : "style", - })); + // Redirect skipped steps + useEffect(() => { + if (!activeTicket || !activeWizard) return; + if (activeWizard.step === "epic" && !hasEpicStep) { + dispatch({ + type: "wizard-patched", + id: activeTicket.id, + patch: { step: hasIssueTypeStep ? "issue-type" : "style" }, + }); return; } - if (state.step === "issue-type" && !hasIssueTypeStep) { - setState((prev) => ({ ...prev, step: "style" })); + if (activeWizard.step === "issue-type" && !hasIssueTypeStep) { + dispatch({ + type: "wizard-patched", + id: activeTicket.id, + patch: { step: "style" }, + }); } - // hasEpicStep / hasIssueTypeStep are fixed for the form lifetime (closure - // constants from options), so they are not valid React dependencies. - }, [state.step]); + }, [activeTicket, activeWizard, dispatch]); - // Keep imperative refs in sync with state for external readers + // Elapsed timer for generating steps on the active ticket useEffect(() => { - stateRef.current = state; - currentStep = state.step; - visiblePreviewDataRef = state.previewData ?? null; - currentHarnessNameRef = state.harnessName; - }); - - useEffect(() => { - if (state.step === "generating" || state.step === "regenerating") { + if (activeStep === "generating" || activeStep === "regenerating") { generatingStartedAt.current = Date.now(); setElapsedSeconds(0); const interval = setInterval(() => { @@ -370,143 +468,224 @@ export async function runInteractiveMode( return () => clearInterval(interval); } generatingStartedAt.current = null; - }, [state.step]); - - // Expose setState to parent - React.useEffect(() => { - updateState = (updates) => { - setState((prev) => { - // Buffer previewData updates when user is actively editing so - // the description preview is not rewritten mid-typing. When the - // same update also moves the step away from edit-prompt, apply it - // atomically instead β€” buffering it just to re-apply one render - // later would flash the stale preview for a frame. - const staysInEditPrompt = (updates.step ?? prev.step) === "edit-prompt"; - if (updates.previewData && prev.step === "edit-prompt" && staysInEditPrompt) { - bufferedPreviewData.current = updates.previewData; - const { previewData: _, ...rest } = updates; - return { ...prev, ...rest }; - } - return { ...prev, ...updates }; - }); - }; - }, []); + }, [activeStep, activeTicket?.id]); - // Fetch issue types when project changes - React.useEffect(() => { - /** - * Loads issue types for the selected project from cache or the backend fetcher. - */ - const fetchTypesForProject = async () => { - if (!state.projectKey) { - return; - } + // Fetch issue types when active ticket project changes + useEffect(() => { + const projectKey = activeWizard?.projectKey; + if (!projectKey || !activeTicket) return; - // Check if we have cached issue types for this project - const cached = issueTypesCache.current.get(state.projectKey); + const fetchTypesForProject = async () => { + const cached = issueTypesCache.current.get(projectKey); if (cached && cached.length > 0) { setIssueTypes(cached); - // Reset issue type to the best default if current is not available - if (!cached.includes(state.issueType)) { - setState((prev) => ({ - ...prev, - issueType: getDefaultIssueType(cached), - })); + if (!cached.includes(activeWizard.issueType)) { + dispatch({ + type: "wizard-patched", + id: activeTicket.id, + patch: { issueType: getDefaultIssueType(cached) }, + }); } return; } - // No cache hit - fetch from API if fetcher is available - if (!options?.fetchIssueTypes) { - return; - } + if (!options?.fetchIssueTypes) return; setIsLoadingIssueTypes(true); try { - const types = await options.fetchIssueTypes(state.projectKey); + const types = await options.fetchIssueTypes(projectKey); if (types.length > 0) { - // Cache the fetched types - issueTypesCache.current.set(state.projectKey, types); + issueTypesCache.current.set(projectKey, types); setIssueTypes(types); - // Reset issue type to the best default if current is not available - if (!types.includes(state.issueType)) { - setState((prev) => ({ - ...prev, - issueType: getDefaultIssueType(types), - })); + if (!types.includes(activeWizard.issueType)) { + dispatch({ + type: "wizard-patched", + id: activeTicket.id, + patch: { issueType: getDefaultIssueType(types) }, + }); } } } catch { - // Silently fall back to default issue types on error setIssueTypes(defaultIssueTypes); } finally { setIsLoadingIssueTypes(false); } }; - fetchTypesForProject(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.projectKey, state.issueType]); + void fetchTypesForProject(); + }, [activeTicket, activeWizard, dispatch]); - useInput( - (inputChar, key) => { - if (key.ctrl && inputChar === "c") { - exit(); + const openTicket = useCallback((): string => { + const id = nextTicketId(); + dispatch({ + type: "ticket-opened", + id, + wizard: makeFreshWizard(), + }); + setCloseConfirmId(null); + setInputVersion((v) => v + 1); + return id; + }, [dispatch]); + + const closeTicket = useCallback( + (id: string) => { + dispatch({ type: "ticket-closed", id }); + setCloseConfirmId(null); + setInputVersion((v) => v + 1); + }, + [dispatch], + ); + + const requestCloseTicket = useCallback( + (id: string) => { + const ticket = getTicket(workspaces, id); + if (!ticket) return; + if (isTicketBusy(ticket.wizard.step)) { + setCloseConfirmId(id); return; } + closeTicket(id); + }, + [workspaces, closeTicket], + ); - // Success screen - any key signals the orchestrator to start another task. - // State reset is owned by handle.restart() so we never flash the wizard - // before the create-another loop is ready to waitForCompletion again. - if (state.step === "success") { - if (restartPromiseResolve) { - const resolveRestart = restartPromiseResolve; - restartPromiseResolve = null; - restartPromiseReject = null; - resolveRestart(); - } + const activateTicket = useCallback( + (id: string) => { + dispatch({ type: "ticket-activated", id }); + setCloseConfirmId(null); + setInputVersion((v) => v + 1); + }, + [dispatch], + ); + + // Expose open/close/activate to the imperative handle + useEffect(() => { + openTicketFn = openTicket; + closeTicketFn = closeTicket; + activateTicketFn = activateTicket; + }, [openTicket, closeTicket, activateTicket]); + + const switchTicketByOffset = useCallback( + (offset: number) => { + if (workspaces.tickets.length === 0) return; + const currentIndex = workspaces.tickets.findIndex( + (t) => t.id === workspaces.activeTicketId, + ); + const base = currentIndex < 0 ? 0 : currentIndex; + const next = (base + offset + workspaces.tickets.length) % workspaces.tickets.length; + const nextTicket = workspaces.tickets[next]; + if (nextTicket) activateTicket(nextTicket.id); + }, + [workspaces, activateTicket], + ); + + const patchActive = useCallback( + (patch: Partial) => { + if (!activeTicket) return; + dispatch({ type: "wizard-patched", id: activeTicket.id, patch }); + }, + [activeTicket, dispatch], + ); + + // Global multi-ticket shortcuts (always active, including during text entry) + useInput((inputChar, key) => { + if (key.ctrl && inputChar === "c") { + exit(); + return; + } + + // Close confirmation dialog + if (closeConfirmId) { + if (inputChar.toLowerCase() === "y" || key.return) { + closeTicket(closeConfirmId); return; } + if (inputChar.toLowerCase() === "n" || key.escape) { + setCloseConfirmId(null); + return; + } + return; + } - // Ctrl+P to navigate to project selection (only if projects are available) + // Ctrl+N β€” open new ticket + if (key.ctrl && inputChar === "n") { + openTicket(); + return; + } + + // Ctrl+W β€” close active ticket + if (key.ctrl && inputChar === "w") { + if (activeTicket) { + requestCloseTicket(activeTicket.id); + } + return; + } + + // Ctrl+↑ / Ctrl+↓ β€” switch tickets + if (key.ctrl && key.upArrow) { + switchTicketByOffset(-1); + return; + } + if (key.ctrl && key.downArrow) { + switchTicketByOffset(1); + return; + } + + // Ctrl+1..9 β€” select ticket by index + if (key.ctrl && inputChar >= "1" && inputChar <= "9") { + const index = parseInt(inputChar, 10) - 1; + const ticket = workspaces.tickets[index]; + if (ticket) activateTicket(ticket.id); + return; + } + + // Empty state: n opens first ticket (when no text entry captures it) + if (!activeTicket && (inputChar === "n" || inputChar === "N") && !key.ctrl) { + openTicket(); + } + }); + + // Wizard key handling for the active ticket (non text-entry steps) + useInput( + (inputChar, key) => { + if (closeConfirmId || !activeTicket || !activeWizard) return; + + // Success screen - any key signals restart for this ticket + if (activeWizard.step === "success") { + emitAction({ type: "restart", ticketId: activeTicket.id }); + return; + } + + // Ctrl+P to navigate to project selection (success already returned above) if (key.ctrl && inputChar === "p" && projects.length > 0) { - if ( - state.step !== "generating" && - state.step !== "regenerating" && - state.step !== "done" - ) { - setState((prev) => ({ ...prev, step: "project" })); - resetInput(); + if (!isTicketBusy(activeWizard.step)) { + patchActive({ step: "project", draftInput: "" }); + setInputVersion((v) => v + 1); return; } } - // Ctrl+G to navigate to harness selection (only if harnesses are available). - // Ctrl+H was the original binding, but it collides with backspace (\b / 0x08) - // in many terminal emulators and line disciplines, so the keypress was - // swallowed before reaching Ink. Ctrl+G does not collide with common - // terminal control characters. if (key.ctrl && inputChar === "g" && hasHarnessStep) { if ( - state.step !== "harness" && - state.step !== "generating" && - state.step !== "regenerating" && - state.step !== "done" + activeWizard.step !== "harness" && + activeWizard.step !== "generating" && + activeWizard.step !== "regenerating" && + activeWizard.step !== "done" ) { - stepBeforeHarness = state.step; - setState((prev) => ({ ...prev, step: "harness" })); - resetInput(); + stepBeforeHarness.current.set(activeTicket.id, activeWizard.step); + patchActive({ step: "harness", draftInput: "" }); + setInputVersion((v) => v + 1); return; } } - // Handle scrolling in preview mode - if (state.step === "preview") { - if (key.upArrow) { + // Scrolling in preview / edit-prompt + if (activeWizard.step === "preview" || activeWizard.step === "edit-prompt") { + if (key.upArrow && !key.ctrl) { scrollViewRef.current?.scrollBy(-1); return; } - if (key.downArrow) { + if (key.downArrow && !key.ctrl) { const ref = scrollViewRef.current; if (ref) { const currentOffset = ref.getScrollOffset(); @@ -550,116 +729,109 @@ export async function runInteractiveMode( } if (!key.ctrl && !key.meta && inputChar) { - if (state.step === "source-type" && ["1", "2", "3"].includes(inputChar)) { + if (activeWizard.step === "source-type" && ["1", "2", "3"].includes(inputChar)) { const sourceType = inputChar === "1" ? "figma" : inputChar === "2" ? "log" : "prompt"; - setState((prev) => ({ ...prev, sourceType, step: "source-input" })); - resetInput(); + patchActive({ sourceType, step: "source-input", draftInput: "" }); + setInputVersion((v) => v + 1); return; } - if (state.step === "issue-type") { + if (activeWizard.step === "issue-type") { const index = parseInt(inputChar) - 1; if (index >= 0 && index < orderedIssueTypes.length) { const issueType = orderedIssueTypes[index]; if (issueType) { - setState((prev) => ({ ...prev, issueType, step: "style" })); - resetInput(); + patchActive({ issueType, step: "style", draftInput: "" }); + setInputVersion((v) => v + 1); return; } } } - if (state.step === "harness") { + if (activeWizard.step === "harness") { const index = parseInt(inputChar) - 1; - if (index >= 0 && index < orderedHarnesses.length) { - const harness = orderedHarnesses[index]; - if (harness) { - const target = stepBeforeHarness ?? "style"; - stepBeforeHarness = null; - setState((prev) => ({ ...prev, harnessName: harness.name, step: target })); - resetInput(); - return; - } + const harness = orderedHarnesses[index]; + if (harness) { + const target = stepBeforeHarness.current.get(activeTicket.id) ?? "style"; + stepBeforeHarness.current.delete(activeTicket.id); + patchActive({ harnessName: harness.name, step: target, draftInput: "" }); + setInputVersion((v) => v + 1); + return; } } - if (state.step === "style" && ["1", "2"].includes(inputChar)) { + if (activeWizard.step === "style" && ["1", "2"].includes(inputChar)) { const promptStyle = inputChar === "1" ? "pm" : "technical"; - setState((prev) => ({ - ...prev, + patchActive({ promptStyle, decompose: false, step: "confirm", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); return; } - if (state.step === "confirm" && ["y", "n"].includes(inputChar.toLowerCase())) { + if (activeWizard.step === "confirm" && ["y", "n"].includes(inputChar.toLowerCase())) { if (inputChar.toLowerCase() === "y") { - setState((prev) => ({ ...prev, step: "generating" })); - if (completePromiseResolve) { - completed = true; - completePromiseResolve(stateRef.current); - } + const config = { ...activeWizard, step: "generating" as const }; + patchActive({ step: "generating" }); + completed = true; + emitAction({ type: "generate", ticketId: activeTicket.id, config }); } else { - setState((prev) => ({ ...prev, step: "source-type" })); - resetInput(); + patchActive({ step: "source-type", draftInput: "" }); + setInputVersion((v) => v + 1); } return; } - if (state.step === "preview") { - // Ignore action keys until preview content is available (avoids blank/stuck states). - if (!state.previewData) { - return; - } + if (activeWizard.step === "preview") { + if (!activeWizard.previewData) return; if (inputChar.toLowerCase() === "e") { - setState((prev) => ({ ...prev, step: "edit-prompt" })); - resetInput(); + patchActive({ step: "edit-prompt", draftInput: "" }); + setInputVersion((v) => v + 1); return; } if (["y", "n"].includes(inputChar.toLowerCase())) { if (inputChar.toLowerCase() === "y") { - setState((prev) => ({ ...prev, step: "done" })); - if (completePromiseResolve) { - completed = true; - completePromiseResolve(stateRef.current); - } + const config = { ...activeWizard, step: "done" as const }; + patchActive({ step: "done" }); + completed = true; + emitAction({ type: "create", ticketId: activeTicket.id, config }); } else { - setState((prev) => ({ - ...prev, + patchActive({ step: "source-type", previewData: undefined, - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); } - return; } } } }, - { isActive: !TEXT_ENTRY_STEPS.has(state.step) }, + { + isActive: + !closeConfirmId && + Boolean(activeTicket) && + activeWizard !== undefined && + !TEXT_ENTRY_STEPS.has(activeWizard.step), + }, ); - /** - * Handles Enter submission from ink-text-input on text-entry wizard steps. - * - * @param submittedValue - Raw input value from the prompt field. - */ const handleTextSubmit = (submittedValue: string) => { + if (!activeTicket || !activeWizard) return; const trimmedInput = submittedValue.trim(); - setInput(submittedValue); - switch (state.step) { + switch (activeWizard.step) { case "project": { if (trimmedInput === "" && defaultProjectKey) { - setState((prev) => ({ - ...prev, + patchActive({ projectKey: defaultProjectKey, step: "source-type", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); break; } @@ -667,12 +839,12 @@ export async function runInteractiveMode( if (index >= 0 && index < projects.length) { const project = projects[index]; if (project) { - setState((prev) => ({ - ...prev, + patchActive({ projectKey: project.key, step: "source-type", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); } } break; @@ -680,49 +852,50 @@ export async function runInteractiveMode( case "source-input": if (trimmedInput) { - setState((prev) => ({ - ...prev, + patchActive({ sourceContent: trimmedInput, step: "custom", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); } break; case "custom": - setState((prev) => ({ - ...prev, + patchActive({ customInstructions: trimmedInput || undefined, - step: stepAfterCustom, - })); - resetInput(); + step: stepAfterCustom as WizardStep, + draftInput: "", + }); + setInputVersion((v) => v + 1); break; case "epic": - setState((prev) => ({ - ...prev, + patchActive({ epicKey: trimmedInput || undefined, step: hasIssueTypeStep ? "issue-type" : "style", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); break; case "edit-prompt": { - const currentPreview = visiblePreviewDataRef; - if (trimmedInput) { - setState((prev) => ({ - ...prev, + const currentPreview = activeWizard.previewData; + if (trimmedInput && currentPreview) { + patchActive({ editPrompt: trimmedInput, step: "regenerating", - })); - if (editPromiseResolve && currentPreview) { - editPromiseResolve({ - editPrompt: trimmedInput, - currentSummary: currentPreview.summary, - currentDescription: currentPreview.description, - }); - } - resetInput(); + draftInput: "", + }); + completed = true; + emitAction({ + type: "edit", + ticketId: activeTicket.id, + editPrompt: trimmedInput, + currentSummary: currentPreview.summary, + currentDescription: currentPreview.description, + }); + setInputVersion((v) => v + 1); } break; } @@ -731,110 +904,88 @@ export async function runInteractiveMode( const navFlags: StepNavFlags = { hasEpicStep, hasIssueTypeStep }; - /** - * Seed value for the text input when navigating back to a text-entry step. - * Keeps prior answers editable instead of clearing the field. - */ const inputSeedForStep = (step: WizardStep): string => { + if (!activeWizard) return ""; switch (step) { case "source-input": - return state.sourceContent || ""; + return activeWizard.sourceContent || ""; case "custom": - return state.customInstructions || ""; + return activeWizard.customInstructions || ""; case "epic": - return state.epicKey || ""; + return activeWizard.epicKey || ""; default: return ""; } }; - /** - * Navigates to the previous wizard step when the user presses Escape. - * Uses the shared back-edge map so skipped epic/issue-type steps are never entered. - * Does not clear previewData (including when leaving edit-prompt). - */ const handleEscape = () => { - if (state.step === "harness") { - const target = stepBeforeHarness ?? "style"; - stepBeforeHarness = null; - resetInput(inputSeedForStep(target)); - setState((prev) => ({ ...prev, step: target })); + if (!activeTicket || !activeWizard) return; + if (activeWizard.step === "harness") { + const target = stepBeforeHarness.current.get(activeTicket.id) ?? "style"; + stepBeforeHarness.current.delete(activeTicket.id); + patchActive({ step: target, draftInput: inputSeedForStep(target) }); + setInputVersion((v) => v + 1); return; } - const previous = getPreviousStep(state.step, navFlags); - if (previous === null) { - return; - } - resetInput(inputSeedForStep(previous)); - setState((prev) => ({ ...prev, step: previous })); + const previous = getPreviousStep(activeWizard.step, navFlags); + if (previous === null) return; + const seed = inputSeedForStep(previous); + patchActive({ step: previous, draftInput: seed }); + setInputVersion((v) => v + 1); }; - /** - * Handles Enter on selection / yes-no steps (non text-input steps). - * Agent and terminal steps ignore Enter so accidental keypresses never blank the UI. - */ const handleEnter = () => { - // Do not act on Enter while the agent is running or the wizard is finished. + if (!activeTicket || !activeWizard) return; if ( - state.step === "generating" || - state.step === "regenerating" || - state.step === "done" || - state.step === "success" + activeWizard.step === "generating" || + activeWizard.step === "regenerating" || + activeWizard.step === "done" || + activeWizard.step === "success" ) { return; } - const trimmedInput = input.trim(); + const trimmedInput = (activeWizard.draftInput || "").trim(); - switch (state.step) { + switch (activeWizard.step) { + case "harness": { + const target = stepBeforeHarness.current.get(activeTicket.id) ?? "style"; + if (trimmedInput === "") { + stepBeforeHarness.current.delete(activeTicket.id); + patchActive({ step: target, draftInput: "" }); + setInputVersion((v) => v + 1); + break; + } + const harness = orderedHarnesses[parseInt(trimmedInput) - 1]; + if (harness) { + stepBeforeHarness.current.delete(activeTicket.id); + patchActive({ harnessName: harness.name, step: target, draftInput: "" }); + setInputVersion((v) => v + 1); + } + break; + } case "source-type": if (["1", "2", "3"].includes(trimmedInput)) { const sourceType = trimmedInput === "1" ? "figma" : trimmedInput === "2" ? "log" : "prompt"; - setState((prev) => ({ - ...prev, - sourceType, - step: "source-input", - })); - resetInput(); + patchActive({ sourceType, step: "source-input", draftInput: "" }); + setInputVersion((v) => v + 1); } break; case "issue-type": { - if (!hasIssueTypeStep) { - break; - } + if (!hasIssueTypeStep) break; if (trimmedInput === "") { - setState((prev) => ({ ...prev, step: "style" })); - resetInput(); + patchActive({ step: "style", draftInput: "" }); + setInputVersion((v) => v + 1); break; } const index = parseInt(trimmedInput) - 1; if (index >= 0 && index < orderedIssueTypes.length) { const issueType = orderedIssueTypes[index]; if (issueType) { - setState((prev) => ({ ...prev, issueType, step: "style" })); - resetInput(); - } - } - break; - } - - case "harness": { - const target = stepBeforeHarness ?? "style"; - if (trimmedInput === "") { - stepBeforeHarness = null; - setState((prev) => ({ ...prev, step: target })); - resetInput(); - break; - } - const harnessIndex = parseInt(trimmedInput) - 1; - if (harnessIndex >= 0 && harnessIndex < orderedHarnesses.length) { - const harness = orderedHarnesses[harnessIndex]; - if (harness) { - stepBeforeHarness = null; - setState((prev) => ({ ...prev, harnessName: harness.name, step: target })); - resetInput(); + patchActive({ issueType, step: "style", draftInput: "" }); + setInputVersion((v) => v + 1); } } break; @@ -843,51 +994,45 @@ export async function runInteractiveMode( case "style": if (["1", "2"].includes(trimmedInput)) { const promptStyle = trimmedInput === "1" ? "pm" : "technical"; - setState((prev) => ({ - ...prev, + patchActive({ promptStyle, decompose: false, step: "confirm", - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); } break; case "confirm": if (["y", "n", ""].includes(trimmedInput.toLowerCase())) { if (trimmedInput.toLowerCase() === "y" || trimmedInput === "") { - setState((prev) => ({ ...prev, step: "generating" })); - if (completePromiseResolve) { - completed = true; - completePromiseResolve(stateRef.current); - } + const config = { ...activeWizard, step: "generating" as const }; + patchActive({ step: "generating" }); + completed = true; + emitAction({ type: "generate", ticketId: activeTicket.id, config }); } else { - setState((prev) => ({ ...prev, step: "source-type" })); - resetInput(); + patchActive({ step: "source-type", draftInput: "" }); + setInputVersion((v) => v + 1); } } break; case "preview": - // Enter alone accepts the draft (same as Y). Only act when preview data exists - // so we never resolve completion while still showing "Waiting for task preview...". - if (!state.previewData) { - break; - } + if (!activeWizard.previewData) break; if (["y", "n", ""].includes(trimmedInput.toLowerCase())) { if (trimmedInput.toLowerCase() === "y" || trimmedInput === "") { - setState((prev) => ({ ...prev, step: "done" })); - if (completePromiseResolve) { - completed = true; - completePromiseResolve(stateRef.current); - } + const config = { ...activeWizard, step: "done" as const }; + patchActive({ step: "done" }); + completed = true; + emitAction({ type: "create", ticketId: activeTicket.id, config }); } else { - setState((prev) => ({ - ...prev, + patchActive({ step: "source-type", previewData: undefined, - })); - resetInput(); + draftInput: "", + }); + setInputVersion((v) => v + 1); } } break; @@ -899,14 +1044,25 @@ export async function runInteractiveMode( onExit: exit, }; - /** - * Renders the UI for the current wizard step. - * Always returns a non-null layout so the body under the chrome is never blank. - * - * @returns Step-specific Ink layout (including skip/recovery placeholders). - */ const renderStep = () => { + if (!activeTicket || !activeWizard) return null; + const state = activeWizard; + const input = state.draftInput || ""; + switch (state.step) { + case "harness": + return ( + + Select agent: + {orderedHarnesses.map((harness, index) => ( + + {index + 1}. {harness.displayName} + {harness.name === state.harnessName ? " (current)" : ""} + + ))} + Enter keeps the current agent; Esc returns. + + ); case "project": return ( @@ -923,7 +1079,7 @@ export async function runInteractiveMode( )} {label} Custom instructions (optional, press Enter to skip): Additional requirements or focus areas - {'Example: "Focus on accessibility" or "Prioritize performance"'} + Example: "Focus on accessibility" or "Prioritize performance" @@ -993,7 +1148,7 @@ export async function runInteractiveMode( Epic key (optional, press Enter to skip): Example: PROJ-123 ); - case "issue-type": + case "issue-type": { if (!hasIssueTypeStep) { return ( @@ -1014,6 +1169,7 @@ export async function runInteractiveMode( Select issue type (Enter to accept default): + {isLoadingIssueTypes ? Loading… : null} {orderedIssueTypes.map((type, index) => ( @@ -1023,6 +1179,7 @@ export async function runInteractiveMode( ))} ); + } case "style": return ( @@ -1033,22 +1190,6 @@ export async function runInteractiveMode( ); - case "harness": { - return ( - - - Select AI agent harness (Enter to accept current): - - {orderedHarnesses.map((harness, index) => ( - - {index + 1}. {harness.displayName} - {harness.name === state.harnessName ? " (current)" : ""} - - ))} - - ); - } - case "confirm": { const sourceLabel = state.sourceType === "figma" @@ -1056,7 +1197,6 @@ export async function runInteractiveMode( : state.sourceType === "log" ? "Error Log" : "Requirements"; - const selectedHarness = allHarnesses.find((h) => h.name === state.harnessName); return ( @@ -1105,11 +1245,13 @@ export async function runInteractiveMode( Prompt Style: {state.promptStyle} - - {(selectedHarness || state.harnessName) && ( + {state.harnessName && ( Agent: - {selectedHarness?.displayName || state.harnessName} + + {allHarnesses.find((h) => h.name === state.harnessName)?.displayName || + state.harnessName} + )} @@ -1127,7 +1269,9 @@ export async function runInteractiveMode( {state.statusMessage ?? "Running AI agent β€” this may take a few minutes"} - Elapsed: {elapsedSeconds}s β€’ Ctrl+C to cancel + + Elapsed: {elapsedSeconds}s β€’ Switch tickets anytime (Ctrl+↑/↓) β€’ Ctrl+C to cancel + ); @@ -1160,7 +1304,7 @@ export async function runInteractiveMode( paddingX={1} paddingY={1} flexDirection="column" - height={25} + height={Math.min(25, Math.max(10, (stdout?.rows ?? 40) - 18))} > {state.previewData.description} @@ -1209,10 +1353,11 @@ export async function runInteractiveMode( What would you like to change? - {'Example: "Add more details about error handling" or "Make it more concise"'} + Example: "Add more details about error handling" or "Make it more + concise" {state.statusMessage ?? "Running AI agent β€” this may take a few minutes"} - Elapsed: {elapsedSeconds}s β€’ Ctrl+C to cancel + + Elapsed: {elapsedSeconds}s β€’ Switch tickets anytime (Ctrl+↑/↓) β€’ Ctrl+C to cancel + ); @@ -1282,13 +1429,14 @@ export async function runInteractiveMode( - Press any key to create another task... + + Press any key to reset this ticket…{sym.sep}Ctrl+N: open another ticket + ); default: - // Never render null for a reachable step β€” recovery path if step graph drifts. return ( @@ -1300,59 +1448,119 @@ export async function runInteractiveMode( } }; - // Get current project display as "Tracker/Project" or just project key/name const currentProjectDisplay = (() => { - const project = projects.find((p) => p.key === state.projectKey)?.name || state.projectKey; + if (!activeWizard) return "N/A"; + const project = + projects.find((p) => p.key === activeWizard.projectKey)?.name || activeWizard.projectKey; if (!project) return "N/A"; return options?.backendName ? `${options.backendName}/${project}` : project; })(); - // Get current harness display name from state. Only show the - // displayName when the selected harness is still in the registry; if it - // was removed mid-run, fall back to the startup display name - // (options?.harnessDisplayName || "None") and never leaks the raw - // harnessName string. - const currentHarnessDisplay = (() => { - const harness = allHarnesses.find((h) => h.name === state.harnessName); - return harness?.displayName || options?.harnessDisplayName || "None"; - })(); + const showSidebar = terminalWidth >= 60; + const titleWidth = terminalWidth < 80 ? 12 : 18; - return ( - + const mainColumn = ( + πŸ“‹ @devintern/pm - Interactive Mode - - Project: - {currentProjectDisplay} - {projects.length > 0 && {sym.sep}Ctrl+P: Change Project} - - - Agent: - {currentHarnessDisplay} - {hasHarnessStep && {sym.sep}Ctrl+G: Change Agent} + {activeTicket ? ( + <> + + Project: + {currentProjectDisplay} + {projects.length > 0 && {sym.sep}Ctrl+P: Change Project} + + + Agent: + + {allHarnesses.find((h) => h.name === activeWizard?.harnessName)?.displayName || + options?.harnessDisplayName || + "None"} + + {hasHarnessStep && {sym.sep}Ctrl+G: Change Agent} + + {sym.sep} + {workspaces.tickets.length} ticket + {workspaces.tickets.length === 1 ? "" : "s"} open + + + + {activeWizard && canNavigateBack(activeWizard.step, navFlags) + ? `ESC: Back${sym.sep}` + : ""} + {activeWizard?.step === "success" + ? `Any key: Reset ticket${sym.sep}Ctrl+N: New${sym.sep}Ctrl+C: Exit` + : activeWizard?.step === "preview" + ? `Y: Create${sym.sep}N: Discard${sym.sep}E: Edit${sym.sep}Ctrl+C: Exit` + : activeWizard && isTicketBusy(activeWizard.step) + ? `Ctrl+↑/↓: Switch${sym.sep}Ctrl+C: Cancel` + : "Ctrl+N: New ticket β€’ Ctrl+W: Close β€’ Ctrl+C: Exit"} + + + ) : ( + Ctrl+N: Open a ticket β€’ Ctrl+C: Exit + )} + + + {closeConfirmId ? ( + + + Close ticket with work in progress? + + + An agent or tracker operation is still running on this ticket. Closing removes it + from the sidebar; in-flight work will no longer be shown here (it is not cancelled + on the agent side). + + Close? (Y/n) + ) : activeTicket ? ( + renderStep() + ) : ( + + )} + + ); + + // Compact ticket strip for narrow terminals + const compactStrip = + !showSidebar && workspaces.tickets.length > 0 ? ( + - {canNavigateBack(state.step, navFlags) || state.step === "harness" - ? `ESC: Back${sym.sep}` - : ""} - {state.step === "success" - ? `Any key: New task${sym.sep}Ctrl+C: Exit` - : state.step === "preview" - ? `Y: Create${sym.sep}N: Discard${sym.sep}E: Edit${sym.sep}Ctrl+C: Exit` - : state.step === "generating" || state.step === "regenerating" - ? "Ctrl+C: Cancel" - : "Ctrl+C: Exit"} + Tickets:{" "} + {workspaces.tickets + .map((t, i) => { + const active = t.id === workspaces.activeTicketId; + const label = `${i + 1}${active ? "*" : ""}`; + return label; + }) + .join(" ")}{" "} + Β· Ctrl+N new Β· Ctrl+↑/↓ switch Β· Ctrl+W close - {renderStep()} + ) : null; + + return ( + + {compactStrip} + + {showSidebar ? ( + + ) : null} + {mainColumn} + ); }; const { waitUntilExit, unmount } = render( - , + , options?.stdin !== undefined ? { stdin: options.stdin } : undefined, ); @@ -1363,18 +1571,31 @@ export async function runInteractiveMode( } }); - /** - * Waits until the user confirms configuration or accepts a generated preview. - * - * @returns Resolved interactive state when the user proceeds to generation or creation. - */ + const waitForAction = (): Promise => { + if (cancelled) { + return Promise.reject(cancelError()); + } + if (actionQueue.length > 0) { + return Promise.resolve(actionQueue.shift()!); + } + return new Promise((resolveAction, rejectAction) => { + if (cancelled) { + rejectAction(cancelError()); + return; + } + actionWaiter = (action) => { + actionWaiter = null; + resolveAction(action); + }; + }); + }; + const waitForCompletion = (): Promise => { if (cancelled) { return Promise.reject(cancelError()); } return new Promise((resolveComplete, rejectComplete) => { completePromiseResolve = (config) => { - // Don't unmount - keep UI running for preview / create-another cycles completePromiseResolve = null; completePromiseReject = null; resolveComplete(config); @@ -1383,145 +1604,205 @@ export async function runInteractiveMode( }); }; - /** Switches the wizard to the generating step while the agent runs. */ - const setGenerating = () => { - if (updateState) { - updateState({ - step: "generating", - statusMessage: "Starting AI agent...", - }); + /** + * Apply wizard updates to a ticket. When the ticket is on edit-prompt and + * the update would stay there, buffer previewData so mid-edit rewrites do + * not clobber the visible description (applied on leave via effect). + */ + const applyTicketUpdate = ( + ticketId: string, + updates: Partial, + opts?: { bufferPreviewIfEditing?: boolean }, + ) => { + const ticket = getTicket(workspacesRef, ticketId); + if (!ticket) return; + + let patch = updates; + if ( + opts?.bufferPreviewIfEditing && + updates.previewData && + ticket.wizard.step === "edit-prompt" + ) { + const nextStep = updates.step ?? ticket.wizard.step; + if (nextStep === "edit-prompt") { + previewBuffer.set(ticketId, updates.previewData); + const { previewData: _preview, ...rest } = updates; + if (Object.keys(rest).length === 0) return; + patch = rest; + } else { + // Leaving edit-prompt with new preview β€” apply atomically, drop buffer. + previewBuffer.delete(ticketId); + } } - }; - /** Updates the status line shown on the generating/regenerating screen. */ - const setStatusMessage = (message: string) => { - if (updateState) { - updateState({ statusMessage: message }); + const action = { + type: "wizard-patched" as const, + id: ticketId, + patch, + }; + + if (!dispatchRef) { + // Pre-mount: update snapshot now; flush into React on first effect. + workspacesRef = ticketWorkspacesReducer(workspacesRef, action); + pendingUpdates.push({ ticketId, updates: patch }); + return; } + dispatchRef(action); }; - /** - * Populates the preview pane with generated task title and description. - * - * @param summary - Generated issue title. - * @param description - Generated issue body (markdown). - */ - const setPreviewData = (summary: string, description: string) => { - if (updateState) { - updateState({ previewData: { summary, description }, step: "preview" }); - } + const setGenerating = (ticketId?: string) => { + const id = resolveTicketId(ticketId); + if (!id) return; + applyTicketUpdate(id, { + step: "generating", + statusMessage: "Starting AI agent...", + }); }; - /** - * Updates preview data without changing the current step. - * Used by orchestrators to refresh preview content while the user - * is on the preview or edit-prompt screen. - * - * @param summary - Generated issue title. - * @param description - Generated issue body (markdown). - */ - const updatePreviewData = (summary: string, description: string) => { - if (updateState) { - updateState({ previewData: { summary, description } }); - } + const setStatusMessage = (message: string, ticketId?: string) => { + const id = resolveTicketId(ticketId); + if (!id) return; + applyTicketUpdate(id, { statusMessage: message }); }; - /** - * Waits until the user submits an edit prompt on the preview screen. - * - * @returns Edit prompt text plus the current preview title and description. - */ - const waitForEdit = (): Promise<{ - editPrompt: string; - currentSummary: string; - currentDescription: string; - }> => { - if (cancelled) { - return Promise.reject(cancelError()); - } - return new Promise((resolveEdit, rejectEdit) => { - editPromiseResolve = (data) => { - editPromiseResolve = null; - editPromiseReject = null; - resolveEdit(data); - }; - editPromiseReject = rejectEdit; - }); + const setPreviewData = (summary: string, description: string, ticketId?: string) => { + const id = resolveTicketId(ticketId); + if (!id) return; + applyTicketUpdate( + id, + { previewData: { summary, description }, step: "preview" }, + { bufferPreviewIfEditing: true }, + ); }; - /** - * Displays the success screen with a completion message. - * - * @param message - Success text shown after task creation. - */ - const showSuccess = (message: string) => { - if (updateState) { - updateState({ successMessage: message, step: "success", statusMessage: undefined }); - } + const updatePreviewData = (summary: string, description: string, ticketId?: string) => { + const id = resolveTicketId(ticketId); + if (!id) return; + applyTicketUpdate( + id, + { previewData: { summary, description } }, + { bufferPreviewIfEditing: true }, + ); }; - /** - * Waits until the user presses any key on the success screen to start another task. - * - * @returns Resolves when the user requests a new wizard run. - * @throws If the user cancels with Ctrl+C / the Ink app unmounts. - */ - const waitForRestart = (): Promise => { - if (cancelled) { - return Promise.reject(cancelError()); - } - return new Promise((resolveRestart, rejectRestart) => { - restartPromiseResolve = () => { - restartPromiseResolve = null; - restartPromiseReject = null; - resolveRestart(); - }; - restartPromiseReject = rejectRestart; + const showSuccess = ( + message: string, + successOptions?: { ticketId?: string; createdKey?: string }, + ) => { + const id = resolveTicketId(successOptions?.ticketId); + if (!id) return; + applyTicketUpdate(id, { + successMessage: message, + step: "success", + statusMessage: undefined, + createdKey: successOptions?.createdKey, }); }; - /** - * Resets wizard state to the first step without unmounting the Ink tree. - * Used after success/error so create-another reuses the same interactive session. - */ - const restart = () => { - if (updateState) { - updateState({ - step: "source-type", - projectKey: defaultProjectKey, - sourceType: undefined, - sourceContent: undefined, - customInstructions: undefined, - epicKey: undefined, - promptStyle: "pm", - issueType: getDefaultIssueType(defaultIssueTypes), - // Preserve the harness the user most recently selected mid-wizard. - harnessName: currentHarnessNameRef ?? currentHarnessName, - decompose: false, - tasks: [], - previewData: undefined, - successMessage: undefined, - statusMessage: undefined, - editPrompt: undefined, - }); - } + const restart = (ticketId?: string) => { + const id = resolveTicketId(ticketId); + if (!id) return; + const fresh = makeFreshWizard(); + previewBuffer.delete(id); + // Explicit undefined clears optional fields (wizard-patched uses `in` checks). + applyTicketUpdate(id, { + ...fresh, + projectKey: defaultProjectKey, + sourceType: undefined, + sourceContent: undefined, + customInstructions: undefined, + epicKey: undefined, + previewData: undefined, + editPrompt: undefined, + successMessage: undefined, + statusMessage: undefined, + createdKey: undefined, + }); }; resolve({ + waitForAction, + waitForCompletion, + waitForEdit: () => { + if (cancelled) { + return Promise.reject(cancelError()); + } + return new Promise((resolveEdit, rejectEdit) => { + editPromiseResolve = (data) => { + editPromiseResolve = null; + editPromiseReject = null; + resolveEdit(data); + }; + editPromiseReject = rejectEdit; + }); + }, + waitForRestart: () => { + if (cancelled) { + return Promise.reject(cancelError()); + } + return new Promise((resolveRestart, rejectRestart) => { + restartPromiseResolve = (data) => { + restartPromiseResolve = null; + restartPromiseReject = null; + resolveRestart(data); + }; + restartPromiseReject = rejectRestart; + }); + }, setGenerating, setStatusMessage, setPreviewData, updatePreviewData, - waitForCompletion, - waitForEdit, showSuccess, - waitForRestart, restart, - getStep: () => currentStep, - getPreviewData: () => visiblePreviewDataRef ?? undefined, - getHarnessName: () => currentHarnessNameRef, - /** Unmounts the Ink interactive form and releases terminal control. */ + // Published (post-commit) snapshot keeps getStep in lockstep with useInput. + getStep: () => getActiveTicket(publishedWorkspaces)?.wizard.step ?? "source-type", + getPreviewData: () => { + const active = getActiveTicket(publishedWorkspaces); + if (!active) return undefined; + const buffered = previewBuffer.get(active.id); + // Prefer live wizard data; buffer is only for mid-edit deferred updates + return active.wizard.previewData ?? buffered; + }, + getHarnessName: () => getActiveTicket(publishedWorkspaces)?.wizard.harnessName, + getActiveTicketId: () => publishedWorkspaces.activeTicketId, + getWorkspaces: () => workspacesRef, + openTicket: () => { + if (openTicketFn) return openTicketFn(); + // Pre-mount fallback + const id = nextTicketId(); + const action = { + type: "ticket-opened" as const, + id, + wizard: makeFreshWizard(), + }; + workspacesRef = ticketWorkspacesReducer(workspacesRef, action); + publishedWorkspaces = workspacesRef; + if (dispatchRef) dispatchRef(action); + return id; + }, + closeTicket: (id: string) => { + if (closeTicketFn) { + closeTicketFn(id); + return; + } + const action = { type: "ticket-closed" as const, id }; + workspacesRef = ticketWorkspacesReducer(workspacesRef, action); + publishedWorkspaces = workspacesRef; + if (dispatchRef) dispatchRef(action); + }, + activateTicket: (id: string) => { + if (activateTicketFn) { + activateTicketFn(id); + return; + } + const action = { type: "ticket-activated" as const, id }; + workspacesRef = ticketWorkspacesReducer(workspacesRef, action); + publishedWorkspaces = workspacesRef; + if (dispatchRef) dispatchRef(action); + }, cleanup: () => { + rejectPendingWaiters(); unmount(); }, }); diff --git a/packages/pm/lib/ticket-workspaces.test.ts b/packages/pm/lib/ticket-workspaces.test.ts new file mode 100644 index 0000000..074fd31 --- /dev/null +++ b/packages/pm/lib/ticket-workspaces.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, test, beforeEach } from "bun:test"; +import { + createInitialWizard, + createTicketWorkspace, + getActiveTicket, + isTicketBusy, + nextTicketId, + pickActiveAfterClose, + resetTicketIdCounter, + ticketAgentStatus, + ticketTitle, + ticketWorkspacesReducer, + initialTicketWorkspacesState, +} from "./ticket-workspaces.ts"; +import type { TicketWorkspacesState, TicketWizardState } from "./ticket-workspaces.ts"; + +function baseWizard(overrides: Partial = {}): TicketWizardState { + return { + ...createInitialWizard({ issueType: "Task", projectKey: "DEV" }), + ...overrides, + }; +} + +function openTwo(): TicketWorkspacesState { + let state = ticketWorkspacesReducer(initialTicketWorkspacesState, { + type: "session-started", + id: nextTicketId(), + wizard: baseWizard(), + }); + state = ticketWorkspacesReducer(state, { + type: "ticket-opened", + id: nextTicketId(), + wizard: baseWizard({ + sourceType: "prompt", + sourceContent: "Second ticket prompt", + }), + }); + return state; +} + +beforeEach(() => { + resetTicketIdCounter(); +}); + +describe("ticketWorkspacesReducer", () => { + test("session-started opens one active ticket workspace", () => { + const state = ticketWorkspacesReducer(initialTicketWorkspacesState, { + type: "session-started", + id: nextTicketId(), + wizard: baseWizard({ projectKey: "DEV" }), + }); + expect(state.tickets).toHaveLength(1); + expect(state.activeTicketId).toBe(state.tickets[0]!.id); + expect(state.tickets[0]!.wizard.projectKey).toBe("DEV"); + expect(state.tickets[0]!.wizard.step).toBe("source-type"); + }); + + test("ticket-opened adds a workspace and focuses it", () => { + let state = ticketWorkspacesReducer(initialTicketWorkspacesState, { + type: "session-started", + id: nextTicketId(), + wizard: baseWizard(), + }); + const firstId = state.activeTicketId!; + const secondId = nextTicketId(); + state = ticketWorkspacesReducer(state, { + type: "ticket-opened", + id: secondId, + wizard: baseWizard({ issueType: "Bug" }), + }); + expect(state.tickets).toHaveLength(2); + expect(state.activeTicketId).toBe(secondId); + expect(state.tickets.find((t) => t.id === firstId)).toBeDefined(); + expect(getActiveTicket(state)?.wizard.issueType).toBe("Bug"); + }); + + test("ticket-activated switches without mutating other workspaces", () => { + let state = openTwo(); + const [first, second] = state.tickets; + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: second!.id, + patch: { step: "generating", statusMessage: "Running…" }, + }); + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: first!.id, + patch: { customInstructions: "focus a11y", draftInput: "partial" }, + }); + state = ticketWorkspacesReducer(state, { type: "ticket-activated", id: first!.id }); + + expect(state.activeTicketId).toBe(first!.id); + expect(getActiveTicket(state)?.wizard.customInstructions).toBe("focus a11y"); + expect(getActiveTicket(state)?.wizard.draftInput).toBe("partial"); + const background = state.tickets.find((t) => t.id === second!.id)!; + expect(background.wizard.step).toBe("generating"); + expect(isTicketBusy(background.wizard.step)).toBe(true); + }); + + test("each ticket keeps independent wizard state", () => { + let state = openTwo(); + const a = state.tickets[0]!.id; + const b = state.tickets[1]!.id; + + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: a, + patch: { + sourceContent: "Ticket A only", + step: "preview", + previewData: { summary: "Auth redesign", description: "Body" }, + }, + }); + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: b, + patch: { step: "generating" }, + }); + + const ticketA = state.tickets.find((t) => t.id === a)!; + const ticketB = state.tickets.find((t) => t.id === b)!; + expect(ticketA.wizard.sourceContent).toBe("Ticket A only"); + expect(ticketA.wizard.step).toBe("preview"); + expect(ticketA.wizard.previewData?.summary).toBe("Auth redesign"); + expect(ticketB.wizard.step).toBe("generating"); + expect(ticketB.wizard.previewData).toBeUndefined(); + expect(ticketB.wizard.sourceContent).toBe("Second ticket prompt"); + }); + + test("background ticket completion does not contaminate the active ticket", () => { + let state = openTwo(); + const a = state.tickets[0]!.id; + const b = state.tickets[1]!.id; + + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: a, + patch: { step: "generating" }, + }); + state = ticketWorkspacesReducer(state, { type: "ticket-activated", id: b }); + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: a, + patch: { + step: "preview", + previewData: { summary: "Done A", description: "Body A" }, + }, + }); + + expect(state.activeTicketId).toBe(b); + expect(getActiveTicket(state)?.wizard.step).toBe("source-type"); + expect(state.tickets.find((t) => t.id === a)!.wizard.step).toBe("preview"); + expect(state.tickets.find((t) => t.id === a)!.wizard.previewData?.summary).toBe("Done A"); + }); + + test("closing a ticket leaves remaining tickets intact", () => { + let state = openTwo(); + const a = state.tickets[0]!.id; + const b = state.tickets[1]!.id; + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: a, + patch: { + step: "preview", + previewData: { summary: "Keep me", description: "Body" }, + }, + }); + + state = ticketWorkspacesReducer(state, { type: "ticket-closed", id: b }); + expect(state.tickets).toHaveLength(1); + expect(state.tickets[0]!.id).toBe(a); + expect(state.tickets[0]!.wizard.previewData?.summary).toBe("Keep me"); + expect(state.activeTicketId).toBe(a); + }); + + test("closing the last ticket yields an empty session", () => { + let state = ticketWorkspacesReducer(initialTicketWorkspacesState, { + type: "session-started", + id: nextTicketId(), + wizard: baseWizard(), + }); + const id = state.activeTicketId!; + state = ticketWorkspacesReducer(state, { type: "ticket-closed", id }); + expect(state.tickets).toHaveLength(0); + expect(state.activeTicketId).toBeNull(); + expect(getActiveTicket(state)).toBeNull(); + }); + + test("returning to a previously opened ticket restores wizard fields", () => { + let state = openTwo(); + const a = state.tickets[0]!.id; + const b = state.tickets[1]!.id; + + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: a, + patch: { + epicKey: "EPIC-1", + customInstructions: "keep this", + step: "preview", + previewData: { summary: "Auth redesign", description: "Body" }, + draftInput: "half typed", + }, + }); + state = ticketWorkspacesReducer(state, { type: "ticket-activated", id: b }); + state = ticketWorkspacesReducer(state, { + type: "wizard-patched", + id: b, + patch: { epicKey: "OTHER" }, + }); + state = ticketWorkspacesReducer(state, { type: "ticket-activated", id: a }); + + const resumed = getActiveTicket(state)!; + expect(resumed.id).toBe(a); + expect(resumed.wizard.epicKey).toBe("EPIC-1"); + expect(resumed.wizard.customInstructions).toBe("keep this"); + expect(resumed.wizard.step).toBe("preview"); + expect(resumed.wizard.previewData?.summary).toBe("Auth redesign"); + expect(resumed.wizard.draftInput).toBe("half typed"); + }); +}); + +describe("pickActiveAfterClose", () => { + test("prefers the ticket above when closing the active ticket", () => { + const tickets = [ + createTicketWorkspace("t1", baseWizard()), + createTicketWorkspace("t2", baseWizard()), + createTicketWorkspace("t3", baseWizard()), + ]; + expect(pickActiveAfterClose(tickets, "t2", "t2")).toBe("t1"); + expect(pickActiveAfterClose(tickets, "t1", "t1")).toBe("t2"); + expect(pickActiveAfterClose(tickets, "t3", "t3")).toBe("t2"); + }); + + test("keeps active when closing a different ticket", () => { + const tickets = [ + createTicketWorkspace("t1", baseWizard()), + createTicketWorkspace("t2", baseWizard()), + ]; + expect(pickActiveAfterClose(tickets, "t1", "t2")).toBe("t2"); + }); +}); + +describe("ticketTitle and ticketAgentStatus", () => { + test("title prefers created key, then draft summary, then source preview", () => { + let ticket = createTicketWorkspace("t1", baseWizard()); + expect(ticketTitle(ticket)).toBe("New ticket"); + + ticket = { + ...ticket, + wizard: baseWizard({ + sourceContent: "Build the sidebar\nmore lines", + }), + }; + expect(ticketTitle(ticket)).toBe("Build the sidebar"); + + ticket = { + ...ticket, + wizard: { + ...ticket.wizard, + previewData: { summary: "Auth redesign", description: "Body" }, + step: "preview", + }, + }; + expect(ticketTitle(ticket)).toBe("Auth redesign"); + + ticket = { + ...ticket, + wizard: { + ...ticket.wizard, + createdKey: "DEV-31", + step: "success", + }, + }; + expect(ticketTitle(ticket)).toBe("DEV-31"); + }); + + test("agent status maps steps for sidebar badges", () => { + expect(ticketAgentStatus("generating")).toBe("running"); + expect(ticketAgentStatus("regenerating")).toBe("running"); + expect(ticketAgentStatus("done")).toBe("running"); + expect(ticketAgentStatus("preview")).toBe("ready"); + expect(ticketAgentStatus("edit-prompt")).toBe("ready"); + expect(ticketAgentStatus("confirm")).toBe("ready"); + expect(ticketAgentStatus("success")).toBe("done"); + expect(ticketAgentStatus("success", true)).toBe("error"); + expect(ticketAgentStatus("source-type")).toBe("idle"); + expect(ticketAgentStatus("project")).toBe("idle"); + }); +}); diff --git a/packages/pm/lib/ticket-workspaces.ts b/packages/pm/lib/ticket-workspaces.ts new file mode 100644 index 0000000..5239abc --- /dev/null +++ b/packages/pm/lib/ticket-workspaces.ts @@ -0,0 +1,307 @@ +/** + * Multi-ticket workspace state for the pm TUI. + * + * Mirrors the desktop product rules (independent workspaces, open/close/switch, + * non-cancelling switch, status at a glance) while storing the TUI wizard + * InteractiveState per ticket. + */ + +export type WizardStep = + | "project" + | "source-type" + | "source-input" + | "custom" + | "epic" + | "style" + | "issue-type" + | "harness" + | "confirm" + | "generating" + | "preview" + | "edit-prompt" + | "regenerating" + | "done" + | "success"; + +export interface TicketWizardState { + step: WizardStep; + projectKey?: string; + sourceType?: "figma" | "log" | "prompt"; + sourceContent?: string; + customInstructions?: string; + epicKey?: string; + promptStyle: "pm" | "technical"; + issueType: string; + harnessName?: string; + decompose: boolean; + tasks: Array<{ + summary: string; + description: string; + type: "Story" | "Task" | "Bug" | "Epic"; + }>; + previewData?: { + summary: string; + description: string; + }; + editPrompt?: string; + successMessage?: string; + statusMessage?: string; + /** Tracker key after successful create (sidebar identity). */ + createdKey?: string; + /** + * Draft text input for the current text-entry step. Kept per ticket so + * switching workspaces does not lose in-progress typing. + */ + draftInput: string; +} + +export interface TicketWorkspace { + id: string; + wizard: TicketWizardState; +} + +export interface TicketWorkspacesState { + tickets: TicketWorkspace[]; + activeTicketId: string | null; +} + +export const initialTicketWorkspacesState: TicketWorkspacesState = { + tickets: [], + activeTicketId: null, +}; + +export type TicketWorkspacesAction = + | { type: "session-started"; id: string; wizard: TicketWizardState } + | { type: "ticket-opened"; id: string; wizard: TicketWizardState } + | { type: "ticket-activated"; id: string } + | { type: "ticket-closed"; id: string } + | { type: "wizard-patched"; id: string; patch: Partial }; + +let ticketCounter = 0; + +/** Generate a unique workspace id. Exported for tests that need stable control. */ +export function nextTicketId(): string { + return `ticket-${++ticketCounter}`; +} + +/** Reset the id counter (tests only). */ +export function resetTicketIdCounter(): void { + ticketCounter = 0; +} + +export function createInitialWizard(defaults: { + projectKey?: string; + issueType: string; + harnessName?: string; +}): TicketWizardState { + return { + step: "source-type", + projectKey: defaults.projectKey, + promptStyle: "pm", + issueType: defaults.issueType, + harnessName: defaults.harnessName, + decompose: false, + tasks: [], + draftInput: "", + }; +} + +export function createTicketWorkspace(id: string, wizard: TicketWizardState): TicketWorkspace { + return { + id, + wizard: { ...wizard, tasks: [...wizard.tasks] }, + }; +} + +export function getActiveTicket(state: TicketWorkspacesState): TicketWorkspace | null { + if (!state.activeTicketId) return null; + return state.tickets.find((t) => t.id === state.activeTicketId) ?? null; +} + +export function getTicket(state: TicketWorkspacesState, id: string): TicketWorkspace | undefined { + return state.tickets.find((t) => t.id === id); +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max - 1)}…`; +} + +/** Human-readable label for the sidebar. */ +export function ticketTitle(ticket: TicketWorkspace, max = 28): string { + if (ticket.wizard.createdKey) { + return truncate(ticket.wizard.createdKey, max); + } + if (ticket.wizard.previewData?.summary?.trim()) { + return truncate(ticket.wizard.previewData.summary.trim(), max); + } + if (ticket.wizard.sourceContent?.trim()) { + const firstLine = + ticket.wizard.sourceContent.trim().split(/\n/)[0] ?? ticket.wizard.sourceContent.trim(); + return truncate(firstLine, max); + } + return "New ticket"; +} + +/** Optional secondary identity line. */ +export function ticketSubtitle(ticket: TicketWorkspace, max = 24): string | null { + if (ticket.wizard.createdKey && ticket.wizard.previewData?.summary?.trim()) { + return truncate(ticket.wizard.previewData.summary.trim(), max); + } + if (ticket.wizard.issueType && ticket.wizard.issueType !== "Task") { + return ticket.wizard.issueType; + } + return null; +} + +export type TicketAgentStatus = "running" | "ready" | "error" | "idle" | "done"; + +/** Map wizard step to a compact sidebar status. */ +export function ticketAgentStatus(step: WizardStep, hasErrorMessage = false): TicketAgentStatus { + if (hasErrorMessage && step === "success") return "error"; + if (step === "generating" || step === "regenerating" || step === "done") return "running"; + if (step === "preview" || step === "edit-prompt") return "ready"; + if (step === "success") return "done"; + if (step === "source-type" || step === "project") return "idle"; + return "ready"; +} + +export function ticketAgentStatusLabel(status: TicketAgentStatus): string { + switch (status) { + case "running": + return "Running"; + case "ready": + return "Ready"; + case "error": + return "Error"; + case "idle": + return "Idle"; + case "done": + return "Done"; + } +} + +/** Compact label for narrow TUI sidebars. */ +export function ticketAgentStatusShort(status: TicketAgentStatus): string { + switch (status) { + case "running": + return "run"; + case "ready": + return "rdy"; + case "error": + return "err"; + case "idle": + return "idle"; + case "done": + return "done"; + } +} + +export function isTicketBusy(step: WizardStep): boolean { + return step === "generating" || step === "regenerating" || step === "done"; +} + +/** + * Choose the next active ticket after closing `closedId`. + * Prefers the neighbor above, then below; null if none remain. + */ +export function pickActiveAfterClose( + tickets: TicketWorkspace[], + closedId: string, + previousActiveId: string | null, +): string | null { + if (previousActiveId !== closedId) { + if (previousActiveId && tickets.some((t) => t.id === previousActiveId && t.id !== closedId)) { + return previousActiveId; + } + } + const remaining = tickets.filter((t) => t.id !== closedId); + if (remaining.length === 0) return null; + const closedIndex = tickets.findIndex((t) => t.id === closedId); + if (closedIndex > 0) { + return tickets[closedIndex - 1]!.id; + } + return remaining[0]!.id; +} + +function updateTicket( + state: TicketWorkspacesState, + id: string, + update: (ticket: TicketWorkspace) => TicketWorkspace, +): TicketWorkspacesState { + const index = state.tickets.findIndex((t) => t.id === id); + if (index < 0) return state; + const tickets = state.tickets.slice(); + tickets[index] = update(tickets[index]!); + return { ...state, tickets }; +} + +export function ticketWorkspacesReducer( + state: TicketWorkspacesState, + action: TicketWorkspacesAction, +): TicketWorkspacesState { + switch (action.type) { + case "session-started": { + // Fresh interactive session: one open workspace so single-ticket use stays smooth. + const ticket = createTicketWorkspace(action.id, action.wizard); + return { tickets: [ticket], activeTicketId: action.id }; + } + case "ticket-opened": { + const ticket = createTicketWorkspace(action.id, action.wizard); + return { + tickets: [...state.tickets, ticket], + activeTicketId: action.id, + }; + } + case "ticket-activated": { + if (!state.tickets.some((t) => t.id === action.id)) return state; + return { ...state, activeTicketId: action.id }; + } + case "ticket-closed": { + if (!state.tickets.some((t) => t.id === action.id)) return state; + const nextActive = pickActiveAfterClose(state.tickets, action.id, state.activeTicketId); + return { + tickets: state.tickets.filter((t) => t.id !== action.id), + activeTicketId: nextActive, + }; + } + case "wizard-patched": { + return updateTicket(state, action.id, (ticket) => { + // `in` so callers can clear optional fields with explicit undefined. + const next: TicketWizardState = { + ...ticket.wizard, + ...action.patch, + tasks: action.patch.tasks ?? ticket.wizard.tasks, + }; + if ("previewData" in action.patch) { + next.previewData = action.patch.previewData; + } + if ("successMessage" in action.patch) { + next.successMessage = action.patch.successMessage; + } + if ("statusMessage" in action.patch) { + next.statusMessage = action.patch.statusMessage; + } + if ("createdKey" in action.patch) { + next.createdKey = action.patch.createdKey; + } + if ("editPrompt" in action.patch) { + next.editPrompt = action.patch.editPrompt; + } + if ("customInstructions" in action.patch) { + next.customInstructions = action.patch.customInstructions; + } + if ("epicKey" in action.patch) { + next.epicKey = action.patch.epicKey; + } + if ("sourceType" in action.patch) { + next.sourceType = action.patch.sourceType; + } + if ("sourceContent" in action.patch) { + next.sourceContent = action.patch.sourceContent; + } + return { ...ticket, wizard: next }; + }); + } + } +}