Skip to content

feat(web): Discover — find and import directories with existing Claude Code history (#745) - #802

Merged
edspencer merged 1 commit into
mainfrom
feat/745-discover-web
Aug 9, 2026
Merged

feat(web): Discover — find and import directories with existing Claude Code history (#745)#802
edspencer merged 1 commit into
mainfrom
feat/745-discover-web

Conversation

@edspencer

Copy link
Copy Markdown
Owner

The web half of #745. The server half landed in #799 (75b8a05); this builds
the view on top of it and changes no server code.

A route, not a dialog

/discover renders the view full-page, and Home renders the same component
inline
when the instance is empty. One component, two mount points — linkable,
refreshable, and nothing to dismiss, so there is no "don't ask again" flag to get
wrong. The deciding argument is #796: paddock service install boots from
launchd with no terminal, so a first-run experience has to be a page you can come
back to rather than a moment you can miss.

Empty = zero non-root projects AND zero root-workspace chats. The root
always exists, so it cannot be what makes an instance non-empty; a conversation
started in it means the instance is in use. useInstanceEmpty returns three
states, not two — null is "not known yet", because collapsing it into "not
empty" mounts the ordinary Home for a beat and then swaps it for a completely
different screen, and that flash lands squarely on the fresh install this exists
for. It only issues the root-chats request when there are no projects, and fails
closed (an unreadable root is not an empty one).

A Discover NavLink joins Config in the sidebar footer stack.

Tri-state and lazy expansion, which are one problem

The listing carries counts; a directory's sessions are fetched only when its row
is expanded. That makes a ticked row mean two different things: "all of this
directory, whatever is in it"
before it is opened, and "exactly these ids"
after. So in lib/discoverSelection.ts:

  • sessions === undefined is load-bearing, not an implementation detail — it
    separates "no per-session opinion has been expressed" from "every session was
    unticked". The second is a no; the first is the default yes.
  • A row nobody has opened is therefore never indeterminate. Showing mixed for
    contents nobody has seen would invent a distinction the user has not made.
  • On the wire this falls out exactly: an unopened row sends no sessionIds,
    which is adopt-chats' own way of saying "everything on offer" — including
    anything that appeared between the scan and the submit.
  • Expanding an unticked row does not re-tick it. Opening something to look at
    it is not consent to import it.

The shared Checkbox primitive gained an indeterminate prop (a DOM property
with no attribute, so it cannot be set from JSX) which also sets
aria-checked="mixed" — done in the primitive so callers cannot get the ARIA
half wrong.

The import run

Two existing calls per accepted row — POST /api/projects (unmanaged, linked,
managed: false explicitly so the sweeper never rewrites somebody's checkout)
then POST …/adopt-chats — sequential, client-driven, no new streaming protocol.
Submit disables on submit; a second click cannot start a second run.

sourceCwd is the created project's workingDir, not the candidate path:
the server canonicalises a linked path when it stores it and adopt-chats
matches on exactly that string, so echoing the server's own answer back is the
one spelling that cannot drift.

Failures are per row, and there are three outcomes, not two:

outcome tone what the row says
chats imported green Imported 4 chats into "paddock" + skip reasons in English
create failed red Couldn't create the project: … — nothing was left behind
created, import failed amber …was created, but importing its chats failed: …. It is there and empty
created, zero chats amber names recordedPath when the scan reported one, that being how an import silently comes back empty

Green/red as specified plus amber, because a project created with nothing in it
is neither: green would hide an empty project, red would claim a failure that did
not happen. The success panel sits above the table rather than replacing it,
so the per-row detail the user needs to read survives the run finishing.
Get started refreshes the project list and navigates Home — which
re-evaluates, is no longer empty, and renders the ordinary workspace. The refresh
is deliberately at that point and not per row: on the Home mount, refreshing
mid-run would make the first success unmount the screen the user is watching.

Empty results explain themselves

scanned: 0 names the Claude home to bind-mount instead of rendering a blank
table. A non-zero scan with no rows gets the excluded tally in a sentence
("11 transcript folders scanned; skipped 1 without a git repository, 3 already a
project, …"). The two soft rules are offered as toggles, but only when relaxing
one would actually reveal something.

Verified live

A throwaway instance (isolated HOME, PADDOCK_DATA_DIR and
PADDOCK_CLAUDE_HOME, no credentials) seeded with six synthetic transcript
folders, driven end to end:

  • fresh instance → Home rendered Discovery, 3 of 6 folders offered, 3 filtered
  • expanded a row → one GET …/sessions, re-expanding fetched nothing more;
    unticking one session took the parent to indeterminate and the total 10 → 9
  • imported → 3 green rows, Imported 4 chats into "paddock" (the unticked one
    genuinely absent from GET …/chats), Get started → the ordinary root Home with
    three projects in the sidebar
  • re-scan → already-managed: 3, no candidates
  • non-git toggle → revealed the notes directory with a no git chip
  • emptied the Claude home → the "No Claude Code history on this machine" panel

Tests

+53: discoverSelection (12), discoverImport (13), useInstanceEmpty (6),
RootHome (3), DiscoverView (17), plus Checkbox indeterminate and the
sidebar link. Web suite 1774 passed / 84 files; server suite 2085 passed / 160
files; typecheck green on both packages.

Two notes on the issue and the API

Closes the UI half of #745.

…e Code history (#745)

The web half of #745. A route, `/discover`, listing the directories this machine
already has Claude Code history in, and the same component rendered inline as an
empty instance's Home.

A route rather than a dialog: an empty instance whose Home *is* Discovery has
nothing to dismiss, so there is no "don't ask again" flag to get wrong, and a
first-run screen you can link to and refresh is the one that survives being
booted from launchd with nobody at a terminal (#796).

Tickboxes are tri-state and rows expand lazily — the listing carries counts, and
`GET /api/discover/sessions` is issued only when a row is opened. Those two
interact: a ticked row means "all of it" before it is opened and "exactly these
ids" after, which is why an unopened row sends no `sessionIds` at all (the
wire's own way of saying everything on offer) and why indeterminate is never
shown for a row nobody has looked at.

Importing is `POST /api/projects` then `POST …/adopt-chats` per row, sequential
and client-driven. Failures are per row, including the one that matters:
create-succeeded / import-failed leaves a real empty project behind, so that row
says so rather than a global toast claiming the run failed.

- `lib/discoverSelection.ts` — the tri-state / lazy-load model, pure
- `lib/discoverImport.ts` — the two-call run and each row's sentence, pure
- `lib/useInstanceEmpty.ts` — zero non-root projects AND zero root chats
- `components/DiscoverView.tsx` — the screen, both mount points
- `routes/{DiscoverPage,RootHome}.tsx` — the two mounts
- `ui/Form.tsx` — `Checkbox` gains `indeterminate` (+ `aria-checked="mixed"`)

Server untouched.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 61c6fc3
Status: ✅  Deploy successful!
Preview URL: https://0e1aa5fe.paddock-7u2.pages.dev
Branch Preview URL: https://feat-745-discover-web.paddock-7u2.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant