diff --git a/docs/plans/2026-04-23-web-frontend-parity-design.md b/docs/plans/2026-04-23-web-frontend-parity-design.md new file mode 100644 index 00000000..507fb4bf --- /dev/null +++ b/docs/plans/2026-04-23-web-frontend-parity-design.md @@ -0,0 +1,85 @@ +# Web Frontend Parity — Design + +**Goal:** Bring the web frontend (Svelte served by `vite` + axum server binary at `src-tauri/src/bin/server.rs`) to feature parity with the Tauri desktop frontend so the desktop target can be deprecated. Motivation: the web frontend is playwright-testable end-to-end; the Tauri target is not. + +## Current state + +- **Shared code path:** `src/` is one Svelte app. `src/lib/backend.ts` already dispatches `command(cmd, args)` to either Tauri `invoke` or `fetch("/api/" + cmd)`, and `listen(event, handler)` to either Tauri events or the shared `/ws` WebSocket. All business events pass through `WsBroadcastEmitter`, so event parity is already achieved for app logic. +- **Backend gap:** 59 unique `#[tauri::command]` handlers in `src-tauri/src/commands.rs`. The axum server exposes 12 routes (`src-tauri/src/bin/server.rs:33-46`); `create_session` is stubbed (501). **~45 commands have no HTTP equivalent.** +- **Frontend leaks:** 7 files import `@tauri-apps/*` directly, bypassing `backend.ts`. They fail silently in web mode: + - `src/main.ts:1` — `invoke("log_frontend_error")` + - `src/App.svelte:5` — `@tauri-apps/api/window` + - `src/lib/Terminal.svelte:7`, `AgentDashboard.svelte:5`, `IssuesModal.svelte:4`, `kanban/KanbanCard.svelte:2` — `@tauri-apps/plugin-opener` + - `src/lib/clipboard.ts:1` — `@tauri-apps/plugin-clipboard-manager` + - `src/lib/finish-branch.ts` — takes `invoke` as a parameter; callers pass Tauri's `invoke` directly +- **Test harness:** `playwright.config.ts:10-22` already boots axum + vite. The chat daemon (`~/projects/the-controller-daemon`) is **not** booted, so chat e2e tests would fail. + +## Open scope questions (resolve before Pass 2) + +1. **Tauri deletion shape.** Delete `src-tauri/src/lib.rs`, `tauri.conf.json`, every `#[tauri::command]` wrapper, and ship `src-tauri/src/bin/server.rs` as the sole backend — or keep Tauri buildable during the transition and just converge the frontend on `backend.ts`? Decision affects whether handlers get *moved* into `server.rs` or *duplicated* during the migration. +2. **Platform-only features.** For `copy_image_file_to_clipboard`, `capture_app_screenshot`, and OS drag-drop with file paths — drop entirely, or reimplement with browser APIs (File System Access, `html2canvas`, DataTransfer)? + +## Inventory of missing HTTP routes + +Grouped by domain. Check = has HTTP route in server.rs today. + +### Covered (13) + +`list_projects`, `check_onboarding`, `restore_sessions`, `connect_session`, `load_project`, `write_to_pty`, `send_raw_to_pty`, `resize_pty`, `close_session`, `list_archived_projects`, `merge_session_branch`, plus `/ws`. `create_session` is stubbed. + +### Pass 1 — unblock web mode golden path + +| Command | Why P0 | +|---|---| +| `create_session` (un-stub) | Can't create sessions in web | +| `read_daemon_token` | Chat workspace mode bootstrap is blocked | +| `log_frontend_error` | `main.ts` error reporting currently hits a direct `invoke` | + +Also in Pass 1 — frontend cleanup: + +- Route `@tauri-apps/plugin-opener` through a new `backend.openUrl(url)` that falls back to `window.open(url, "_blank", "noopener")`. Update the 4 `*.svelte` call sites. +- Replace the direct `invoke("log_frontend_error")` in `main.ts` with `backend.command(...)`. +- Refactor `finish-branch.ts` to use `backend.command` instead of receiving `invoke` as a parameter. +- Gate `getCurrentWindow()` in `App.svelte` behind the `isTauri` check (or move the logic to a no-op in web). +- `src/lib/clipboard.ts` image-paste path: detect Tauri via `isTauri` and fall back to `navigator.clipboard.read()` in web; acceptable degradation for P1 is "text-only paste in web". + +**Exit criterion:** `pnpm dev` + `cargo run --bin server --features server` yields a browser at `http://localhost:1420` where you can open an existing project, spawn a session, and see terminal output. + +### Pass 2 — feature parity + +| Domain | Commands | +|---|---| +| Project lifecycle | `create_project`, `delete_project`, `list_project_prompts`, `save_onboarding_config` | +| Session detail | `get_session_commits`, `get_session_token_usage`, `get_repo_head` | +| Staging | `stage_session`, `unstage_session` | +| Prompts / agents.md | `save_session_prompt`, `set_initial_prompt`, `get_agents_md`, `update_agents_md` | +| GitHub | `list_github_issues`, `create_github_issue`, `close_github_issue`, `delete_github_issue`, `post_github_comment`, `add_github_label`, `remove_github_label`, `generate_issue_body` | +| Kanban | `kanban_load_order`, `kanban_save_order` | +| Secure env | `submit_secure_env_value`, `cancel_secure_env_request` | +| Onboarding / filesystem | `check_claude_cli`, `start_claude_login`, `stop_claude_login`, `scaffold_project`, `generate_project_names`, `list_directories_at`, `list_root_directories`, `home_dir` | + +Most of these are thin wrappers — the logic already lives in `the_controller_lib`. The server handler only needs arg unpacking + error mapping. Plan to add a small macro or helper to cut boilerplate. + +### Pass 3 — background features + harness + +- Maintainer + auto-worker HTTP wiring (12 commands): `trigger_maintainer_check`, `get_maintainer_status`, `get_maintainer_issues`, `get_maintainer_issue_detail`, `get_maintainer_history`, `clear_maintainer_reports`, `configure_maintainer`, `get_auto_worker_queue`, `get_worker_reports`, `configure_auto_worker`, `list_assigned_issues`. +- Add the chat daemon to `playwright.config.ts` `webServer` list (or a script that spawns it before tests). +- Platform-only features (`copy_image_file_to_clipboard`, `capture_app_screenshot`) — decide per Q2 above. + +## Deprecation steps (after Pass 3) + +Deferred until the three passes land and the web target has been exercised by a full playwright run: + +1. Delete `src-tauri/src/lib.rs` and the `#[tauri::command]` wrappers in `commands.rs` (keep the underlying functions — they're shared library code). +2. Remove `tauri.conf.json`, `src-tauri/capabilities/`, `src-tauri/icons/`, `src-tauri/build.rs`. +3. Drop `@tauri-apps/*` dependencies from `package.json`; remove `src/lib/backend.ts`'s Tauri branch (`fetch`/`WebSocket` only). +4. Rename `src-tauri/` → `server/` (or similar) and update build scripts. +5. Update `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`. + +## Validation + +- **Pass 1:** playwright smoke test — boot web stack, open a seeded project, spawn session, write to PTY, observe output. +- **Pass 2:** extend existing e2e specs in `e2e/specs/` to run under the `e2e` project (currently chromium against `localhost:1420`). Each command needs a test that fails if the route is removed. +- **Pass 3:** a chat-mode e2e spec that sends a message and observes agent output. + +At each pass, per `CLAUDE.md` task structure: Definition / Constraints / Validation must be stated before code. diff --git a/e2e/specs/architecture-logs.spec.ts b/e2e/specs/architecture-logs.spec.ts deleted file mode 100644 index 22e009ab..00000000 --- a/e2e/specs/architecture-logs.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { test, expect } from "@playwright/test"; - -/** - * User story: A user in Architecture mode triggers generation and sees - * real-time log feedback, then a rendered diagram with components. - * - * Actor: User in Architecture mode with a project loaded - * Action: Presses 'r' to generate architecture - * Outcome: Log lines stream during generation, then an SVG diagram - * renders with a populated components list and details panel - */ - -async function switchToArchitectureMode(page: import("@playwright/test").Page) { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Click a project in the sidebar to establish focus (required for - // hotkeys like 'r' that need a focused project) - const firstProject = page.locator(".project-header").first(); - await expect(firstProject).toBeVisible({ timeout: 5_000 }); - await firstProject.click(); - - // Switch to architecture mode via real keyboard - await page.keyboard.press("Space"); - await expect(page.locator(".picker")).toBeVisible({ timeout: 3_000 }); - await page.keyboard.press("r"); - await expect(page.locator(".architecture-explorer")).toBeVisible({ - timeout: 5_000, - }); -} - -test("architecture generation streams logs then renders diagram with components", async ({ - page, -}) => { - test.setTimeout(180_000); - - await switchToArchitectureMode(page); - - // Precondition: empty state visible before generation - await expect( - page.locator(".diagram-surface .empty-state") - ).toContainText("No architecture generated yet"); - - // Action: trigger generation with real keyboard press - await page.keyboard.press("r"); - - // Outcome 1: log lines stream showing generation progress - const logOutput = page.locator(".log-output"); - await expect(logOutput).toBeVisible({ timeout: 30_000 }); - await expect(logOutput).toContainText("Scanning repository for evidence", { - timeout: 5_000, - }); - await expect(logOutput).toContainText("evidence files", { - timeout: 10_000, - }); - - // Outcome 2: after generation completes, a rendered SVG diagram appears - const diagramSvg = page.locator(".diagram-render svg"); - await expect(diagramSvg).toBeVisible({ timeout: 150_000 }); - - // Outcome 3: components list populates with at least one component - const componentButtons = page.locator(".component-list button"); - await expect(componentButtons.first()).toBeVisible({ timeout: 5_000 }); - const componentCount = await componentButtons.count(); - expect(componentCount).toBeGreaterThan(0); - - // Outcome 4: component count badge reflects the actual number - await expect(page.locator(".section-count")).toHaveText( - String(componentCount) - ); - - // Outcome 5: first component is auto-selected and shows details - await expect(componentButtons.first()).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator(".detail-name")).toBeVisible(); - await expect(page.locator(".summary")).toBeVisible(); - - // Outcome 6: clicking a different component updates the details - if (componentCount > 1) { - const secondBtn = componentButtons.nth(1); - const secondName = await secondBtn.innerText(); - await secondBtn.click(); - await expect(page.locator(".detail-name")).toHaveText(secondName); - } - - // Outcome 7: generate button shows "Regenerate" after success - await expect(page.locator(".generate-action")).toHaveText("Regenerate"); -}); diff --git a/e2e/specs/notes-ai-chat.spec.ts b/e2e/specs/notes-ai-chat.spec.ts deleted file mode 100644 index 4ea09387..00000000 --- a/e2e/specs/notes-ai-chat.spec.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { test, expect } from "@playwright/test"; - -async function navigateToNote(page: any, projectName: string, noteFilename: string) { - // Set up test note - await page.request.post("http://localhost:3001/api/write_note", { - data: { - projectName, - filename: noteFilename, - content: "# AI Chat Test\n\nThis is the first paragraph with some text to select.\n\nThis is the second paragraph for context.", - }, - }); - - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to notes mode - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("n"); - await page.waitForTimeout(500); - - // Click the project to expand it - const projectEl = page.locator(".sidebar").getByText(projectName, { exact: true }); - await expect(projectEl).toBeVisible({ timeout: 3_000 }); - await projectEl.click(); - await page.waitForTimeout(300); - - // Expand the project (click the arrow or press l) - await page.keyboard.press("l"); - await page.waitForTimeout(500); - - // Click the note - const displayName = noteFilename.replace(/\.md$/, ""); - const noteEl = page.locator(".sidebar").getByText(displayName); - await expect(noteEl).toBeVisible({ timeout: 3_000 }); - await noteEl.click(); - await page.waitForTimeout(300); - - // Open editor with Enter - await page.keyboard.press("Enter"); - await page.waitForTimeout(1000); - - const editor = page.locator('[data-testid="note-code-editor"]'); - await expect(editor).toBeVisible({ timeout: 5_000 }); - await expect(editor.locator(".cm-focused")).toBeVisible({ timeout: 2_000 }); - - return editor; -} - -test("ga in visual mode opens AI chat panel near selection", async ({ page }) => { - const editor = await navigateToNote(page, "the-controller", "ai-chat-test.md"); - - // Navigate down to the text content (past the heading and blank line) - await page.keyboard.press("j"); - await page.waitForTimeout(100); - await page.keyboard.press("j"); - await page.waitForTimeout(100); - - // Enter visual mode and select some text - await page.keyboard.press("v"); - await page.waitForTimeout(200); - - // Select to end of line - await page.keyboard.press("$"); - await page.waitForTimeout(200); - - // Take screenshot before ga - await page.screenshot({ path: "e2e/results/before-ga.png" }); - - // Press ga to trigger AI chat - await page.keyboard.press("g"); - await page.waitForTimeout(100); - await page.keyboard.press("a"); - await page.waitForTimeout(500); - - // Take screenshot after ga - await page.screenshot({ path: "e2e/results/after-ga.png" }); - - // Verify the AI panel appears - const panel = page.locator('[data-testid="note-ai-panel"]'); - await expect(panel).toBeVisible({ timeout: 3_000 }); - - // Verify the input is present and focused - const input = page.locator('[data-testid="note-ai-input"]'); - await expect(input).toBeVisible(); - await expect(input).toBeFocused(); - - // Verify selected text preview is shown in the panel - const panelText = await panel.textContent(); - console.log("Panel text:", panelText); - - // Take screenshot with panel open - await page.screenshot({ path: "e2e/results/ai-panel-open.png" }); - - // Test typing in the input - await input.fill("explain this text"); - await expect(input).toHaveValue("explain this text"); - - // Take screenshot with text entered - await page.screenshot({ path: "e2e/results/ai-panel-with-prompt.png" }); - - // Test Escape dismisses the panel - await page.keyboard.press("Escape"); - await page.waitForTimeout(300); - await expect(panel).not.toBeVisible(); - - // Take screenshot after dismiss - await page.screenshot({ path: "e2e/results/ai-panel-dismissed.png" }); -}); - -test("ga does nothing in normal mode (only works in visual mode)", async ({ page }) => { - const editor = await navigateToNote(page, "the-controller", "ai-chat-test.md"); - - // In normal mode (no visual selection), press ga - await page.keyboard.press("g"); - await page.waitForTimeout(100); - await page.keyboard.press("a"); - await page.waitForTimeout(500); - - // Panel should NOT appear - const panel = page.locator('[data-testid="note-ai-panel"]'); - await expect(panel).not.toBeVisible(); -}); - -test("multiline visual selection works with ga", async ({ page }) => { - const editor = await navigateToNote(page, "the-controller", "ai-chat-test.md"); - - // Navigate to content - await page.keyboard.press("j"); - await page.waitForTimeout(100); - await page.keyboard.press("j"); - await page.waitForTimeout(100); - - // Enter visual line mode (V) for multiline selection - await page.keyboard.press("Shift+v"); - await page.waitForTimeout(200); - - // Select down one more line - await page.keyboard.press("j"); - await page.waitForTimeout(200); - await page.keyboard.press("j"); - await page.waitForTimeout(200); - - // Take screenshot of multiline selection - await page.screenshot({ path: "e2e/results/multiline-selection.png" }); - - // Press ga - await page.keyboard.press("g"); - await page.waitForTimeout(100); - await page.keyboard.press("a"); - await page.waitForTimeout(500); - - // Panel should appear - const panel = page.locator('[data-testid="note-ai-panel"]'); - await expect(panel).toBeVisible({ timeout: 3_000 }); - - // Selected text preview should contain multiline text - const preview = await panel.locator("pre").textContent(); - console.log("Multiline preview:", preview); - - // Take screenshot - await page.screenshot({ path: "e2e/results/multiline-ai-panel.png" }); - - // Dismiss - await page.keyboard.press("Escape"); - await page.waitForTimeout(300); - await expect(panel).not.toBeVisible(); -}); - -test("ga works when entire note is selected with ggVG", async ({ page }) => { - const editor = await navigateToNote(page, "the-controller", "ai-chat-test.md"); - - // Select the entire note: gg (go to top), V (visual line), G (to bottom) - await page.keyboard.press("g"); - await page.waitForTimeout(100); - await page.keyboard.press("g"); - await page.waitForTimeout(200); - await page.keyboard.press("Shift+v"); - await page.waitForTimeout(200); - await page.keyboard.press("Shift+g"); - await page.waitForTimeout(200); - - // Press ga to trigger AI chat - await page.keyboard.press("g"); - await page.waitForTimeout(100); - await page.keyboard.press("a"); - await page.waitForTimeout(500); - - // Panel should appear even though position 0 may be scrolled off-screen - const panel = page.locator('[data-testid="note-ai-panel"]'); - await expect(panel).toBeVisible({ timeout: 3_000 }); - - // Dismiss - await page.keyboard.press("Escape"); - await page.waitForTimeout(300); - await expect(panel).not.toBeVisible(); -}); diff --git a/e2e/specs/notes-keybindings.spec.ts b/e2e/specs/notes-keybindings.spec.ts deleted file mode 100644 index 112674fc..00000000 --- a/e2e/specs/notes-keybindings.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("n key in notes mode opens New Folder modal", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to notes mode: Space then n - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("n"); - await page.waitForTimeout(500); - - // Now in notes mode — press n to create a new folder - await page.keyboard.press("n"); - - // The New Folder modal should appear with "New Folder" header - const modal = page.locator(".modal"); - await expect(modal).toBeVisible({ timeout: 3_000 }); - await expect(modal.locator(".modal-header")).toHaveText("New Folder"); -}); - -test("c key in notes mode opens New Note modal", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to notes mode: Space then n - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("n"); - await page.waitForTimeout(500); - - // Now in notes mode — press c to create a new note - await page.keyboard.press("c"); - - // The New Note modal should appear with "New Note" header - const modal = page.locator(".modal"); - await expect(modal).toBeVisible({ timeout: 3_000 }); - await expect(modal.locator(".modal-header")).toHaveText("New Note"); -}); diff --git a/e2e/specs/notes-vim-o.spec.ts b/e2e/specs/notes-vim-o.spec.ts deleted file mode 100644 index c7470e56..00000000 --- a/e2e/specs/notes-vim-o.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("o key creates new line and enters insert mode", async ({ page }) => { - // Reset note content - await page.request.post("http://localhost:3001/api/write_note", { - data: { projectName: "fa-agent-v3", filename: "test-note.md", content: "# Test Note" }, - }); - - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Navigate to notes mode and open editor - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("n"); - await page.waitForTimeout(500); - await page.keyboard.press("j"); - await page.waitForTimeout(200); - await page.keyboard.press("l"); - await page.waitForTimeout(300); - await page.keyboard.press("j"); - await page.waitForTimeout(200); - await page.keyboard.press("Enter"); - await page.waitForTimeout(1000); - - const editor = page.locator('[data-testid="note-code-editor"]'); - await expect(editor).toBeVisible({ timeout: 3_000 }); - await expect(editor.locator(".cm-focused")).toBeVisible({ timeout: 2_000 }); - - const getContent = () => page.evaluate(() => { - const lines = document.querySelectorAll('[data-testid="note-code-editor"] .cm-line'); - return Array.from(lines).map(l => l.textContent).join('\n'); - }); - - // Press o — should create new line below and enter insert mode - await page.keyboard.press("o"); - await page.waitForTimeout(300); - - // Type text on the new line - await page.keyboard.type("hello from o"); - await page.waitForTimeout(300); - - const content = await getContent(); - console.log("Content after o + typing:", JSON.stringify(content)); - - // Verify: line 1 is original content, line 2 has the typed text - expect(content).toContain("# Test Note"); - expect(content).toContain("hello from o"); - - // Escape back to normal mode, press o again - await page.keyboard.press("Escape"); - await page.waitForTimeout(200); - await page.keyboard.press("o"); - await page.waitForTimeout(300); - await page.keyboard.type("second line"); - await page.waitForTimeout(300); - - const finalContent = await getContent(); - console.log("Final content:", JSON.stringify(finalContent)); - - expect(finalContent).toContain("hello from o"); - expect(finalContent).toContain("second line"); -}); - -test("o entry key from sidebar opens editor in insert mode with new line", async ({ page }) => { - // Reset note content - await page.request.post("http://localhost:3001/api/write_note", { - data: { projectName: "fa-agent-v3", filename: "test-note.md", content: "# Test Note" }, - }); - - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Navigate to notes mode - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("n"); - await page.waitForTimeout(500); - await page.keyboard.press("j"); - await page.waitForTimeout(200); - await page.keyboard.press("l"); - await page.waitForTimeout(300); - await page.keyboard.press("j"); - await page.waitForTimeout(200); - - // Press o on the focused note (entry key) — should open editor with vim o - await page.keyboard.press("o"); - await page.waitForTimeout(1000); - - const editor = page.locator('[data-testid="note-code-editor"]'); - await expect(editor).toBeVisible({ timeout: 3_000 }); - - // Type text — if o entry key worked, we should be in insert mode on a new line - await page.keyboard.type("via entry key"); - await page.waitForTimeout(300); - - const content = await page.evaluate(() => { - const lines = document.querySelectorAll('[data-testid="note-code-editor"] .cm-line'); - return Array.from(lines).map(l => l.textContent).join('\n'); - }); - console.log("Content after entry-key o:", JSON.stringify(content)); - - expect(content).toContain("# Test Note"); - expect(content).toContain("via entry key"); -}); diff --git a/e2e/specs/smoke.spec.ts b/e2e/specs/smoke.spec.ts index d862f1fb..ca8608e3 100644 --- a/e2e/specs/smoke.spec.ts +++ b/e2e/specs/smoke.spec.ts @@ -2,6 +2,6 @@ import { test, expect } from "@playwright/test"; test("app loads and renders sidebar", async ({ page }) => { await page.goto("/"); - await expect(page).toHaveTitle("The Controller"); + await expect(page).toHaveTitle(/^The Controller/); await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); }); diff --git a/e2e/specs/ui-consistency.spec.ts b/e2e/specs/ui-consistency.spec.ts index d15dc4fa..b0b936a3 100644 --- a/e2e/specs/ui-consistency.spec.ts +++ b/e2e/specs/ui-consistency.spec.ts @@ -8,7 +8,7 @@ import { test, expect, type Page } from "@playwright/test"; test.describe("Layout integrity", () => { test("app loads with sidebar and main content area", async ({ page }) => { await page.goto("/"); - await expect(page).toHaveTitle("The Controller"); + await expect(page).toHaveTitle(/^The Controller/); await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); await expect(page.locator(".terminal-area")).toBeVisible(); @@ -168,19 +168,15 @@ test.describe("Sidebar header updates per mode", () => { if (pickerVisible) { await expect(picker.locator(".picker-title")).toHaveText("Switch Workspace"); - // Verify all 6 modes are listed const options = picker.locator(".picker-option"); - await expect(options).toHaveCount(6); + await expect(options).toHaveCount(4); - // Check mode labels const labels = await options.locator(".option-label").allTextContents(); expect(labels).toEqual([ "Development", "Agents", - "Architecture", - "Notes", - "Infrastructure", - "Voice", + "Kanban", + "Chat", ]); // Current mode should have "current" badge @@ -271,131 +267,6 @@ test.describe("No overlapping or clipped elements", () => { }); }); -test.describe("Architecture mode empty state", () => { - test("architecture view shows empty state with generate hint", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to architecture mode - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - - const picker = page.locator(".picker"); - const pickerVisible = await picker.isVisible({ timeout: 2_000 }).catch(() => false); - - if (pickerVisible) { - // Press 'r' for architecture - await page.keyboard.press("r"); - - // Wait for architecture view to render - const archExplorer = page.locator(".architecture-explorer"); - const visible = await archExplorer.isVisible({ timeout: 3_000 }).catch(() => false); - - if (visible) { - // Check diagram pane exists - await expect(page.locator(".diagram-pane")).toBeVisible(); - // Check inspector rail exists - await expect(page.locator(".inspector-rail")).toBeVisible(); - // Check generate button exists - await expect(page.locator(".generate-action")).toBeVisible(); - - // Switch back to development - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - const pickerAgain = page.locator(".picker"); - if (await pickerAgain.isVisible({ timeout: 1_000 }).catch(() => false)) { - await page.keyboard.press("d"); - } - } - } - }); -}); - -test.describe("Infrastructure mode empty state", () => { - test("infrastructure view shows empty state", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to infrastructure mode - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - - const picker = page.locator(".picker"); - const pickerVisible = await picker.isVisible({ timeout: 2_000 }).catch(() => false); - - if (pickerVisible) { - await page.keyboard.press("i"); - - // Infrastructure empty state (two-line pattern) - const emptyTitle = page.locator(".empty-state .empty-title"); - const visible = await emptyTitle.isVisible({ timeout: 3_000 }).catch(() => false); - - if (visible) { - await expect(emptyTitle).toHaveText("No services deployed yet"); - await expect(page.locator(".empty-state .empty-hint")).toContainText( - "press" - ); - } - - // Switch back - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - if (await page.locator(".picker").isVisible({ timeout: 1_000 }).catch(() => false)) { - await page.keyboard.press("d"); - } - } - }); -}); - -test.describe("Notes mode", () => { - test("notes mode shows Notes header and editor area", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Switch to notes mode - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - - const picker = page.locator(".picker"); - const pickerVisible = await picker.isVisible({ timeout: 2_000 }).catch(() => false); - - if (pickerVisible) { - await page.keyboard.press("n"); - - // Sidebar header should now say "Notes" - await expect(page.locator(".sidebar-header h2")).toHaveText("Notes", { - timeout: 3_000, - }); - - // Notes editor should be visible - const notesEditor = page.locator(".notes-editor"); - const visible = await notesEditor.isVisible({ timeout: 3_000 }).catch(() => false); - - if (visible) { - // Should show empty state if no note selected - const emptyTitle = notesEditor.locator(".empty-title"); - const hasEmpty = await emptyTitle.isVisible().catch(() => false); - if (hasEmpty) { - await expect(emptyTitle).toHaveText("No note selected"); - } - } - - // Switch back - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: " ", code: "Space" })); - }); - if (await page.locator(".picker").isVisible({ timeout: 1_000 }).catch(() => false)) { - await page.keyboard.press("d"); - } - } - }); -}); - test.describe("Agents mode", () => { test("agents mode shows Agents header", async ({ page }) => { await page.goto("/"); diff --git a/e2e/specs/visual-audit.spec.ts b/e2e/specs/visual-audit.spec.ts deleted file mode 100644 index a95fdfb3..00000000 --- a/e2e/specs/visual-audit.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from "@playwright/test"; -import path from "node:path"; - -const SCREENSHOT_DIR = path.resolve("e2e/results/visual-audit"); - -test.describe("Visual audit — screenshots of every workspace mode", () => { - test("capture all modes", async ({ page }) => { - await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // 1. Development mode (default) - await page.waitForTimeout(500); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-development.png"), fullPage: false }); - - // 2. Open help modal - await page.locator(".btn-help").click(); - await expect(page.locator(".modal")).toBeVisible({ timeout: 3_000 }); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-help-modal.png"), fullPage: false }); - await page.keyboard.press("Escape"); - await expect(page.locator(".modal")).not.toBeVisible(); - - // 3. Open workspace mode picker - await page.keyboard.press("Space"); - const picker = page.locator(".picker"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-workspace-picker.png"), fullPage: false }); - - // 4. Switch to Agents - await page.keyboard.press("a"); - await expect(page.locator(".sidebar-header h2")).toHaveText("Agents", { timeout: 3_000 }); - await page.waitForTimeout(300); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-agents.png"), fullPage: false }); - - // 5. Switch to Architecture - await page.keyboard.press("Space"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.keyboard.press("r"); - await page.waitForTimeout(300); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "05-architecture.png"), fullPage: false }); - } - - // 6. Switch to Notes - await page.keyboard.press("Space"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.keyboard.press("n"); - await expect(page.locator(".sidebar-header h2")).toHaveText("Notes", { timeout: 3_000 }); - await page.waitForTimeout(300); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "06-notes.png"), fullPage: false }); - } - - // 7. Switch to Infrastructure - await page.keyboard.press("Space"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.keyboard.press("i"); - await page.waitForTimeout(300); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "07-infrastructure.png"), fullPage: false }); - } - - // 8. Switch to Voice (no sidebar) - await page.keyboard.press("Space"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.keyboard.press("v"); - await page.waitForTimeout(500); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "08-voice.png"), fullPage: false }); - } - - // 9. Switch back to Development - await page.keyboard.press("Space"); - if (await picker.isVisible({ timeout: 2_000 }).catch(() => false)) { - await page.keyboard.press("d"); - await page.waitForTimeout(300); - await page.screenshot({ path: path.join(SCREENSHOT_DIR, "09-development-returned.png"), fullPage: false }); - } - } - }); -}); diff --git a/e2e/specs/voice-mode-state.spec.ts b/e2e/specs/voice-mode-state.spec.ts deleted file mode 100644 index 192a2342..00000000 --- a/e2e/specs/voice-mode-state.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { test, expect } from "@playwright/test"; - -// User story: -// Actor: A user in voice mode -// Action: Navigates away (Space → d) and back (Space → v) -// Outcome: The label shows the current voice state (not the generic "voice mode") - -test("voice mode label reflects state after re-entry", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); - - // Enter voice mode: Space → v - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("v"); - - // The voice mode view should mount and show a label - const label = page.locator(".voice-mode .label"); - await expect(label).toBeVisible({ timeout: 5_000 }); - - // Wait for the label to update from the initial "voice mode" to an actual state - // (pipeline emits paused/listening on success, or error on failure) - await expect(label).not.toHaveText("voice mode", { timeout: 10_000 }); - const firstVisitLabel = await label.textContent(); - - // Navigate away to development mode: Space → d - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("d"); - - // Sidebar should be visible again (voice mode hides it) - await expect(page.locator(".sidebar")).toBeVisible({ timeout: 5_000 }); - - // Navigate back to voice mode: Space → v - await page.keyboard.press("Space"); - await page.waitForTimeout(300); - await page.keyboard.press("v"); - - // Label should be visible again - await expect(label).toBeVisible({ timeout: 5_000 }); - - // CORE ASSERTION: the label must NOT be the generic "voice mode" — - // it should show the actual pipeline state (paused, listening, or error) - await expect(label).not.toHaveText("voice mode", { timeout: 10_000 }); -}); diff --git a/src-tauri/src/bin/server.rs b/src-tauri/src/bin/server.rs index c47906aa..d3222fe7 100644 --- a/src-tauri/src/bin/server.rs +++ b/src-tauri/src/bin/server.rs @@ -10,7 +10,7 @@ use axum::{ }; use serde_json::Value; use std::sync::Arc; -use the_controller_lib::{config, emitter::WsBroadcastEmitter, state::AppState}; +use the_controller_lib::{commands, config, emitter::WsBroadcastEmitter, state::AppState}; use tokio::sync::broadcast; use tower_http::cors::CorsLayer; @@ -36,6 +36,8 @@ async fn main() { .route("/api/restore_sessions", post(restore_sessions)) .route("/api/connect_session", post(connect_session)) .route("/api/load_project", post(load_project)) + .route("/api/create_project", post(create_project)) + .route("/api/delete_project", post(delete_project)) .route("/api/write_to_pty", post(write_to_pty)) .route("/api/send_raw_to_pty", post(send_raw_to_pty)) .route("/api/resize_pty", post(resize_pty)) @@ -43,6 +45,68 @@ async fn main() { .route("/api/create_session", post(create_session)) .route("/api/list_archived_projects", post(list_archived_projects)) .route("/api/merge_session_branch", post(merge_session_branch)) + .route("/api/read_daemon_token", post(read_daemon_token)) + .route("/api/log_frontend_error", post(log_frontend_error)) + .route("/api/get_agents_md", post(get_agents_md)) + .route("/api/update_agents_md", post(update_agents_md)) + .route("/api/set_initial_prompt", post(set_initial_prompt)) + .route("/api/save_session_prompt", post(save_session_prompt)) + .route("/api/list_project_prompts", post(list_project_prompts)) + .route("/api/get_session_commits", post(get_session_commits)) + .route( + "/api/get_session_token_usage", + post(get_session_token_usage), + ) + .route("/api/get_repo_head", post(get_repo_head)) + .route("/api/save_onboarding_config", post(save_onboarding_config)) + .route("/api/home_dir", post(home_dir)) + .route("/api/list_directories_at", post(list_directories_at)) + .route("/api/list_root_directories", post(list_root_directories)) + .route("/api/check_claude_cli", post(check_claude_cli)) + .route("/api/generate_project_names", post(generate_project_names)) + .route("/api/list_github_issues", post(list_github_issues)) + .route("/api/create_github_issue", post(create_github_issue)) + .route("/api/close_github_issue", post(close_github_issue)) + .route("/api/delete_github_issue", post(delete_github_issue)) + .route("/api/post_github_comment", post(post_github_comment)) + .route("/api/add_github_label", post(add_github_label)) + .route("/api/remove_github_label", post(remove_github_label)) + .route("/api/generate_issue_body", post(generate_issue_body)) + .route("/api/list_assigned_issues", post(list_assigned_issues)) + .route("/api/kanban_load_order", post(kanban_load_order)) + .route("/api/kanban_save_order", post(kanban_save_order)) + .route("/api/configure_maintainer", post(configure_maintainer)) + .route("/api/configure_auto_worker", post(configure_auto_worker)) + .route("/api/get_worker_reports", post(get_worker_reports)) + .route("/api/get_auto_worker_queue", post(get_auto_worker_queue)) + .route("/api/get_maintainer_status", post(get_maintainer_status)) + .route("/api/get_maintainer_history", post(get_maintainer_history)) + .route( + "/api/trigger_maintainer_check", + post(trigger_maintainer_check), + ) + .route( + "/api/clear_maintainer_reports", + post(clear_maintainer_reports), + ) + .route("/api/get_maintainer_issues", post(get_maintainer_issues)) + .route( + "/api/get_maintainer_issue_detail", + post(get_maintainer_issue_detail), + ) + .route( + "/api/submit_secure_env_value", + post(submit_secure_env_value), + ) + .route( + "/api/cancel_secure_env_request", + post(cancel_secure_env_request), + ) + .route("/api/start_claude_login", post(start_claude_login)) + .route("/api/stop_claude_login", post(stop_claude_login)) + .route("/api/scaffold_project", post(scaffold_project)) + .route("/api/stage_session", post(stage_session)) + .route("/api/unstage_session", post(unstage_session)) .route("/ws", get(ws_upgrade)) .fallback(fallback_handler) .layer(CorsLayer::permissive()) @@ -198,20 +262,80 @@ async fn load_project( AxumState(state): AxumState>, Json(args): Json, ) -> Result, (StatusCode, String)> { - let project_id = args["projectId"].as_str().unwrap_or_default(); - let id = - uuid::Uuid::parse_str(project_id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let storage = state - .app - .storage - .lock() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let project = storage - .load_project(id) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let name = args["name"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "name required".to_string()))? + .to_string(); + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let state = state.clone(); + let project = tokio::task::spawn_blocking(move || { + commands::load_project_impl(&state.app, name, repo_path) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(project).unwrap())) +} + +async fn create_project( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let name = args["name"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "name required".to_string()))? + .to_string(); + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let state = state.clone(); + let project = tokio::task::spawn_blocking(move || { + commands::create_project_impl(&state.app, name, repo_path) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(serde_json::to_value(project).unwrap())) } +async fn delete_project( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let delete_repo = args["deleteRepo"].as_bool().unwrap_or(false); + let state = state.clone(); + tokio::task::spawn_blocking(move || { + commands::delete_project_impl(&state.app, project_id, delete_repo) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + async fn write_to_pty( AxumState(state): AxumState>, Json(args): Json, @@ -271,26 +395,84 @@ async fn close_session( AxumState(state): AxumState>, Json(args): Json, ) -> Result, (StatusCode, String)> { - let session_id = args["sessionId"].as_str().unwrap_or_default(); - let id = - uuid::Uuid::parse_str(session_id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; - let mut pty = state - .app - .pty_manager - .lock() - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let _ = pty.close_session(id); + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + let delete_worktree = args["deleteWorktree"].as_bool().unwrap_or(false); + let state = state.clone(); + tokio::task::spawn_blocking(move || { + commands::close_session_impl(&state.app, project_id, session_id, delete_worktree) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(Value::Null)) } async fn create_session( - AxumState(_state): AxumState>, - Json(_args): Json, + AxumState(state): AxumState>, + Json(args): Json, ) -> Result, (StatusCode, String)> { - Err(( - StatusCode::NOT_IMPLEMENTED, - "create_session not yet wired".to_string(), - )) + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let kind = args["kind"].as_str().map(str::to_string); + let background = args["background"].as_bool(); + let initial_prompt = args["initialPrompt"].as_str().map(str::to_string); + let github_issue = if args["githubIssue"].is_null() { + None + } else { + Some( + serde_json::from_value(args["githubIssue"].clone()) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("githubIssue: {}", e)))?, + ) + }; + + let state = state.clone(); + let session_id = tokio::task::spawn_blocking(move || { + commands::create_session_impl( + &state.app, + project_id, + kind, + github_issue, + background, + initial_prompt, + ) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + Ok(Json(Value::String(session_id))) +} + +async fn read_daemon_token() -> Result, (StatusCode, String)> { + let token = commands::daemon::read_daemon_token() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::String(token))) +} + +async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { + let message = args["message"].as_str().unwrap_or(""); + eprintln!("[FRONTEND] {}", message); + Ok(Json(Value::Null)) } async fn list_archived_projects( @@ -431,6 +613,708 @@ async fn merge_session_branch( )) } +async fn get_agents_md( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let content = commands::get_agents_md_impl(&state.app, project_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::String(content))) +} + +async fn update_agents_md( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let content = args["content"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "content required".to_string()))? + .to_string(); + commands::update_agents_md_impl(&state.app, project_id, content) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn set_initial_prompt( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + let prompt = args["prompt"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "prompt required".to_string()))? + .to_string(); + commands::set_initial_prompt_impl(&state.app, project_id, session_id, prompt) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn save_session_prompt( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + commands::save_session_prompt_impl(&state.app, project_id, session_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn list_project_prompts( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let prompts = commands::list_project_prompts_impl(&state.app, project_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(prompts).unwrap())) +} + +async fn get_session_commits( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + let state = state.clone(); + let commits = tokio::task::spawn_blocking(move || { + commands::get_session_commits_impl(&state.app, project_id, session_id) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(commits).unwrap())) +} + +async fn get_session_token_usage( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + let usage = commands::get_session_token_usage_impl(&state.app, project_id, session_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(usage).unwrap())) +} + +async fn get_repo_head(Json(args): Json) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let (branch, short_hash) = + tokio::task::spawn_blocking(move || commands::get_repo_head_impl(repo_path)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::json!([branch, short_hash]))) +} + +async fn save_onboarding_config( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let projects_root = args["projectsRoot"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectsRoot required".to_string()))? + .to_string(); + commands::save_onboarding_config_impl(&state.app, projects_root) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn home_dir() -> Result, (StatusCode, String)> { + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not determine home directory".to_string(), + ) + })?; + Ok(Json(Value::String(home))) +} + +async fn list_directories_at(Json(args): Json) -> Result, (StatusCode, String)> { + let path = args["path"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "path required".to_string()))? + .to_string(); + let p = std::path::PathBuf::from(&path); + if !p.is_dir() { + return Err(( + StatusCode::BAD_REQUEST, + format!("Not a directory: {}", path), + )); + } + let entries = config::list_directories(&p) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json(serde_json::to_value(entries).unwrap())) +} + +async fn list_root_directories( + AxumState(state): AxumState>, +) -> Result, (StatusCode, String)> { + let entries = commands::list_root_directories_impl(&state.app) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(entries).unwrap())) +} + +async fn check_claude_cli() -> Result, (StatusCode, String)> { + let result = tokio::task::spawn_blocking(config::check_claude_cli_status) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })?; + Ok(Json(Value::String(result))) +} + +async fn generate_project_names( + Json(args): Json, +) -> Result, (StatusCode, String)> { + let description = args["description"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "description required".to_string()))? + .to_string(); + let names = tokio::task::spawn_blocking(move || config::generate_names_via_cli(&description)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(names).unwrap())) +} + +// --- GitHub --- + +async fn list_github_issues( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issues = commands::github::list_github_issues(repo_path, &state.app) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(issues).unwrap())) +} + +async fn create_github_issue( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let title = args["title"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "title required".to_string()))? + .to_string(); + let body = args["body"].as_str().unwrap_or("").to_string(); + let issue = commands::github::create_github_issue(&state.app, repo_path, title, body) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(issue).unwrap())) +} + +async fn close_github_issue( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))?; + let comment = args["comment"].as_str().unwrap_or("").to_string(); + commands::github::close_github_issue(&state.app, repo_path, issue_number, comment) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn delete_github_issue( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))?; + commands::github::delete_github_issue(&state.app, repo_path, issue_number) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn post_github_comment(Json(args): Json) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))?; + let body = args["body"].as_str().unwrap_or("").to_string(); + commands::github::post_github_comment(repo_path, issue_number, body) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn add_github_label( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))?; + let label = args["label"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "label required".to_string()))? + .to_string(); + let description = args["description"].as_str().map(str::to_string); + let color = args["color"].as_str().map(str::to_string); + commands::github::add_github_label( + &state.app, + repo_path, + issue_number, + label, + description, + color, + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn remove_github_label( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))?; + let label = args["label"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "label required".to_string()))? + .to_string(); + commands::github::remove_github_label(&state.app, repo_path, issue_number, label) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn generate_issue_body(Json(args): Json) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let title = args["title"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "title required".to_string()))? + .to_string(); + let body = commands::github::generate_issue_body(repo_path, title) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::String(body))) +} + +async fn list_assigned_issues( + Json(args): Json, +) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let issues = commands::github::list_assigned_issues(repo_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(issues).unwrap())) +} + +// --- Kanban --- + +fn kanban_order_file(state: &AppState) -> Result { + let storage = state + .storage + .lock() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(commands::kanban::order_file_in(&storage.base_dir())) +} + +async fn kanban_load_order( + AxumState(state): AxumState>, +) -> Result, (StatusCode, String)> { + let path = kanban_order_file(&state.app)?; + let order = tokio::task::spawn_blocking(move || commands::kanban::load_order_from(&path)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(order)) +} + +async fn kanban_save_order( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let order = args["order"].clone(); + let path = kanban_order_file(&state.app)?; + tokio::task::spawn_blocking(move || commands::kanban::save_order_to(&path, &order)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +// --- Maintainer / auto-worker --- + +async fn configure_maintainer( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let enabled = args["enabled"].as_bool().unwrap_or(false); + let interval_minutes = args["intervalMinutes"].as_u64().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + "intervalMinutes required".to_string(), + ) + })?; + let github_repo = args["githubRepo"].as_str().map(str::to_string); + commands::configure_maintainer_impl( + &state.app, + project_id, + enabled, + interval_minutes, + github_repo, + ) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn configure_auto_worker( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let enabled = args["enabled"].as_bool().unwrap_or(false); + commands::configure_auto_worker_impl(&state.app, project_id, enabled) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn get_worker_reports(Json(args): Json) -> Result, (StatusCode, String)> { + let repo_path = args["repoPath"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "repoPath required".to_string()))? + .to_string(); + let reports = commands::github::get_worker_reports(repo_path) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(reports).unwrap())) +} + +async fn get_auto_worker_queue( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let queue = commands::get_auto_worker_queue_impl(&state.app, project_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(queue).unwrap())) +} + +async fn get_maintainer_status( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let status = commands::get_maintainer_status_impl(&state.app, project_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(status).unwrap())) +} + +async fn get_maintainer_history( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let history = commands::get_maintainer_history_impl(&state.app, project_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(history).unwrap())) +} + +async fn trigger_maintainer_check( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let log = commands::trigger_maintainer_check_impl(&state.app, project_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(log).unwrap())) +} + +async fn clear_maintainer_reports( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + commands::clear_maintainer_reports_impl(&state.app, project_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn get_maintainer_issues( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let issues = commands::get_maintainer_issues_impl(&state.app, project_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(issues).unwrap())) +} + +async fn get_maintainer_issue_detail( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let issue_number = args["issueNumber"] + .as_u64() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "issueNumber required".to_string()))? + as u32; + let detail = commands::get_maintainer_issue_detail_impl(&state.app, project_id, issue_number) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(detail).unwrap())) +} + +// --- Secure env / Claude login / scaffold / stage --- + +async fn submit_secure_env_value( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let request_id = args["requestId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "requestId required".to_string()))? + .to_string(); + let value = args["value"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "value required".to_string()))? + .to_string(); + let state = state.clone(); + let status = tokio::task::spawn_blocking(move || { + the_controller_lib::secure_env::submit_secure_env_value_status( + &state.app, + &request_id, + &value, + ) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {}", e), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::String(status))) +} + +async fn cancel_secure_env_request( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let request_id = args["requestId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "requestId required".to_string()))?; + the_controller_lib::secure_env::cancel_secure_env_request(&state.app, request_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn start_claude_login( + AxumState(state): AxumState>, +) -> Result, (StatusCode, String)> { + let session_id = commands::start_claude_login_impl(&state.app) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::String(session_id))) +} + +async fn stop_claude_login( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + commands::stop_claude_login_impl(&state.app, session_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn scaffold_project( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let name = args["name"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "name required".to_string()))? + .to_string(); + let project = commands::scaffold_project_impl(&state.app, name) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(serde_json::to_value(project).unwrap())) +} + +async fn stage_session( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))?; + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))?; + let project_uuid = + uuid::Uuid::parse_str(project_id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let session_uuid = + uuid::Uuid::parse_str(session_id).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + commands::stage_session_core(&state.app, project_uuid, session_uuid, true) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + +async fn unstage_session( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let session_id = args["sessionId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "sessionId required".to_string()))? + .to_string(); + commands::unstage_session_impl(&state.app, project_id, session_id) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + Ok(Json(Value::Null)) +} + // --- WebSocket --- async fn ws_upgrade( diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c4758cbe..d248d143 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,9 +10,9 @@ use crate::storage::ProjectInventory; use crate::token_usage::{self, TokenDataPoint}; use crate::worktree::WorktreeManager; -mod daemon; -mod github; -mod kanban; +pub mod daemon; +pub mod github; +pub mod kanban; mod media; /// Create a `CLAUDE.md` symlink pointing to `agents.md` in the given directory, @@ -410,9 +410,8 @@ pub async fn connect_session( .map_err(|e| format!("Task failed: {}", e))? } -#[tauri::command] -pub fn create_project( - state: State, +pub fn create_project_impl( + state: &AppState, name: String, repo_path: String, ) -> Result { @@ -425,7 +424,6 @@ pub fn create_project( let storage = state.storage.lock().map_err(|e| e.to_string())?; - // Reject duplicate project names. if let Ok(inventory) = storage.list_projects() { let existing = inventory.projects; if existing.iter().any(|p| p.name == name) { @@ -448,7 +446,6 @@ pub fn create_project( storage.save_project(&project).map_err(|e| e.to_string())?; - // If repo doesn't have agents.md, create default one in config dir let repo_agents = path.join("agents.md"); if !repo_agents.exists() { storage @@ -456,17 +453,24 @@ pub fn create_project( .map_err(|e| e.to_string())?; } - // If repo has agents.md but no CLAUDE.md, create symlink ensure_claude_md_symlink(path)?; Ok(project) } #[tauri::command] -pub fn load_project( +pub fn create_project( state: State, name: String, repo_path: String, +) -> Result { + create_project_impl(&state, name, repo_path) +} + +pub fn load_project_impl( + state: &AppState, + name: String, + repo_path: String, ) -> Result { validate_project_name(&name)?; @@ -475,7 +479,6 @@ pub fn load_project( return Err(format!("repo_path is not a directory: {}", repo_path)); } - // Validate it's a git repo let git_dir = path.join(".git"); if !git_dir.exists() { return Err(format!("not a git repository: {}", repo_path)); @@ -483,13 +486,11 @@ pub fn load_project( let storage = state.storage.lock().map_err(|e| e.to_string())?; - // Return existing project if one with the same repo_path exists if let Ok(inventory) = storage.list_projects() { let existing = inventory.projects; if let Some(project) = existing.iter().find(|p| p.repo_path == repo_path) { return Ok(project.clone()); } - // Reject duplicate project names when creating new. if existing.iter().any(|p| p.name == name) { return Err(format!("A project named '{}' already exists", name)); } @@ -510,7 +511,6 @@ pub fn load_project( storage.save_project(&project).map_err(|e| e.to_string())?; - // Only create default agents.md if repo doesn't have one let repo_agents = path.join("agents.md"); if !repo_agents.exists() { storage @@ -518,12 +518,20 @@ pub fn load_project( .map_err(|e| e.to_string())?; } - // If repo has agents.md but no CLAUDE.md, create symlink ensure_claude_md_symlink(path)?; Ok(project) } +#[tauri::command] +pub fn load_project( + state: State, + name: String, + repo_path: String, +) -> Result { + load_project_impl(&state, name, repo_path) +} + #[tauri::command] pub fn list_projects(state: State) -> Result { let storage = state.storage.lock().map_err(|e| e.to_string())?; @@ -531,9 +539,8 @@ pub fn list_projects(state: State) -> Result Ok(inventory) } -#[tauri::command] -pub fn delete_project( - state: State, +pub fn delete_project_impl( + state: &AppState, project_id: String, delete_repo: bool, ) -> Result<(), String> { @@ -542,7 +549,6 @@ pub fn delete_project( let storage = state.storage.lock().map_err(|e| e.to_string())?; let project = storage.load_project(id).map_err(|e| e.to_string())?; - // Close all PTY sessions and clean up worktrees { let mut pty_manager = state.pty_manager.lock().map_err(|e| e.to_string())?; for session in &project.sessions { @@ -555,10 +561,8 @@ pub fn delete_project( } } - // Delete project metadata from ~/.the-controller/projects/{id}/ storage.delete_project_dir(id).map_err(|e| e.to_string())?; - // Optionally delete the repo directory if delete_repo && Path::new(&project.repo_path).exists() { std::fs::remove_dir_all(&project.repo_path) .map_err(|e| format!("failed to delete repo: {}", e))?; @@ -568,7 +572,15 @@ pub fn delete_project( } #[tauri::command] -pub fn get_agents_md(state: State, project_id: String) -> Result { +pub fn delete_project( + state: State, + project_id: String, + delete_repo: bool, +) -> Result<(), String> { + delete_project_impl(&state, project_id, delete_repo) +} + +pub fn get_agents_md_impl(state: &AppState, project_id: String) -> Result { let id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; let project = storage.load_project(id).map_err(|e| e.to_string())?; @@ -576,8 +588,12 @@ pub fn get_agents_md(state: State, project_id: String) -> Result, +pub fn get_agents_md(state: State, project_id: String) -> Result { + get_agents_md_impl(&state, project_id) +} + +pub fn update_agents_md_impl( + state: &AppState, project_id: String, content: String, ) -> Result<(), String> { @@ -589,9 +605,20 @@ pub fn update_agents_md( } #[tauri::command] -pub fn create_session( +pub fn update_agents_md( state: State, - _app_handle: AppHandle, + project_id: String, + content: String, +) -> Result<(), String> { + update_agents_md_impl(&state, project_id, content) +} + +/// Transport-agnostic `create_session` implementation. +/// +/// Why: both the Tauri command wrapper and the axum server handler share this +/// body so the web and desktop frontends see identical behavior. +pub fn create_session_impl( + state: &AppState, project_id: String, kind: Option, github_issue: Option, @@ -603,7 +630,6 @@ pub fn create_session( let project_uuid = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let session_id = Uuid::new_v4(); - // Load the project and generate session label let (repo_path, label, base_dir, project_name) = { let storage = state.storage.lock().map_err(|e| e.to_string())?; let project = storage @@ -618,10 +644,8 @@ pub fn create_session( ) }; - // Create worktree under ~/.the-controller/worktrees/{project_name}/{label}/ let worktree_dir = base_dir.join("worktrees").join(&project_name).join(&label); - // Try to create a worktree; fall back to repo path for repos without commits let (session_dir, wt_path, wt_branch) = match WorktreeManager::create_worktree(&repo_path, &label, &worktree_dir) { Ok(worktree_path) => { @@ -631,14 +655,10 @@ pub fn create_session( .to_string(); (wt_str.clone(), Some(wt_str), Some(label.clone())) } - Err(e) if e == "unborn_branch" => { - // Repo has no commits — use repo path directly, no worktree - (repo_path.clone(), None, None) - } + Err(e) if e == "unborn_branch" => (repo_path.clone(), None, None), Err(e) => return Err(e), }; - // Build initial prompt: explicit prompt takes priority, then GitHub issue context let initial_prompt = initial_prompt.or_else(|| { github_issue.as_ref().map(|issue| { crate::session_args::build_issue_prompt( @@ -665,7 +685,7 @@ pub fn create_session( }; update_project_with_rollback( - &state, + state, project_uuid, |project| { project.sessions.push(session_config); @@ -705,6 +725,26 @@ pub fn create_session( Ok(session_id.to_string()) } +#[tauri::command] +pub fn create_session( + state: State, + _app_handle: AppHandle, + project_id: String, + kind: Option, + github_issue: Option, + background: Option, + initial_prompt: Option, +) -> Result { + create_session_impl( + &state, + project_id, + kind, + github_issue, + background, + initial_prompt, + ) +} + #[tauri::command] pub fn write_to_pty( state: State, @@ -739,9 +779,8 @@ pub fn resize_pty( pty_manager.resize_session(id, rows, cols) } -#[tauri::command] -pub fn set_initial_prompt( - state: State, +pub fn set_initial_prompt_impl( + state: &AppState, project_id: String, session_id: String, prompt: String, @@ -764,6 +803,16 @@ pub fn set_initial_prompt( Ok(()) } +#[tauri::command] +pub fn set_initial_prompt( + state: State, + project_id: String, + session_id: String, + prompt: String, +) -> Result<(), String> { + set_initial_prompt_impl(&state, project_id, session_id, prompt) +} + #[tauri::command] pub async fn submit_secure_env_value( state: State<'_, AppState>, @@ -851,7 +900,7 @@ pub(crate) fn kill_process_group(pid: u32) { /// rebase conflicts are handled by prompting the session's Claude via PTY. /// When false (socket path), these conditions return an error immediately — /// the caller is expected to commit and resolve conflicts before staging. -pub(crate) async fn stage_session_core( +pub async fn stage_session_core( state: &AppState, project_id: Uuid, session_id: Uuid, @@ -1129,9 +1178,8 @@ pub async fn stage_session( Ok(()) } -#[tauri::command] -pub fn unstage_session( - state: State, +pub fn unstage_session_impl( + state: &AppState, project_id: String, session_id: String, ) -> Result<(), String> { @@ -1151,10 +1199,8 @@ pub fn unstage_session( let staged = project.staged_sessions.remove(idx); - // Kill the staged Controller process group kill_process_group(staged.pid); - // Clean up this session's socket let socket = crate::status_socket::staged_socket_path(&session_uuid); let _ = std::fs::remove_file(&socket); @@ -1163,7 +1209,15 @@ pub fn unstage_session( } #[tauri::command] -pub fn get_repo_head(repo_path: String) -> Result<(String, String), String> { +pub fn unstage_session( + state: State, + project_id: String, + session_id: String, +) -> Result<(), String> { + unstage_session_impl(&state, project_id, session_id) +} + +pub fn get_repo_head_impl(repo_path: String) -> Result<(String, String), String> { let repo = git2::Repository::open(&repo_path).map_err(|e| format!("Failed to open repo: {}", e))?; @@ -1181,8 +1235,12 @@ pub fn get_repo_head(repo_path: String) -> Result<(String, String), String> { } #[tauri::command] -pub fn save_session_prompt( - state: State, +pub fn get_repo_head(repo_path: String) -> Result<(String, String), String> { + get_repo_head_impl(repo_path) +} + +pub fn save_session_prompt_impl( + state: &AppState, project_id: String, session_id: String, ) -> Result<(), String> { @@ -1200,7 +1258,6 @@ pub fn save_session_prompt( .find(|s| s.id == session_uuid) .ok_or_else(|| "Session not found".to_string())?; - // Build prompt text: use initial_prompt, or derive from github_issue let prompt_text = session .initial_prompt .clone() @@ -1216,7 +1273,6 @@ pub fn save_session_prompt( }) .ok_or_else(|| "Session has no prompt to save".to_string())?; - // Auto-generate name: first ~60 chars (safe for multi-byte UTF-8) let name = { let truncated: String = prompt_text.chars().take(60).collect(); if truncated.len() < prompt_text.len() { @@ -1241,9 +1297,17 @@ pub fn save_session_prompt( } #[tauri::command] -pub fn list_project_prompts( +pub fn save_session_prompt( state: State, project_id: String, + session_id: String, +) -> Result<(), String> { + save_session_prompt_impl(&state, project_id, session_id) +} + +pub fn list_project_prompts_impl( + state: &AppState, + project_id: String, ) -> Result, String> { let project_uuid = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; @@ -1254,22 +1318,27 @@ pub fn list_project_prompts( } #[tauri::command] -pub fn close_session( +pub fn list_project_prompts( state: State, project_id: String, +) -> Result, String> { + list_project_prompts_impl(&state, project_id) +} + +pub fn close_session_impl( + state: &AppState, + project_id: String, session_id: String, delete_worktree: bool, ) -> Result<(), String> { let project_uuid = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let session_uuid = Uuid::parse_str(&session_id).map_err(|e| e.to_string())?; - // Try to close the PTY session even if the terminal is already gone. { let mut pty_manager = state.pty_manager.lock().map_err(|e| e.to_string())?; let _ = pty_manager.close_session(session_uuid); } - // Remove session from project let storage = state.storage.lock().map_err(|e| e.to_string())?; let mut project = storage .load_project(project_uuid) @@ -1283,7 +1352,6 @@ pub fn close_session( project.sessions.retain(|s| s.id != session_uuid); storage.save_project(&project).map_err(|e| e.to_string())?; - // Optionally clean up worktree if delete_worktree { if let Some(session) = session { if let (Some(wt_path), Some(branch)) = (session.worktree_path, session.worktree_branch) @@ -1297,10 +1365,16 @@ pub fn close_session( } #[tauri::command] -pub fn start_claude_login( +pub fn close_session( state: State, - _app_handle: AppHandle, -) -> Result { + project_id: String, + session_id: String, + delete_worktree: bool, +) -> Result<(), String> { + close_session_impl(&state, project_id, session_id, delete_worktree) +} + +pub fn start_claude_login_impl(state: &AppState) -> Result { let session_id = Uuid::new_v4(); let mut pty_manager = state.pty_manager.lock().map_err(|e| e.to_string())?; pty_manager.spawn_command(session_id, "claude", &["login"], state.emitter.clone())?; @@ -1308,12 +1382,24 @@ pub fn start_claude_login( } #[tauri::command] -pub fn stop_claude_login(state: State, session_id: String) -> Result<(), String> { +pub fn start_claude_login( + state: State, + _app_handle: AppHandle, +) -> Result { + start_claude_login_impl(&state) +} + +pub fn stop_claude_login_impl(state: &AppState, session_id: String) -> Result<(), String> { let id = Uuid::parse_str(&session_id).map_err(|e| e.to_string())?; let mut pty_manager = state.pty_manager.lock().map_err(|e| e.to_string())?; pty_manager.close_session(id) } +#[tauri::command] +pub fn stop_claude_login(state: State, session_id: String) -> Result<(), String> { + stop_claude_login_impl(&state, session_id) +} + #[tauri::command] pub fn home_dir() -> Result { dirs::home_dir() @@ -1328,8 +1414,7 @@ pub fn check_onboarding(state: State) -> Result Ok(config::load_config(&base_dir)) } -#[tauri::command] -pub fn save_onboarding_config(state: State, projects_root: String) -> Result<(), String> { +pub fn save_onboarding_config_impl(state: &AppState, projects_root: String) -> Result<(), String> { let path = Path::new(&projects_root); if !path.is_dir() { return Err(format!( @@ -1344,6 +1429,11 @@ pub fn save_onboarding_config(state: State, projects_root: String) -> config::save_config(&base_dir, &cfg).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn save_onboarding_config(state: State, projects_root: String) -> Result<(), String> { + save_onboarding_config_impl(&state, projects_root) +} + #[tauri::command] pub async fn check_claude_cli() -> Result { let result = tokio::task::spawn_blocking(config::check_claude_cli_status) @@ -1361,8 +1451,7 @@ pub fn list_directories_at(path: String) -> Result, String config::list_directories(p).map_err(|e| e.to_string()) } -#[tauri::command] -pub fn list_root_directories(state: State) -> Result, String> { +pub fn list_root_directories_impl(state: &AppState) -> Result, String> { let storage = state.storage.lock().map_err(|e| e.to_string())?; let base_dir = storage.base_dir(); let cfg = config::load_config(&base_dir) @@ -1370,19 +1459,22 @@ pub fn list_root_directories(state: State) -> Result) -> Result, String> { + list_root_directories_impl(&state) +} + #[tauri::command] pub fn generate_project_names(description: String) -> Result, String> { config::generate_names_via_cli(&description) } -#[tauri::command] -pub async fn scaffold_project(state: State<'_, AppState>, name: String) -> Result { +pub async fn scaffold_project_impl(state: &AppState, name: String) -> Result { validate_project_name(&name)?; let repo_path = { let storage = state.storage.lock().map_err(|e| e.to_string())?; - // Reject duplicate project names. if let Ok(inventory) = storage.list_projects() { let existing = inventory.projects; if existing.iter().any(|p| p.name == name) { @@ -1419,12 +1511,17 @@ pub async fn scaffold_project(state: State<'_, AppState>, name: String) -> Resul Ok(project) } +#[tauri::command] +pub async fn scaffold_project(state: State<'_, AppState>, name: String) -> Result { + scaffold_project_impl(&state, name).await +} + #[tauri::command] pub async fn list_github_issues( repo_path: String, state: State<'_, AppState>, ) -> Result, String> { - github::list_github_issues(repo_path, state).await + github::list_github_issues(repo_path, &state).await } #[tauri::command] @@ -1456,7 +1553,7 @@ pub async fn create_github_issue( title: String, body: String, ) -> Result { - github::create_github_issue(state, repo_path, title, body).await + github::create_github_issue(&state, repo_path, title, body).await } #[tauri::command] @@ -1466,7 +1563,7 @@ pub async fn close_github_issue( issue_number: u64, comment: String, ) -> Result<(), String> { - github::close_github_issue(state, repo_path, issue_number, comment).await + github::close_github_issue(&state, repo_path, issue_number, comment).await } #[tauri::command] @@ -1475,7 +1572,7 @@ pub async fn delete_github_issue( repo_path: String, issue_number: u64, ) -> Result<(), String> { - github::delete_github_issue(state, repo_path, issue_number).await + github::delete_github_issue(&state, repo_path, issue_number).await } #[tauri::command] @@ -1496,7 +1593,7 @@ pub async fn add_github_label( description: Option, color: Option, ) -> Result<(), String> { - github::add_github_label(state, repo_path, issue_number, label, description, color).await + github::add_github_label(&state, repo_path, issue_number, label, description, color).await } #[tauri::command] @@ -1506,7 +1603,7 @@ pub async fn remove_github_label( issue_number: u64, label: String, ) -> Result<(), String> { - github::remove_github_label(state, repo_path, issue_number, label).await + github::remove_github_label(&state, repo_path, issue_number, label).await } #[tauri::command] @@ -1623,9 +1720,8 @@ pub async fn merge_session_branch( )) } -#[tauri::command] -pub fn get_session_commits( - state: State, +pub fn get_session_commits_impl( + state: &AppState, project_id: String, session_id: String, ) -> Result, String> { @@ -1648,10 +1744,8 @@ pub fn get_session_commits( None => return Ok(session.done_commits.clone()), }; - // Discover new commits on the branch that aren't on main let new_commits = discover_branch_commits(&worktree_path).unwrap_or_default(); - // Merge with previously stored commits (new first, then stored, dedup by hash) let mut seen = std::collections::HashSet::new(); let mut all_commits = Vec::new(); for c in new_commits.iter().chain(session.done_commits.iter()) { @@ -1660,7 +1754,6 @@ pub fn get_session_commits( } } - // Persist if we found new commits if all_commits.len() > session.done_commits.len() { let mut project = project.clone(); if let Some(s) = project.sessions.iter_mut().find(|s| s.id == session_uuid) { @@ -1673,10 +1766,18 @@ pub fn get_session_commits( } #[tauri::command] -pub fn get_session_token_usage( +pub fn get_session_commits( state: State, project_id: String, session_id: String, +) -> Result, String> { + get_session_commits_impl(&state, project_id, session_id) +} + +pub fn get_session_token_usage_impl( + state: &AppState, + project_id: String, + session_id: String, ) -> Result, String> { let project_uuid = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let session_uuid = Uuid::parse_str(&session_id).map_err(|e| e.to_string())?; @@ -1700,6 +1801,15 @@ pub fn get_session_token_usage( token_usage::get_token_usage(working_dir, &session.kind) } +#[tauri::command] +pub fn get_session_token_usage( + state: State, + project_id: String, + session_id: String, +) -> Result, String> { + get_session_token_usage_impl(&state, project_id, session_id) +} + /// Walk commits on the worktree branch that aren't on the main branch. fn discover_branch_commits(worktree_path: &str) -> Result, String> { let repo = git2::Repository::discover(worktree_path) @@ -1751,9 +1861,8 @@ pub(crate) fn validate_maintainer_interval(minutes: u64) -> Result<(), String> { Ok(()) } -#[tauri::command] -pub async fn configure_maintainer( - state: State<'_, AppState>, +pub fn configure_maintainer_impl( + state: &AppState, project_id: String, enabled: bool, interval_minutes: u64, @@ -1773,10 +1882,20 @@ pub async fn configure_maintainer( } #[tauri::command] -pub async fn configure_auto_worker( +pub async fn configure_maintainer( state: State<'_, AppState>, project_id: String, enabled: bool, + interval_minutes: u64, + github_repo: Option, +) -> Result<(), String> { + configure_maintainer_impl(&state, project_id, enabled, interval_minutes, github_repo) +} + +pub fn configure_auto_worker_impl( + state: &AppState, + project_id: String, + enabled: bool, ) -> Result<(), String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; @@ -1788,6 +1907,15 @@ pub async fn configure_auto_worker( Ok(()) } +#[tauri::command] +pub async fn configure_auto_worker( + state: State<'_, AppState>, + project_id: String, + enabled: bool, +) -> Result<(), String> { + configure_auto_worker_impl(&state, project_id, enabled) +} + #[tauri::command] pub async fn get_worker_reports(repo_path: String) -> Result, String> { github::get_worker_reports(repo_path).await @@ -1834,9 +1962,8 @@ fn build_auto_worker_queue( queue } -#[tauri::command] -pub async fn get_auto_worker_queue( - state: State<'_, AppState>, +pub async fn get_auto_worker_queue_impl( + state: &AppState, project_id: String, ) -> Result, String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; @@ -1853,9 +1980,16 @@ pub async fn get_auto_worker_queue( } #[tauri::command] -pub async fn get_maintainer_status( +pub async fn get_auto_worker_queue( state: State<'_, AppState>, project_id: String, +) -> Result, String> { + get_auto_worker_queue_impl(&state, project_id).await +} + +pub fn get_maintainer_status_impl( + state: &AppState, + project_id: String, ) -> Result, String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; @@ -1865,9 +1999,16 @@ pub async fn get_maintainer_status( } #[tauri::command] -pub async fn get_maintainer_history( +pub async fn get_maintainer_status( state: State<'_, AppState>, project_id: String, +) -> Result, String> { + get_maintainer_status_impl(&state, project_id) +} + +pub fn get_maintainer_history_impl( + state: &AppState, + project_id: String, ) -> Result, String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; @@ -1876,6 +2017,14 @@ pub async fn get_maintainer_history( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_maintainer_history( + state: State<'_, AppState>, + project_id: String, +) -> Result, String> { + get_maintainer_history_impl(&state, project_id) +} + async fn run_maintainer_check_spawn_blocking_with( repo_path: String, project_id: Uuid, @@ -1908,10 +2057,8 @@ async fn run_maintainer_check_spawn_blocking( .await } -#[tauri::command] -pub async fn trigger_maintainer_check( - state: State<'_, AppState>, - _app_handle: AppHandle, +pub async fn trigger_maintainer_check_impl( + state: &AppState, project_id: String, ) -> Result { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; @@ -1959,11 +2106,15 @@ pub async fn trigger_maintainer_check( } #[tauri::command] -pub async fn clear_maintainer_reports( +pub async fn trigger_maintainer_check( state: State<'_, AppState>, _app_handle: AppHandle, project_id: String, -) -> Result<(), String> { +) -> Result { + trigger_maintainer_check_impl(&state, project_id).await +} + +pub fn clear_maintainer_reports_impl(state: &AppState, project_id: String) -> Result<(), String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; let storage = state.storage.lock().map_err(|e| e.to_string())?; storage @@ -1976,8 +2127,16 @@ pub async fn clear_maintainer_reports( } #[tauri::command] -pub async fn get_maintainer_issues( +pub async fn clear_maintainer_reports( state: State<'_, AppState>, + _app_handle: AppHandle, + project_id: String, +) -> Result<(), String> { + clear_maintainer_reports_impl(&state, project_id) +} + +pub async fn get_maintainer_issues_impl( + state: &AppState, project_id: String, ) -> Result, String> { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; @@ -1995,9 +2154,16 @@ pub async fn get_maintainer_issues( } #[tauri::command] -pub async fn get_maintainer_issue_detail( +pub async fn get_maintainer_issues( state: State<'_, AppState>, project_id: String, +) -> Result, String> { + get_maintainer_issues_impl(&state, project_id).await +} + +pub async fn get_maintainer_issue_detail_impl( + state: &AppState, + project_id: String, issue_number: u32, ) -> Result { let project_id = Uuid::parse_str(&project_id).map_err(|e| e.to_string())?; @@ -2014,6 +2180,15 @@ pub async fn get_maintainer_issue_detail( github::get_maintainer_issue_detail(repo_path, github_repo, issue_number).await } +#[tauri::command] +pub async fn get_maintainer_issue_detail( + state: State<'_, AppState>, + project_id: String, + issue_number: u32, +) -> Result { + get_maintainer_issue_detail_impl(&state, project_id, issue_number).await +} + #[tauri::command] pub fn log_frontend_error(message: String) { eprintln!("[FRONTEND] {}", message); diff --git a/src-tauri/src/commands/daemon.rs b/src-tauri/src/commands/daemon.rs index 9790bda1..f3ba31c6 100644 --- a/src-tauri/src/commands/daemon.rs +++ b/src-tauri/src/commands/daemon.rs @@ -17,7 +17,7 @@ pub(crate) fn read_token_from(path: &std::path::Path) -> Result Ok(s.trim().to_string()) } -pub(crate) async fn read_daemon_token() -> Result { +pub async fn read_daemon_token() -> Result { let path = daemon_token_path(); tokio::task::spawn_blocking(move || read_token_from(&path)) .await diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index fb35467f..70fcd2cd 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -1,5 +1,3 @@ -use tauri::State; - use crate::models::{AssignedIssue, GithubIssue}; use crate::state::AppState; @@ -94,9 +92,9 @@ async fn fetch_github_issues(repo_path: String) -> Result, Stri Ok(issues) } -pub(crate) async fn list_github_issues( +pub async fn list_github_issues( repo_path: String, - state: State<'_, AppState>, + state: &AppState, ) -> Result, String> { // Check cache (lock is dropped at end of block before any .await) let cache_result = { @@ -142,10 +140,7 @@ pub(crate) async fn list_github_issues( Ok(issues) } -pub(crate) async fn generate_issue_body( - repo_path: String, - title: String, -) -> Result { +pub async fn generate_issue_body(repo_path: String, title: String) -> Result { let prompt = format!( "Write a concise GitHub issue body for an issue titled: \"{}\". \ Include a Summary section and a Details section. \ @@ -167,8 +162,8 @@ pub(crate) async fn generate_issue_body( } } -pub(crate) async fn create_github_issue( - state: State<'_, AppState>, +pub async fn create_github_issue( + state: &AppState, repo_path: String, title: String, body: String, @@ -208,7 +203,7 @@ pub(crate) async fn create_github_issue( Ok(issue) } -pub(crate) async fn post_github_comment( +pub async fn post_github_comment( repo_path: String, issue_number: u64, body: String, @@ -237,8 +232,8 @@ pub(crate) async fn post_github_comment( Ok(()) } -pub(crate) async fn add_github_label( - state: State<'_, AppState>, +pub async fn add_github_label( + state: &AppState, repo_path: String, issue_number: u64, label: String, @@ -294,8 +289,8 @@ pub(crate) async fn add_github_label( Ok(()) } -pub(crate) async fn remove_github_label( - state: State<'_, AppState>, +pub async fn remove_github_label( + state: &AppState, repo_path: String, issue_number: u64, label: String, @@ -328,8 +323,8 @@ pub(crate) async fn remove_github_label( Ok(()) } -pub(crate) async fn close_github_issue( - state: State<'_, AppState>, +pub async fn close_github_issue( + state: &AppState, repo_path: String, issue_number: u64, comment: String, @@ -368,8 +363,8 @@ pub(crate) async fn close_github_issue( Ok(()) } -pub(crate) async fn delete_github_issue( - state: State<'_, AppState>, +pub async fn delete_github_issue( + state: &AppState, repo_path: String, issue_number: u64, ) -> Result<(), String> { @@ -400,7 +395,7 @@ pub(crate) async fn delete_github_issue( Ok(()) } -pub(crate) async fn get_maintainer_issues( +pub async fn get_maintainer_issues( repo_path: String, github_repo: Option, ) -> Result, String> { @@ -436,7 +431,7 @@ pub(crate) async fn get_maintainer_issues( serde_json::from_slice(&output.stdout).map_err(|e| format!("Failed to parse gh output: {}", e)) } -pub(crate) async fn get_maintainer_issue_detail( +pub async fn get_maintainer_issue_detail( repo_path: String, github_repo: Option, issue_number: u32, @@ -468,7 +463,7 @@ pub(crate) async fn get_maintainer_issue_detail( serde_json::from_slice(&output.stdout).map_err(|e| format!("Failed to parse gh output: {}", e)) } -pub(crate) async fn list_assigned_issues(repo_path: String) -> Result, String> { +pub async fn list_assigned_issues(repo_path: String) -> Result, String> { let nwo = extract_github_repo_async(repo_path).await?; let output = tokio::process::Command::new("gh") @@ -503,7 +498,7 @@ pub(crate) async fn list_assigned_issues(repo_path: String) -> Result Result, String> { +pub async fn get_worker_reports(repo_path: String) -> Result, String> { let nwo = extract_github_repo_async(repo_path).await?; let output = tokio::process::Command::new("gh") diff --git a/src-tauri/src/commands/kanban.rs b/src-tauri/src/commands/kanban.rs index 0e0dc0cb..f1c4ed37 100644 --- a/src-tauri/src/commands/kanban.rs +++ b/src-tauri/src/commands/kanban.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use serde_json::Value; use tauri::{AppHandle, Manager}; -fn order_file_in(dir: &Path) -> PathBuf { +pub fn order_file_in(dir: &Path) -> PathBuf { dir.join("kanban-order.json") } @@ -18,7 +18,7 @@ fn order_file(app: &AppHandle) -> Result { Ok(order_file_in(&dir)) } -fn load_order_from(path: &Path) -> Result { +pub fn load_order_from(path: &Path) -> Result { if !path.exists() { return Ok(Value::Object(Default::default())); } @@ -30,7 +30,7 @@ fn load_order_from(path: &Path) -> Result { serde_json::from_slice(&bytes).map_err(|e| format!("failed to parse kanban-order.json: {e}")) } -fn save_order_to(path: &Path, order: &Value) -> Result<(), String> { +pub fn save_order_to(path: &Path, order: &Value) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| "invalid order file path".to_string())?; diff --git a/src-tauri/src/secure_env.rs b/src-tauri/src/secure_env.rs index a0fa572f..39af0e0f 100644 --- a/src-tauri/src/secure_env.rs +++ b/src-tauri/src/secure_env.rs @@ -179,7 +179,7 @@ pub(crate) fn begin_secure_env_request_with_response( } #[allow(dead_code)] -pub(crate) fn cancel_secure_env_request(state: &AppState, request_id: &str) -> Result<(), String> { +pub fn cancel_secure_env_request(state: &AppState, request_id: &str) -> Result<(), String> { let mut active = state .secure_env_request .lock() @@ -277,6 +277,21 @@ pub(crate) fn submit_secure_env_value( ) } +/// Transport-agnostic submit that produces the `"created"`/`"updated"` string +/// the UI expects, without exposing the internal `EnvWriteResult` type. +pub fn submit_secure_env_value_status( + state: &AppState, + request_id: &str, + value: &str, +) -> Result { + let result = submit_secure_env_value(state, request_id, value)?; + Ok(if result.created { + "created".to_string() + } else { + "updated".to_string() + }) +} + #[allow(dead_code)] pub(crate) fn update_env_file( env_path: &Path, diff --git a/src/App.svelte b/src/App.svelte index 2a5b6875..f1f7be14 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,8 +1,7 @@