Lossless Changelog
product

The Story So Far — memopop-ai From Empty Bun Workspace to Live Desktop Client

First changelog at the monorepo level. Catching up the eight days that took memopop-native from `bun create tauri-app` to a fullscreen client driving the orchestrator's HTTP API end-to-end.

Michael StatonMusic Editoraugmented with Claude Code (Opus 4.7)
feat(memopop-native): full Tauri client for the orchestrator API

Catch-up commit covering work since the bun-workspace scaffold and the
three foundational changelogs already in apps/memopop-native/changelog/.
The desktop client now drives the FastAPI sidecar end-to-end:

* Onboarding journey. Anchor orchestrator → outline gallery → outline
  detail → firm creation (snake_case slug + idempotent .gitignore
  handling) → deal capture form. JourneyBreadcrumbs across the top
  tracks position with a thin progress bar.

* Job runtime. Fullscreen JobView with three live panes:
  - PhaseChecklist (left): ten ordered phases derived from structured
    milestone events streaming from the orchestrator. States: pending,
    in-progress (animated spinner), done (green check + relative time),
    failed (red X).
  - LogStream (center): terminal-dark, JetBrains Mono, blinking purple
    cursor at the tail. Capped at 1,000 rendered / 2,000 stored with
    rAF-throttled scroll to fix Svelte deep-reactivity GC stalls.
  - ArtifactBrowser (right): polls GET /memos/{id}/artifacts every 3s,
    groups files by top-level folder, flashes new files green on arrival.

* Sidecar lifecycle. Rust SidecarManager spawns Python from
  {repoPath}/.venv/bin/python, polls /healthz, self-heals on dead
  sidecars (always probes healthz before forwarding, drops stale handles),
  kills cleanly on WindowEvent::CloseRequested|Destroyed. POST
  /actions/stop-sidecar wires a Stop button that calls
  SidecarManager::shutdown directly — bypasses Python entirely so it
  works when the sidecar is wedged.

* Transport seam. LocalTransport routes JSON through one api_dispatch
  Tauri command (Rust forwards /memos* to the sidecar via reqwest, strips
  the dispatcher-only repoPath field). SSE streams go DIRECT from the
  webview to localhost:8765 — bypassing the Rust dispatcher because the
  FastAPI CORS allowlist already covers tauri:// and http://localhost
  origins, and reimplementing chunked HTTP forwarding in Rust would be
  a waste.

Tied to the investment-memo-orchestrator changelogs 2026-04-30_01
(FastAPI sidecar), 2026-05-01_02 (milestones + scaffold), and
2026-05-01_03 (persistence + versioning + lifecycle) which shipped the
API surface this client consumes.

Changelog - 2026-05-01_01

The Story So Far — memopop-ai From Empty Bun Workspace to Live Desktop Client

This is the first changelog at the monorepo level for memopop-ai. We've been writing changelogs inside apps/memopop-native/changelog/ since the Tauri shell was scaffolded — three of them, all dated 2026-04-27 — but never one at the top of the workspace itself. So this entry catches up the rest, narrates the arc from "Bun workspace with three placeholder app dirs" to "fullscreen desktop client driving the orchestrator's HTTP API live, with milestones, log streaming, file flashing, stop button, and a self-healing sidecar manager," and gives the next changelog a place to start.

If you want the granular foundation, read those three first:

Everything below picks up where 2026-04-27_03 ended.


The Arc, in Order

  1. 2026-04-27. Three changelogs in one day. Tauri shell stood up, plugins installed, transport interface defined, LocalTransport calling a single Rust api_dispatch command, GET /firms wired end-to-end. Then Settings + firm picker. Then full onboarding flow — gallery, outline detail modal, firm creation modal with snake_case slug preview and idempotent .gitignore patching, deal capture form. Stopped at "Ready to generate" placeholder.

  2. 2026-04-30. Two things happened in parallel:

    • JourneyBreadcrumbs shipped (above the gallery / between header and content) — a four-step progress bar (Outline → Firm → Deal → Generate) with backward navigation.

    • The orchestrator gained a FastAPI sidecar at investment-memo-orchestrator/src/server/. The desktop client now had something to talk to. The sidecar's API mirrors generate_memo() kwargs.

  3. 2026-04-30 → 2026-05-01 (overnight). Wired the desktop to the sidecar.

    • Rust: Cargo.toml gained reqwest + tokio[time]. New src/api/sidecar.rs with SidecarManager (lazy spawn from {repoPath}/.venv/bin/python, healthz polling, child handle in Mutex<Option<CommandChild>>, forward() for JSON, shutdown() for SIGKILL). New match arms in router.rs for /memos* (POST + status + list + artifacts) — all routed through forward_to_sidecar which strips the dispatcher-only repoPath field before forwarding. lib.rs registers the manager in app state and wires shutdown to WindowEvent::Destroyed.

    • Frontend: Transport interface gains subscribeEvents(jobId, onEvent). LocalTransport.subscribeEvents opens an EventSource direct to http://127.0.0.1:8765/memos/{id}/events — bypassing the Rust dispatcher entirely. New running_job flow stage with milestones + events arrays. DealCreationModal now does a real POST /memos. New JobLogViewer modal with three tabs: Milestones, Files, Logs.

  4. 2026-05-01 morning. Built MilestoneTimeline and ArtifactBrowser (live polling of artifacts every 3s, files flash green on arrival). The orchestrator's MilestoneExtractor published structured milestone events; the timeline rendered them as a vertical chronological list.

  5. 2026-05-01 (the user-feedback inflection). First end-to-end run with an actual memo. Four issues surfaced:

    1. Log viewer ate memory and the screen went black on a 30s cadence.

    2. The checklist updated only at the end (consequence of #1).

    3. Files landed at output/ChromaDB-v0.0.1/ — wrong dir.

    4. A second run silently overwrote the first run's directory.

  6. 2026-05-01 evening — the redesign. Replaced the tabbed JobLogViewer modal with JobView: fullscreen, three columns simultaneously visible (PhaseChecklist · LogStream · ArtifactBrowser), no tabs. The user's words: "this will be THE THING THEY ARE TRYING TO DO, WITH IMPORTANCE." Fullscreen made it that. Deleted the old JobLogViewer.svelte and MilestoneTimeline.svelte — the new components own that space cleanly.

  7. 2026-05-01 late evening — the hardening pass. Performance fixes (events array capped at 2,000 in store, rendered lines capped at 1,000 with a "earlier lines hidden — full log on disk" banner, requestAnimationFrame-throttled auto-scroll). Stop button (red ⏹ Stop in the JobView header, calls POST /actions/stop-sidecar which routes to SidecarManager::shutdown() directly so it works when Python is wedged). Tauri shell extended to handle WindowEvent::CloseRequested alongside Destroyed (Cmd+W and Cmd+Q both reliably terminate the sidecar). SidecarManager::ensure_running self-heals — always probes /healthz first, drops stale handles, spawns fresh.

  8. Where we are now (commit time). The user can launch MemoPop, anchor an orchestrator, click an outline, set up a firm, fill a deal form, click Generate, and watch:

    • The phase checklist tick over in real time.

    • Log lines stream in the dark center pane with a blinking purple cursor.

    • Files flash green on the right as they appear in the firm-scoped output dir.

    • A red Stop button kills the run cleanly mid-flight.

    • Quitting the window kills the sidecar.


What memopop-ai Is Right Now

memopop-ai/
├── apps/
│   ├── memopop-native/         # The Tauri 2 desktop shell. The substantive app.
│   ├── memopop-site/           # Astro landing page (placeholder; not yet wired into the journey).
│   └── memopop-web-app/        # SvelteKit web app (placeholder; future hosted-mode home).
├── packages/                   # Shared packages (currently empty / pre-extraction).
├── changelog/                  # ← THIS file lives here. First entry at the monorepo level.
└── context-v/                  # Specs, explorations, reminders. (Read context-v/specs/ for designs.)

The substantive app: apps/memopop-native/

apps/memopop-native/
├── src-tauri/                  # Rust backend
│   ├── src/api/
│   │   ├── router.rs           # api_dispatch — match (method, path) → query | action | sidecar-forward
│   │   ├── queries.rs          # GET /firms, /outlines, /outlines/{id}
│   │   ├── actions.rs          # POST /actions/create-firm
│   │   ├── sidecar.rs          # SidecarManager: spawn, healthz, forward, shutdown, self-heal
│   │   └── mod.rs              # ApiError shape + helper constructors
│   ├── src/lib.rs              # Tauri builder, plugin registration, WindowEvent handler
│   └── Cargo.toml              # tauri 2, reqwest, tokio[time], plugins (shell, dialog, store, fs, opener)
└── src/                        # Frontend (Svelte 5 runes + SvelteKit static)
    ├── lib/
    │   ├── transport/          # Transport interface + LocalTransport (api_dispatch + EventSource)
    │   ├── stores/             # settings.svelte.ts, outlines.svelte.ts, flow.svelte.ts (running_job state)
    │   └── components/         # JobView, PhaseChecklist, LogStream, ArtifactBrowser,
    │                           #   OutlineGallery, OutlineCard, OutlineDetail,
    │                           #   FirmCreationModal, DealCreationModal,
    │                           #   AnchorOrchestrator, JourneyBreadcrumbs, Header, Settings
    └── routes/
        ├── +layout.svelte      # Header + JourneyBreadcrumbs + content; height: 100vh + min-height: 0
        ├── +page.svelte        # Branches: anchor → running_job (JobView) → gallery + modals
        └── settings/+page.svelte

How a request flows

┌─────────────────────────────────────────────────────────────────┐
│  Webview (Svelte runes UI)                                      │
│                                                                 │
│  getTransport().request('POST', '/memos', { ... })              │
│  getTransport().subscribeEvents(jobId, onEvent)                 │
└────┬───────────────────────────────────────────┬────────────────┘
     │ JSON via Tauri invoke                     │ EventSource (HTTP/SSE)
     ▼                                           │
┌─────────────────────────────────────────┐      │
│  Rust dispatcher (api_dispatch)         │      │
│  - Routes /firms, /outlines, /actions   │      │
│  - Forwards /memos* via reqwest →       │      │
│    SidecarManager::forward()            │      │
└────┬────────────────────────────────────┘      │
     │ HTTP localhost:8765                       │
     ▼                                           │
┌─────────────────────────────────────────┐      │
│  FastAPI sidecar (Python, in venv)      │◄─────┘
│  - generate_memo() in a worker thread   │
│  - JobEventBus → SSE                    │
│  - persist_job_logs at terminal         │
└─────────────────────────────────────────┘

The split is deliberate: the Rust dispatcher is the right place for JSON request/response (uniform error shape, single Tauri command surface, easy to swap for HTTP-mode later). SSE is browser-native — the webview opens an EventSource directly. Reimplementing chunked HTTP forwarding inside api_dispatch would have been wasted code.


Design Decisions Worth Recording

The Transport interface has exactly two methods. request() for JSON request/response, subscribeEvents() for SSE streaming. Resisted the temptation to add a third (watchFiles, subscribeStatus, etc.) — anything periodic can be polled via request() and a setInterval, anything streaming can be expressed as SSE. Two methods stay easy to swap when local mode becomes hosted mode.

The Tauri sidecar lives in the orchestrator's repo, not memopop-ai's. Documented in context-v/explorations/Moving-an-Agent-Orchestrator-to-an-API.md. The FastAPI is a thin import wrapper around generate_memo(); it has to live next to the Python source it imports. The orchestrator gains an HTTP surface for any non-Python client; we gain a memopop-specific UI; nobody owns two copies of the workflow.

SSE goes direct from webview to localhost:8765. Considered routing through Rust for "uniformity," rejected because (a) EventSource is browser-native and works perfectly, (b) the FastAPI CORS allowlist already covers Tauri origins (tauri://localhost, http://tauri.localhost(:port)?), (c) Rust would have to forward chunked HTTP responses, which is real engineering for no real benefit.

Lazy sidecar spawn. SidecarManager doesn't start anything until the first /memos* call. Spawn cost (250ms-1s) is paid once, on the first action that needs it. The user clicking around the gallery and reading outline detail modals never wakes up Python.

Self-healing instead of version-checking. ensure_running always probes /healthz first. If the sidecar is dead, hung, or running stale code (which we can't directly detect without instrumentation), the simplest reliable approach is "if healthz is silent, kill any handle we have and spawn fresh." Future work could add a /healthz-returned version hash for staleness detection; today's bias is "self-heal anything that isn't responding."

Stop bypasses Python. POST /actions/stop-sidecar routes to SidecarManager::shutdown() in Rust directly — does not round-trip through the FastAPI server. The whole point of "Stop" is that Python may be wedged. SIGKILL via stored CommandChild is the only design that works when the sidecar is in a deadlocked retry loop or rate-limited. SIGKILL means cancelled runs lose their structured log file (no time for persist_job_logs to run); for v1 that trade-off is acceptable.

The job UI is a route-level page, not a modal. Earlier iteration was a centered modal capped at 760px. The first user-test feedback was decisive: "most people will likely not have it as a small part of their screen." Replaced with a full-viewport three-column layout. Logs dominate, milestones are glanceable on the left, files flash on the right.

Performance bounds at the store level, not just the view level. Capping rendered lines fixes layout thrash; but the events array itself was the source of GC stalls (each event a deep-reactivity proxy). Cap at the store with slice(-2000) whenever the array exceeds the bound. View-level caps are a secondary defense.

Flow state is a discriminated union, not a stack. idle | outline_detail | create_firm | create_deal | ready_to_run | running_job. One variable; conditional rendering in +page.svelte reads cleanly. Resisted earlier urge to model a modal stack (since the spec's flow is linear, not nested).


Verification

$ bun run check
1 ERROR (vite.config.js — pre-existing, unrelated)
19 WARNINGS (all pre-existing accessibility / unused-CSS)
0 new issues from any of this work.

$ bun run build
✓ built in 1.6s
✓ Wrote site to "build" via @sveltejs/adapter-static

$ cargo check --manifest-path src-tauri/Cargo.toml
Finished `dev` profile [unoptimized + debuginfo] target(s)

$ bun run tauri dev
# Window opens, gallery renders, full journey is operable end-to-end.

End-to-end run verified the night of 2026-05-01: ChromaDB on alpha-partners ran a full 17-minute pipeline through the JobView, files appearing live in the right pane, milestones ticking over in the checklist, log streaming smoothly without freezing. Stop button confirmed killing the sidecar via "Stopped by user" milestone. Output landed at io/alpha-partners/deals/ChromaDB/outputs/ChromaDB-v0.0.2/ (the never-overwrite versioning fix doing its job).


Files Worth Highlighting

(Not exhaustive — the full tree is in apps/memopop-native/. These are the load-bearing pieces a future contributor should know about.)

FileRole
apps/memopop-native/src-tauri/src/api/router.rsThe dispatcher. One match expression, one source of routing truth. Add new endpoints here.
apps/memopop-native/src-tauri/src/api/sidecar.rsSidecarManager — process lifecycle, self-heal, forward. The Python child's parent.
apps/memopop-native/src/lib/transport/local.tsTwo methods: request() and subscribeEvents(). The whole transport seam.
apps/memopop-native/src/lib/stores/flow.svelte.tsThe state machine for the user's journey. Discriminated union, ~150 lines.
apps/memopop-native/src/lib/components/JobView.svelteThe fullscreen runtime view. Three children, one header, one banner.
apps/memopop-native/src/lib/components/PhaseChecklist.svelteMaps milestones to ten ordered phases. Pure presentation.
apps/memopop-native/src/lib/components/LogStream.svelteCapped-and-throttled tail. The sexy bit.
apps/memopop-native/src/lib/components/ArtifactBrowser.svelteLive file-tree with green-flash on arrival.

What's Next

The journey from outline → memo is closed end-to-end for the local-sidecar mode. Three categories of follow-up, none urgent:

  1. Polish the export layer. The orchestrator produces a 67k-character markdown memo plus a one-pager HTML/PDF plus a Word doc. The desktop client currently shows the output directory path and the file list — but doesn't open files. A "View memo" button that opens 7-{Company}-vX.Y.Z.md in the system viewer (or a built-in markdown preview) would close the loop visibly. tauri-plugin-shell::open() is the move.

  2. Branded export flow. The orchestrator's export-branded.py produces firm-styled HTML/PDF using the brand-{firm}-config.yaml we scaffold-stub during firm creation. Today the brand config has only company.conventional_name populated. A "Brand setup" wizard (hinted at in context-v/specs/An-Onboarding-User-Journey-for-Memopop-Native.md) is the natural next feature — promising version is the agent-driven version that auto-fetches colors/fonts/logos from a firm's website URL.

  3. Hosted mode. Today's transport is LocalTransport. Tomorrow's is HttpTransport to a deployed FastAPI. Both implement the same two-method interface; the components don't change. The work between local and hosted is auth, multi-tenant isolation, persistent storage, and a job queue — none of which are needed before the first hosted user.

The memopop-site/ and memopop-web-app/ apps in this workspace are placeholders for the hosted future. They'll get their own changelog entries when there's something to say.