feat(web): Discover — find and import directories with existing Claude Code history (#745) - #802
Merged
Merged
Conversation
…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.
Deploying paddock with
|
| 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 |
Merged
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The web half of #745. The server half landed in #799 (
75b8a05); this buildsthe view on top of it and changes no server code.
A route, not a dialog
/discoverrenders the view full-page, and Home renders the same componentinline 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 installboots fromlaunchd 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.
useInstanceEmptyreturns threestates, not two —
nullis "not known yet", because collapsing it into "notempty" 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
DiscoverNavLinkjoinsConfigin 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 === undefinedis load-bearing, not an implementation detail — itseparates "no per-session opinion has been expressed" from "every session was
unticked". The second is a no; the first is the default yes.
contents nobody has seen would invent a distinction the user has not made.
sessionIds,which is
adopt-chats' own way of saying "everything on offer" — includinganything that appeared between the scan and the submit.
it is not consent to import it.
The shared
Checkboxprimitive gained anindeterminateprop (a DOM propertywith no attribute, so it cannot be set from JSX) which also sets
aria-checked="mixed"— done in the primitive so callers cannot get the ARIAhalf wrong.
The import run
Two existing calls per accepted row —
POST /api/projects(unmanaged, linked,managed: falseexplicitly 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.
sourceCwdis the created project'sworkingDir, not the candidate path:the server canonicalises a linked path when it stores it and
adopt-chatsmatches 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:
Imported 4 chats into "paddock"+ skip reasons in EnglishCouldn't create the project: …— nothing was left behind…was created, but importing its chats failed: …. It is there and emptyrecordedPathwhen the scan reported one, that being how an import silently comes back emptyGreen/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: 0names the Claude home to bind-mount instead of rendering a blanktable. A non-zero scan with no rows gets the
excludedtally 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_DIRandPADDOCK_CLAUDE_HOME, no credentials) seeded with six synthetic transcriptfolders, driven end to end:
GET …/sessions, re-expanding fetched nothing more;unticking one session took the parent to indeterminate and the total 10 → 9
Imported 4 chats into "paddock"(the unticked onegenuinely absent from
GET …/chats), Get started → the ordinary root Home withthree projects in the sidebar
already-managed: 3, no candidatesnotesdirectory with ano gitchipTests
+53:
discoverSelection(12),discoverImport(13),useInstanceEmpty(6),RootHome(3),DiscoverView(17), plusCheckboxindeterminate and thesidebar 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
paddock scan) #745 sketches step 2 asGET /api/discover?dir=…; the merged server usesGET /api/discover/sessions?dir=…, and its docstring explains why (one OpenAPIpath cannot describe two 200 bodies). The client follows the code.
paddock scan) #745's open question "require.git, or rank it first?" is answered by theserver defaulting to require with
includeNonGit=1to relax. The UI surfacesthat as a toggle shown only when the tally says it would reveal something —
a permanently visible switch that does nothing on most machines is worse than
no switch.
Closes the UI half of #745.