diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 6f6af963..1c8ed5e1 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -13,6 +13,7 @@ import { resolveWorktreeMakeTargetPath, worktreeInitCommand, worktreeMakeCommand, + worktreeReseedCommand, } from "../commands/worktree.js"; import { buildWorktreeConfig, @@ -481,6 +482,191 @@ describe("worktree helpers", () => { } }); + it("requires an explicit source for worktree reseed", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-source-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await expect(worktreeReseedCommand({ seed: false, yes: true })).rejects.toThrow( + "Reseed requires an explicit source.", + ); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("reseed preserves the current worktree ports, instance id, and branding", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceRoot = path.join(tempRoot, "source"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const currentInstanceId = "existing-worktree"; + const currentPaths = resolveWorktreeLocalPaths({ + cwd: repoRoot, + homeDir, + instanceId: currentInstanceId, + }); + const sourcePaths = resolveWorktreeLocalPaths({ + cwd: sourceRoot, + homeDir: path.join(tempRoot, ".paperclip-source"), + instanceId: "default", + }); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const currentConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: currentPaths, + serverPort: 3114, + databasePort: 54341, + }); + const sourceConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: sourcePaths, + serverPort: 3200, + databasePort: 54400, + }); + fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync( + currentPaths.envPath, + [ + `PAPERCLIP_HOME=${homeDir}`, + `PAPERCLIP_INSTANCE_ID=${currentInstanceId}`, + "PAPERCLIP_WORKTREE_NAME=existing-name", + "PAPERCLIP_WORKTREE_COLOR=\"#112233\"", + ].join("\n"), + "utf8", + ); + + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + seed: false, + yes: true, + }); + + const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8"); + + expect(rewrittenConfig.server.port).toBe(3114); + expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341); + expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir); + expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name"); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\""); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("restores the current worktree config and instance data if reseed fails", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceRoot = path.join(tempRoot, "source"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const currentInstanceId = "rollback-worktree"; + const currentPaths = resolveWorktreeLocalPaths({ + cwd: repoRoot, + homeDir, + instanceId: currentInstanceId, + }); + const sourcePaths = resolveWorktreeLocalPaths({ + cwd: sourceRoot, + homeDir: path.join(tempRoot, ".paperclip-source"), + instanceId: "default", + }); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(currentPaths.instanceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const currentConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: currentPaths, + serverPort: 3114, + databasePort: 54341, + }); + const sourceConfig = { + ...buildSourceConfig(), + database: { + mode: "postgres", + connectionString: "", + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: sourcePaths.secretsKeyFilePath, + }, + }, + } as PaperclipConfig; + + fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8"); + fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8"); + fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); + + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await expect(worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + yes: true, + })).rejects.toThrow("Source instance uses postgres mode but has no connection string"); + + const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8"); + const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8"); + + expect(restoredConfig.server.port).toBe(3114); + expect(restoredConfig.database.embeddedPostgresPort).toBe(54341); + expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); + expect(restoredMarker).toBe("keep me"); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("rebinds same-repo workspace paths onto the current worktree root", () => { expect( rebindWorkspaceCwd({ diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 65e74849..3025e955 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -80,6 +80,7 @@ import { type WorktreeInitOptions = { name?: string; + color?: string; instance?: string; home?: string; fromConfig?: string; @@ -97,6 +98,22 @@ type WorktreeMakeOptions = WorktreeInitOptions & { startPoint?: string; }; +type WorktreeReseedOptions = { + fromConfig?: string; + fromDataDir?: string; + fromInstance?: string; + home?: string; + seedMode?: string; + yes?: boolean; + seed?: boolean; +}; + +type WorktreeReseedBackup = { + tempRoot: string; + repoConfigDirBackup: string | null; + instanceRootBackup: string | null; +}; + type WorktreeEnvOptions = { config?: string; json?: boolean; @@ -942,8 +959,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { instanceId, }); const branding = { - name: worktreeName, - color: generateWorktreeColor(), + name: opts.name ?? worktreeName, + color: opts.color ?? generateWorktreeColor(), }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; @@ -1051,6 +1068,160 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { + if (!existsSync(sourcePath)) { + return null; + } + await fsPromises.cp(sourcePath, targetPath, { recursive: true }); + return targetPath; +} + +async function snapshotWorktreeReseedState(target: { + repoConfigDir: string; + instanceRoot: string; +}): Promise { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-reseed-backup-")); + return { + tempRoot, + repoConfigDirBackup: await snapshotDirectory( + target.repoConfigDir, + path.resolve(tempRoot, "repo-config"), + ), + instanceRootBackup: await snapshotDirectory( + target.instanceRoot, + path.resolve(tempRoot, "instance-root"), + ), + }; +} + +async function restoreDirectoryBackup(backupPath: string | null, targetPath: string): Promise { + rmSync(targetPath, { recursive: true, force: true }); + if (!backupPath) { + return; + } + await fsPromises.cp(backupPath, targetPath, { recursive: true }); +} + +async function restoreWorktreeReseedState( + backup: WorktreeReseedBackup, + target: { repoConfigDir: string; instanceRoot: string }, +): Promise { + await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir); + await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot); +} + +export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed "))); + + if (!hasExplicitSourceSelection(opts)) { + throw new Error( + "Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).", + ); + } + + const target = resolveCurrentWorktreeReseedState({ home: opts.home }); + const sourceConfigPath = resolveSourceConfigPath(opts); + if (path.resolve(sourceConfigPath) === target.currentConfigPath) { + throw new Error( + "Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.", + ); + } + + const seedMode = opts.seedMode ?? "minimal"; + if (!isWorktreeSeedMode(seedMode)) { + throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); + } + + const confirmed = opts.yes + ? true + : await p.confirm({ + message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`, + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.log.warn("Reseed cancelled."); + return; + } + + const targetPaths = resolveWorktreeLocalPaths({ + cwd: process.cwd(), + homeDir: target.homeDir, + instanceId: target.instanceId, + }); + const backup = await snapshotWorktreeReseedState(targetPaths); + + try { + await runWorktreeInit({ + name: target.worktreeName, + color: target.worktreeColor, + instance: target.instanceId, + home: target.homeDir, + fromConfig: opts.fromConfig, + fromDataDir: opts.fromDataDir, + fromInstance: opts.fromInstance, + sourceConfigPathOverride: sourceConfigPath, + serverPort: target.serverPort, + dbPort: target.dbPort, + seed: opts.seed ?? true, + seedMode, + force: true, + }); + } catch (error) { + await restoreWorktreeReseedState(backup, targetPaths); + throw error; + } finally { + rmSync(backup.tempRoot, { recursive: true, force: true }); + } +} + export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); @@ -2632,6 +2803,17 @@ export function registerWorktreeCommands(program: Command): void { .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + worktree + .command("reseed") + .description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id") + .option("--from-config ", "Source config.json to seed from") + .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") + .option("--from-instance ", "Source instance id when deriving the source config") + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") + .option("--yes", "Skip the destructive confirmation prompt", false) + .action(worktreeReseedCommand); + program .command("worktree:list") .description("List git worktrees visible from this repo and whether they look like Paperclip worktrees") diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7aa171f5..6aa30237 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -232,6 +232,15 @@ pnpm paperclipai worktree init --force --seed-mode minimal \ That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances//`, and preserves the git worktree contents themselves. +For existing worktrees, prefer the dedicated reseed command instead of rebuilding the `worktree init --force` flags manually: + +```sh +cd /path/to/existing/worktree +pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full +``` + +`worktree reseed` preserves the current worktree's instance id, ports, and branding while replacing only that worktree's isolated Paperclip instance data from the chosen source. + **`pnpm paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. | Option | Description | @@ -258,6 +267,17 @@ pnpm paperclipai worktree:make experiment --no-seed **`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance. +**`pnpm paperclipai worktree reseed [options]`** — Replace the current worktree instance with a fresh seed from another Paperclip source while preserving the current worktree's ports and instance id. + +| Option | Description | +|---|---| +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source `PAPERCLIP_HOME` used when deriving the source config | +| `--from-instance ` | Source instance id when deriving the source config | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--yes` | Skip the destructive confirmation prompt | + | Option | Description | |---|---| | `-c, --config ` | Path to config file | diff --git a/ui/package.json b/ui/package.json index 7a798f7d..020e2ec2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "access": "public" }, "dependencies": { + "@assistant-ui/react": "0.12.23", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0bc4721b..804e8d48 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -36,6 +36,7 @@ import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; +import { IssueChatUxLab } from "./pages/IssueChatUxLab"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -175,6 +176,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -347,6 +349,7 @@ export function App() { } /> } /> } /> + } /> } /> }> {boardRoutes()} diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index c0883192..26c969e3 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -3,13 +3,13 @@ import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; -import { issuesApi } from "../api/issues"; import type { TranscriptEntry } from "../adapters"; +import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { ExternalLink } from "lucide-react"; import { Identity } from "./Identity"; -import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { RunChatSurface } from "./RunChatSurface"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; const MIN_DASHBOARD_RUNS = 4; @@ -63,6 +63,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { {runs.map((run) => (
-
diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx new file mode 100644 index 00000000..f292646a --- /dev/null +++ b/ui/src/components/IssueChatThread.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread"; + +vi.mock("@assistant-ui/react", () => ({ + AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, + ThreadPrimitive: { + Root: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + Viewport: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + Empty: ({ children }: { children: ReactNode }) =>
{children}
, + Messages: () =>
, + }, + MessagePrimitive: { + Root: ({ children }: { children: ReactNode }) =>
{children}
, + Content: () => null, + Parts: () => null, + }, + useAui: () => ({ thread: () => ({ append: vi.fn() }) }), + useAuiState: () => false, + useMessage: () => ({ + id: "message", + role: "assistant", + createdAt: new Date("2026-04-06T12:00:00.000Z"), + content: [], + metadata: { custom: {} }, + status: { type: "complete" }, + }), +})); + +vi.mock("./transcript/useLiveRunTranscripts", () => ({ + useLiveRunTranscripts: () => ({ + transcriptByRun: new Map(), + hasOutputForRun: () => false, + }), +})); + +vi.mock("./MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: ({ + value = "", + onChange, + placeholder, + }: { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + }) => ( +