Repository navigation
Conversation
…ins the word it reports
A hit's text was cut to 140 runes from the FRONT of the line. Agent prose arrives as one
long line, so a word matched at character 500 was not in the row the search returned — a
wrong answer wearing the shape of a truncated one. The window now centres on the match,
and an ellipsis at either end says the line goes on.
Getting there means the store has to know WHERE it matched, so `matcher` grew `find` and
`locate`, and `Hit` carries the match's rune offsets. They are published rather than left
to each surface to recompute, because recomputing means a second matcher, and a second
one disagrees: `--word`'s boundaries are Unicode classes that `\b` does not mean, and
folding is on by default here and off in most regex dialects. A caller that highlighted
its own idea of the match would draw the box around the wrong characters.
Two details that are easy to get wrong, and are tested:
- `--word` spells its boundaries as CONSUMING character classes (RE2 has no lookaround),
so the reported range comes from submatch 1. Reporting the whole match would hand
every caller the spaces either side of the word.
- `locate` re-finds against the CLEANED text rather than reusing an offset from the raw
line: cleanLine turns a tab into four spaces, which moves every byte after it. It may
legitimately fail, and -1 then means "cannot say", not "no match" — the hit stands and
only the highlight is dropped.
The CLI passes its own 140 as TextWidth instead of truncating afterwards, so `truncateRunes`
is gone.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…ver HTTP
The three scopes a person asks for are not three endpoints: neither `project` nor `ref`
is the whole store, `project` is one project, `ref` is one pad. `ref` is also the only
form that carries a password, exactly as `--pad` is in the CLI.
What differs from the CLI, and why:
- The password comes from the SESSION that already unlocked that pad, never from the
request, so it cannot end up in history or in a link someone copies. `sess.unlocked`
is an exact-ref lookup, so anything else fails closed.
- `limit` has a ceiling. `--limit 0` at a terminal is a person waiting for their own
command; over HTTP it is a request to serialise the store into one response.
- `before`/`after` are unix seconds, not "30d". The age spelling belongs where a person
types the bound; a browser knows the clock, and a wire format that parses prose is one
more place for two surfaces to disagree about what "a month ago" means.
A store-wide search still skips protected pads and still says which — that list is handed
straight through, because an empty result must never be readable as "the word is nowhere
in the store".
A misspelled flag is refused rather than read as false: a filter that silently turns
itself off answers a question nobody asked.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…fix a form here needs Every form-associated component mirrors a wrapping <label> onto its inner control as aria-labelledby, minting an id for the label when it has none. That id came from a counter declared in EACH component file, all sharing one `pd-label-` prefix — so the first <label> around a select and the first around an input were both `pd-label-1`, getElementById returned whichever came first in the DOM, and the second control announced the first one's name. aria-labelledby outranks aria-label, so it could not be overridden from here. The search page's four-field row is exactly that shape: its Agent field called itself "Project". Reported upstream, fixed there (labelIdFor in reactive.js, one counter, and it now steps past ids already present in the document), so the workaround this repo was carrying comes out in the next commit rather than being left to rot. Vendored from main, which also brings PR #13 (<puredashboard-copy>) along: `make vendor-ui` syncs the whole directory, and nothing here uses that component yet. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
…the hit as a place to go
Three ways of asking one question, so three entry points and one endpoint:
- #/search, with the question IN THE URL. A search is then a link, and the thing worth
sending a colleague is the question rather than a screenshot of the answer. Back and
reload work because the address bar is the state.
- "Search this project" on a project page, which is that page's scope pre-filled.
- A third tab on a pad's rail, beside the outline and the task board — three indexes of
the same pad: by section, by work, by what was written. A hit there moves the
transcript next to it, through the same jumpTo an outline row uses.
A hit from the store-wide page carries `?section=&q=` into the pad, so following one lands
on the section AND opens the rail on the same search. Without that, a result is something
you then have to go and find by hand.
What was NOT searched is on the summary line itself, not in a paragraph below it: a person
who reads one line must not read the wrong answer. Three empty states stay distinguishable —
nothing asked yet, asked and absent, asked and absent but pads were skipped.
Results are never repainted under the reader. When a pad moves, the page says so and offers
to run it again; the default order is by pad activity, so a silent refresh would slide a
whole group to the top of what someone is reading. In a pad the same rule is stronger — a
search there reads the whole file, and re-running it per arriving section turns a busy pad
into a poll.
The match is painted from the offsets the SERVER computed; nothing re-runs the pattern in
JavaScript. safeInline exists for that: it is length-preserving, where safeText collapses
whitespace and would shift every character after a double space, landing the highlight
beside the word instead of on it.
Two things fixed on the way, neither caused by this feature:
- The rail's tab strip was covered by the sticky toolbar once you scrolled. `top: 3.25rem`
was a guess at the toolbar's height; it wraps to two lines and is 88px in practice, so
the offset is now measured and published as --pad-toolbar-h. It hid Outline and Tasks
too, and would have hidden the only way into the new tab.
- The label workaround for the vendored id clash is gone, now that the fix is vendored.
Demo: mobile-budget2v, five weeks old, holds the definition of a term the flagship restates
this week. Neither "oldest first" nor cutting a hit around its match can be shown on a store
built five minutes ago — one needs two pads separated in time, the other a line long enough
to have a late match.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGYnqPKtsrWfugEKyASb7a
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.
Content search reaches the Web UI: the store, a project, or one pad — and a hit you can follow.
pad searchhas been a CLI-only capability since it landed.DESIGN.mdsaid at the time thatit could be added to the Web UI later from the same
store.Search; this is that.What it adds
Three ways of asking one question, so three entry points and one endpoint (
GET /api/search).Neither
projectnorrefset means the whole store;projectnarrows to one project;reftoone pad.
#/searchThe rail now holds three indexes of the same pad: by section (outline), by work (tasks), by what
was written (search).
A hit on the store-wide page links to
#/pads/<ref>?section=<n>&q=<query>, so following one landson the section and opens that pad's rail on the same search. Without that, a result is
something you then have to go and find by hand.
Design decisions worth a reviewer's attention
A protected pad is reached the way the CLI reaches one — by being named. A store-wide search
skips protected pads before reading a single body byte, and reports which. Only a search that names
one pad can open it, and the password comes from the session that already unlocked it, so it never
travels in a URL that could end up in history or in a copied link. This deliberately does not
widen to "search every pad this session has unlocked": that would need
store.Searchto acceptseveral ref–password pairs, and reading through protection one noun at a time is the thing the
store refuses to do.
What was NOT searched is on the summary line itself, not in a paragraph below it. A person who
reads one line must not read the wrong answer, and "0 matches" and "0 matches, 3 pads skipped" are
different answers to the same question.
limithas a ceiling here that the CLI does not need.--limit 0at a terminal is a personwaiting for their own command; over HTTP it is a request to serialise the store into one response.
No search-as-you-type. There is no index — the store reads the pads it looks at — so ten
keystrokes would be ten walks over every pad.
Results are never repainted under the reader. The default order is by pad activity, so a silent
refresh slides a whole group to the top of what someone is reading. When a pad moves, the page says
the results are stale and offers to run it again. Inside a pad the rule is stronger: a search there
reads the whole file, and re-running it per arriving section would turn a busy pad into a poll.
The match is highlighted from offsets the SERVER computed. Nothing re-runs the pattern in
JavaScript, because a second matcher disagrees with the first:
--word's boundaries are Unicodeclasses that
\bdoes not mean, and folding is on by default here and off in most regex dialects.Two fixes that came along, neither caused by this feature
A hit was cut from the front of the line, not around its match. Agent prose arrives as one long
line, so a word matched at character 500 was not in the row the search returned — a wrong answer
wearing the shape of a truncated one. This affected the CLI as much as the new UI, and is fixed for
both in the first commit.
The rail's tab strip was covered by the sticky toolbar.
top: 3.25remwas a guess at thetoolbar's height; it wraps to two lines and measures 88px in practice. It was hiding Outline and
Tasks already, and would have hidden the only way into the new tab. The offset is now measured and
published as
--pad-toolbar-h.Vendor bump
puredashboard→5d9d4f9, for a fix this PR needed. Every form-associated component mirrors awrapping
<label>onto its inner control asaria-labelledbyand mints an id when the label hasnone — from a counter declared in each component file, all sharing one
pd-label-prefix. Sothe first
<label>around a select and the first around an input were bothpd-label-1, and thesecond control announced the first one's name;
aria-labelledbyoutranksaria-label, so it couldnot be overridden from here. The search form's four-field row is exactly that shape: its Agent
field called itself "Project".
Reported upstream and fixed there (one counter in
reactive.js, which now also steps past idsalready present in the document). The workaround this repo carried is removed in the same PR rather
than left to rot.
Note that
make vendor-uisyncs the whole directory, so the bump also brings<puredashboard-copy>along. Nothing here uses it yet.
Reviewing this
Four commits, each of which builds and passes tests on its own (checked by walking them):
fix(search)feat(ui)chore(vendor)feat(ui)The order is load-bearing: the vendor bump has to precede the UI commit, which removes the
workaround.
Verification
make checkgreen.--wordsubmatch boundaries,title hits, and
TextWidth: 0.after unlock (403 → hits), the limit ceiling, and a misspelled flag being refused.
/security-reviewon the branch: no findings at or above the bar.store-wide page; deep-link opening the rail on the same query and landing on §44; the stale notice
appearing on a post and clearing on re-run; the rail tab strip clear of the toolbar (88px measured,
8px gap).
Demo scenario
mobile-budget2v— five weeks old, holds the definition of a term the flagship restates this week.Neither "oldest first" nor cutting a hit around its match can be demonstrated on a store built five
minutes ago: one needs two pads separated in time, the other a line long enough to have a late
match.
tools/gendemo/README.mdis updated in the same commit, as that file asks.Follow-up, not in this PR
The transcript rebuilds every section node on each render (
body.replaceChildren()), which measuredas 0 of 20 nodes surviving one render, parsed markdown thrown away, and — visibly — a section the
reader had expanded snapping shut whenever an agent posts. It is a keyed list (key = section number)
that changes constantly, i.e. the case
repeat()exists for. Out of scope here: it touches the mostintricate part of that page and deserves its own before/after measurement.