samagotchi 0.126.0 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -1
- data/docs/architecture.md +1 -0
- data/docs/cli.md +1 -1
- data/docs/configuration.md +2 -2
- data/docs/internals/participants.md +13 -7
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/session_participants.rb +14 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -0
- data/lib/samagotchi/web/participant_access.rb +16 -1
- data/lib/samagotchi/web/project_list.rb +52 -0
- data/lib/samagotchi/web/public/app.js +8 -0
- data/lib/samagotchi/web/public/composer.css +12 -1
- data/lib/samagotchi/web/public/index.html +35 -16
- data/lib/samagotchi/web/public/notify.js +3 -1
- data/lib/samagotchi/web/public/participant.html +31 -31
- data/lib/samagotchi/web/public/participant.js +1 -0
- data/lib/samagotchi/web/public/project_picker.js +115 -0
- data/lib/samagotchi/web/public/restore.js +7 -4
- data/lib/samagotchi/web/public/scope.js +8 -10
- data/lib/samagotchi/web/public/send_turn.js +3 -1
- data/lib/samagotchi/web/public/stage_view.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/push/attention.rb +24 -5
- data/lib/samagotchi/worker.rb +7 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d35df5e2cd9639c1da812c221e1c256567df7e2f9986df32c6cbc49cc9d6d431
|
|
4
|
+
data.tar.gz: 7e98badd5615015b9841a9885404c130ff46fddefd05dc70bafd051ae37f92ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f5eaf2d9dca71dd6951989506b64c67ce905396c3af824521aa6fcc8e974d7a4f7ca6843f307f9915b5b5986b3ab87e2707ca76a771e985fa095a99acf9cedf
|
|
7
|
+
data.tar.gz: 65b7f35bcd7372d6c51a438c3808937266b035e8e67ac6c48511734d00f78ac12b9de3536359c67ad8797a4dc542e16a04649ece8d0dc7e8e729e6b7f60dd0a2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and commands may change between minor versions. How releases are made:
|
|
|
7
7
|
[docs/releasing.md](docs/releasing.md).
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.127.0] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- chi web's header project chip opens a picker to switch projects without starting chi web in each one.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- A participant's comment box sits in the same glass card as the owner's composer: the key hint above the text field,
|
|
19
|
+
their usage as the card's footer and the grip on its top edge; folded, the card keeps the usage line.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- A new invite link opens in a browser that still holds an ended link's cookie (behind an auth proxy it showed
|
|
24
|
+
'revoked').
|
|
25
|
+
- A Verify verdict push names an owner's verdict "You" when its author is empty or the owner's mark, not "A
|
|
26
|
+
participant", on the phone and in the tab alike.
|
|
27
|
+
- A Verify fresh check's system prompt carries no participants line when the owner asked for it, and its
|
|
28
|
+
requester's own (review or pair) when a participant did, instead of the parent session's.
|
|
29
|
+
- A Verify turn that fails fast and comes back into chi web's composer keeps its claim, so sending it again still
|
|
30
|
+
puts a verdict card on the claim.
|
|
31
|
+
|
|
32
|
+
Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
|
|
33
|
+
|
|
10
34
|
## [0.126.0] - 2026-10-27
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -4763,7 +4787,8 @@ and long-lived sessions.
|
|
|
4763
4787
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4764
4788
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4765
4789
|
|
|
4766
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4790
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.127.0...HEAD
|
|
4791
|
+
[0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
|
|
4767
4792
|
[0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
|
|
4768
4793
|
[0.125.0]: https://github.com/dm1try/samagotchi/compare/v0.124.0...v0.125.0
|
|
4769
4794
|
[0.124.0]: https://github.com/dm1try/samagotchi/compare/v0.123.0...v0.124.0
|
data/docs/architecture.md
CHANGED
|
@@ -615,6 +615,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
|
|
|
615
615
|
|---|---|---|---|---|
|
|
616
616
|
| `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
|
|
617
617
|
| `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
|
|
618
|
+
| `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
|
|
618
619
|
| `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning`, `parent_answers_warning` (a level looser than config.yml's, capped) and `first_turn_id` (the id the prompt it starts with gets for its turn; null for an `idle` create) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
|
|
619
620
|
| `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
|
|
620
621
|
| `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
|
data/docs/cli.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all] [--start-dir DIR]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: `/?scope=all`, every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
|
-
- `chi web --start-dir DIR` (config `web.start_dir`) — the folder chi web starts in, in place of the one you run it in (`~` and a relative path work; chi web never changes its own folder): its page opens on that folder's project (the printed link, a bare `/`, the LAN link and its QR code, the Home Screen app), and a new session from the all view starts there;
|
|
16
|
+
- `chi web --start-dir DIR` (config `web.start_dir`) — the folder chi web starts in, in place of the one you run it in (`~` and a relative path work; chi web never changes its own folder): its page opens on that folder's project (the printed link, a bare `/`, the LAN link and its QR code, the Home Screen app), and a new session from the all view starts there; the header's project chip switches projects. One that isn't a folder exits 1; `--start-dir` without `web` exits 2. A chi web already running in another folder keeps it (the second one says so: restart it to change it)
|
|
17
17
|
- `chi web --tunnel tailscale` — the Web UI over https on your tailnet, for your phone anywhere: Tailscale Serve (never Funnel) on this machine's `<machine>.<tailnet>.ts.net` name for this run, the owner your Tailscale login, turned off on exit (see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet)); `--tunnel` without `web` exits 2
|
|
18
18
|
- `chi web push ls [--format json]`, `chi web push rm ID`, `chi web push test [ID]` — the phones chi web pushes to (see [Phone notifications](configuration.md#phone-notifications-web-push)): list them (short id, label, origin, added, last delivery, done turns, last error; `stale` for one made on another public URL than chi web's now: never sent to), remove one, or send a test notification to ID or to every device not stale. chi web need not run; nothing printed holds a device's push address or keys. A usage error exits 2; a store others can read, a corrupt one, an unknown device or a failed test push exit 1. This machine only: `chi --remote X web push` exits 2
|
|
19
19
|
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
|
data/docs/configuration.md
CHANGED
|
@@ -1893,7 +1893,7 @@ What it needs:
|
|
|
1893
1893
|
|
|
1894
1894
|
Turning it on, on the phone: open the `Proxied:` link in Safari → Share → **Add to Home Screen** → open the **Chi**
|
|
1895
1895
|
app → tap the **bell** at the top → turn on **This device** in the sheet → Allow. A test notification arrives at once.
|
|
1896
|
-
The app opens on the project chi web starts in (`web.start_dir`; the header's
|
|
1896
|
+
The app opens on the project chi web starts in (`web.start_dir`; the header's project picker switches projects), but its
|
|
1897
1897
|
notifications are every project's: opening it closes the ones whose question or card is no longer open and sets its
|
|
1898
1898
|
badge for every project, whichever project the page shows.
|
|
1899
1899
|
In a browser tab the bell stays the tab's own notifications (while the tab is in the background), as before.
|
|
@@ -2077,7 +2077,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
|
|
|
2077
2077
|
| `web.working_set` | `true` | yes | The stage view's working set: the files, memories and URLs a turn brought into the model's context, in a rail in the stage, a footprint line under each finished turn and a `set N` chip in place of the mem chip; `false` brings the mem chip back. `?working_set=1` or `?working_set=0` on the page URL overrides it for that page load; the turn view ignores it. See [CLI: the working set](cli.md#the-working-set). |
|
|
2078
2078
|
| `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
|
|
2079
2079
|
| `web.editor` | `vscode` | yes | What a file ref in `chi web` (a file tool row's title, a diff line) opens, for a viewer on this machine: a preset (`vscode`, `vscode-insiders`, `cursor`, `zed`) or a URL template with `{path}` and an optional `{line}`; `none` turns refs off. See [Opening files from chi web](#opening-files-from-chi-web). |
|
|
2080
|
-
| `web.start_dir` | the folder `chi web` runs in | | The folder `chi web` starts in (`chi web --start-dir DIR` for one run): its page (the printed link, a bare `/`, the LAN link and QR code, the Home Screen app) opens on that folder's project, outside a repo on every session, and a new session from the all view starts there;
|
|
2080
|
+
| `web.start_dir` | the folder `chi web` runs in | | The folder `chi web` starts in (`chi web --start-dir DIR` for one run): its page (the printed link, a bare `/`, the LAN link and QR code, the Home Screen app) opens on that folder's project, outside a repo on every session, and a new session from the all view starts there; the header's project picker switches projects. `~` and a relative path (resolved from your home folder) work; one that isn't a folder stops `chi web` from starting. `chi web` never changes its own folder. See [CLI](cli.md). |
|
|
2081
2081
|
| `web.participants.expires` | `7d` | | How long a participant's link lasts unless `chi share --expires` says otherwise: `Nh` or `Nd`, 1h to 30d. See [participants](internals/participants.md). |
|
|
2082
2082
|
| `web.participants.cap` | `2.0` | | What a participant's turns, and the side calls they cause, may cost you in dollars unless `chi share --cap` says otherwise ($0 to $1000); at the cap their next comment is refused (429 `cap_reached`) and you get a notice. `chi share cap ID P DOLLARS` changes one grant's. |
|
|
2083
2083
|
| `web.participants.queue` | `3` | | How many of a participant's turns may wait behind their running one (0 to 20); one more is refused (429 `too_many_turns`). |
|
|
@@ -35,7 +35,8 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
35
35
|
retention, or archived or deleted by a process that didn't revoke, is caught by the gate: a live grant whose session
|
|
36
36
|
file is gone or archived is revoked on sight (`revoked_by: gone | archive`). Unarchiving doesn't restore a grant.
|
|
37
37
|
An ended grant stays in the store for 30 days, so its token answers `token_revoked` / `token_expired` rather than a
|
|
38
|
-
bare 401; then it goes.
|
|
38
|
+
bare 401; then it goes. The ended and bound pages also trade a link's `#code=`, so a new invite opens even in a
|
|
39
|
+
browser still holding an ended grant's cookie.
|
|
39
40
|
- **Store.** `$XDG_STATE_HOME/samagotchi/web-grants/grants.json`, next to `web-token`: the file 0600, in its own 0700
|
|
40
41
|
folder, written whole (`AtomicFile`) under a lock file (`grants.lock`) since chi web and a local `chi share` both
|
|
41
42
|
write it. Its `version` is 1, and 2 once a grant is bound (never lowered); a store above `Grants::MAX_VERSION` is a
|
|
@@ -358,10 +359,11 @@ fields: a field added to an event later reaches nobody until it is named there.
|
|
|
358
359
|
reads. `queued` counts their own queued turns on the worker; `model` is the session's model as its host is sent it
|
|
359
360
|
(the alias applied, no host name), live else saved, redacted. Every source reads the grant's own author: another
|
|
360
361
|
participant's spend, the owner's and the session's totals never reach them (the red-team spec checks the body). The
|
|
361
|
-
owner's participant view has none. The page draws it as one line
|
|
362
|
+
owner's participant view has none. The page draws it as one line in the comment box card's footer
|
|
363
|
+
(`.composer-card-foot`, `usageItems`): "$0.42 of
|
|
362
364
|
$2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
|
|
363
|
-
full queue and a link's last day, "over" at the cap.
|
|
364
|
-
|
|
365
|
+
full queue and a link's last day, "over" at the cap. The key hint is the line above the text field (hidden on a
|
|
366
|
+
phone). The usage is cut at the end (the model first); folded, it stays as the card's footer under the grip. It is redrawn at each re-read (a
|
|
365
367
|
turn's end, a queued or taken-back comment), not while a turn streams.
|
|
366
368
|
- **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
|
|
367
369
|
recap and, from the coordinator, its handoff's body: a participant's review turn reads it like the rest of the
|
|
@@ -416,7 +418,9 @@ fields: a field added to an event later reaches nobody until it is named there.
|
|
|
416
418
|
participant's invite link** in their own browser swaps their owner cookie for that grant (the page asks first). A
|
|
417
419
|
continue archives the session and revokes its grants: re-share, and links to the old session stay on it.
|
|
418
420
|
- **Verify verdict cards** (`verdict_card.js`). The built-in Verify preset carries the selected source turn id and quote
|
|
419
|
-
with the submitted turn, without changing its prompt text. A
|
|
421
|
+
with the submitted turn, without changing its prompt text. A Verify turn that fails fast and is rolled back hands
|
|
422
|
+
the claim back with its prompt (`prompt_restored`'s `verify`), so the owner's resend from the composer is a Verify
|
|
423
|
+
turn again. A completed Verify reply whose final non-empty line has
|
|
420
424
|
a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
|
|
421
425
|
after the source turn, name its author, show the reason and quote, and link to the checking turn; a live completion
|
|
422
426
|
adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
|
|
@@ -483,7 +487,7 @@ fields: a field added to an event later reaches nobody until it is named there.
|
|
|
483
487
|
for a fresh verdict yet (`Attention` pushes a verdict at a turn's end).
|
|
484
488
|
- **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
|
|
485
489
|
`participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
|
|
486
|
-
double-click resets, click or drag down folds it; folded, the grip is the handle on the
|
|
490
|
+
double-click resets, click or drag down folds it; folded, the grip is the handle on the card's top edge and typing on the
|
|
487
491
|
page opens it; Annotate opens it for the visit), the one-row layout of the owner's session composer (Send at its
|
|
488
492
|
bottom right; a phone keeps it one row), and ↑/↓ over the participant's own comments: the ones the server took from
|
|
489
493
|
this browser, in localStorage under `chi_review_history:<grant id>` (`review_history.js`; the grant id is the review
|
|
@@ -630,7 +634,9 @@ fixture loads and saves unchanged, `spec/turn_author_record_spec.rb`).
|
|
|
630
634
|
worker's Engine at each turn's start), the system prompt's volatile tail opens with "Participants: people other
|
|
631
635
|
than the owner review this session. …" (`SystemPrompt::PARTICIPANTS_LINE`), or in a working session with
|
|
632
636
|
`PAIR_PARTICIPANTS_LINE`, which names the pair header (and the review one, for turns stamped before). It comes
|
|
633
|
-
when the first joins and goes when the last leaves or expires.
|
|
637
|
+
when the first joins and goes when the last leaves or expires. A Verify fresh child reads its parent's
|
|
638
|
+
`participants.json` but has one participant at most: its requester, under their saved mode while their grant is
|
|
639
|
+
active; the owner's child has no line (`SessionParticipants#prompt_mode`).
|
|
634
640
|
- **Records.** `last_turn.author`, the analytics turn record's `author`, `turn_enqueued`'s `author`, and the
|
|
635
641
|
Bridge's queued rows. `turn_started` carries `author`, `author_name` and `participant_mode` on a participant's turn
|
|
636
642
|
(the owner's carries none), and so does the Bridge's turn snapshot and its replay. The owner's history
|
data/docs/sessions.md
CHANGED
|
@@ -91,10 +91,12 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
91
91
|
|
|
92
92
|
**Ordering:**
|
|
93
93
|
|
|
94
|
+
- The web header's project chip opens a picker. The start project is the default row (including its start folder); other rows show their root, session and waiting counts. **All projects** opens every session, and choosing a project drops the open-session hash while keeping `?view=`.
|
|
95
|
+
|
|
94
96
|
- `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
|
|
95
97
|
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new sessions start in that folder; the header chip says `<project> · all`, and `all` opens the same place as `?scope=all` (every session; a new session there starts in chi web's start folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the start folder's project). A bare `/` (the LAN link, the Home Screen app) is the start folder's project too: the folder chi web runs in, or `web.start_dir` / `chi web --start-dir DIR`; outside a repo it shows every session. A pushed notification opens its session in that session's own project (`/?dir=<its project>#/s/<id>`; `?scope=all` outside a repo). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat, and the grip on the chat's top edge — the same thin handle as the composer's, its twin — puts them away and brings them back: a drag up (or a click, or Enter/Space on it) hides them, a drag down (or a click) shows them again, and the choice is remembered. A hidden strip leaves its status in the top bar (`N sessions · ● 1 live · ● 1 waiting`, from the cards), and a click on that pill opens All sessions, without showing the strip first. In a window under 900 px wide, where the strip scrolls sideways, the grip stays under it, outside the scroller; a window under 700 px tall hides the strip by itself, so a question or approval card in the stage keeps room, and the grip shows it until the window is tall again. A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new session. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new session starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new session only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
96
98
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
97
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
99
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`), `markdown_place.js` (where rendered markdown and its refs go: a text as markdown HTML, its `path:line` refs, a final answer's rendering), `images.js` (image chips in the composer: paste, drop and the image button, the upload before a turn, the thumbnails and the lightbox), `composer_grip.js` (the composer's grip and fold: the drag, tap and keys, the fold per visit), `composer_mount.js` (the composer mounted on a page's markup: the message box and its height, the image chips from a paste, a drop or the image button, the grip and fold, ↑/↓ over the prompt history and the keys; the page's own parts come in as hooks: what Enter and Send do, keys taken first, Esc, where the history comes from, the info bar's Stop turn and drop while folded, typing anywhere to open it; its styles, the card it sits in (`.composer-card`, its footer and its line above the text field) included, are the shared `composer.css`, linked after `tokens.css` by `index.html` and `participant.html`), `strip_grip.js` (the latest-sessions strip's grip, the composer grip's twin: hides and shows the strip), `review_history.js` (a participant's ↑/↓ list on the participant page: their own comments, in localStorage per grant, never the owner's `/api/history`; `participant.js` mounts `composer_mount.js` with it, no `/` list, no delivery choices, images off), `storage.js` (the page's per-browser choices in localStorage, guarded: a choice that can't be read or written is not remembered), `unqueue.js` (the × on a message queued for after the turn: takes it back, its text into an empty composer or a toast), `toasts.js` (the page's toast stack: a timer each, sticky ones until acted on, the same text reusing its item), `toast_place.js` (where the toast sits: above the composer card, the stage and an open model panel), `dock_reserve.js` (what floats over the history: the history's bottom reserve (`--dock-h`) at the composer card's and the showing stage's height, a reader at the end kept there, and the toast's place measured for `toast_place.js`), `notice_links.js` (a hook notice line's http(s) URLs as links), `start_page.js` (the start page's model picker and its LLM context and thinking chips, a choice for the next new session only; a fresh chain link's picker and its `/model`), `session_actions.js` (Continue, Archive and its Undo, Delete and Stop, and the start page when the open session is gone), `send_turn.js` (the composer's send: a new session first on the start page, the route, the echo, the image upload, the `/turn` ack, a fast failure's restore and each error's place). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
98
100
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
99
101
|
- The web session page's message box folds away for room to read. Drag the grip on its top edge (the pill that shows on hover; always on a phone) down, past the box by 32 px: the pill turns accent while releasing would hide it, and on release the box folds and the info bar stays as the page's bottom edge. The grip stays as a handle on the info bar's top edge, drawn always: pull it up (8 px opens it, and the box follows the pointer in the same gesture), click or tap it (opens it and focuses the box), or just type on the page (the key lands in the box) to bring it back; Enter or Space on the focused grip folds or opens too. A draft (text or images, which folding never clears) colours the handle with the accent, a session waiting on you with the attention colour; its tooltip says so. Stop turn moves into the info bar while it is folded. The fold is remembered per browser (`chi_composer_collapsed`), an archived session opens folded for the visit, and opening it there, like the page opening it itself (a restored line, a quote, continue →'s opener), is for that visit only. The fold keeps the height the grip set (`chi_composer_floor`): the box opens again at the height it had, and only a double-click on the grip forgets it. That height never takes more than half of the window the box is drawn in: a floor saved in a bigger window is held to that half in a smaller one (a rotated phone, a resized window, another device on the same origin), and the saved floor itself stays, so the height comes back when the room does. A click on the expanded grip hides the box as well: it folds 250 ms after the press, so a second click inside that window is read as a double-click and resets the height instead (a drag, Enter or Space act at once, and a drag after a click takes the wait back). The start page's grip only resizes.
|
|
100
102
|
|
|
@@ -54,16 +54,29 @@ module Samagotchi
|
|
|
54
54
|
# The system prompt's participants line while anyone is in it (#active?
|
|
55
55
|
# as of the last #refresh!): ParticipantMode::PAIR when +session+ is a
|
|
56
56
|
# working session and an active participant was invited to act, else
|
|
57
|
-
# ParticipantMode::REVIEW; nil with nobody active.
|
|
57
|
+
# ParticipantMode::REVIEW; nil with nobody active. A Verify fresh
|
|
58
|
+
# child reads its parent's sidecar but has one participant at most, its
|
|
59
|
+
# requester: their saved mode while their grant is active, nil for the
|
|
60
|
+
# owner's child (no participant ever writes in it).
|
|
58
61
|
def prompt_mode(session)
|
|
59
62
|
active = @entries.select { |entry| entry.active?(@clock.call) }
|
|
60
63
|
return nil if active.empty?
|
|
61
64
|
|
|
65
|
+
fresh = session.respond_to?(:verify_fresh) && session.verify_fresh
|
|
66
|
+
return fresh_prompt_mode(fresh, active) if fresh
|
|
67
|
+
|
|
62
68
|
active.any? { |entry| ParticipantMode.for_turn(session, entry) == ParticipantMode::PAIR } ? ParticipantMode::PAIR : ParticipantMode::REVIEW
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
private
|
|
66
72
|
|
|
73
|
+
def fresh_prompt_mode(fresh, active)
|
|
74
|
+
return nil unless fresh.participant?
|
|
75
|
+
|
|
76
|
+
id = Steer.participant_id(fresh.author)
|
|
77
|
+
active.any? { |entry| entry.id == id } ? fresh.turn_mode : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
67
80
|
def source_id(session)
|
|
68
81
|
return nil unless session
|
|
69
82
|
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -60,6 +60,7 @@ require_relative "editor"
|
|
|
60
60
|
require_relative "../message_parts"
|
|
61
61
|
require_relative "../launch_mode"
|
|
62
62
|
require_relative "session_summary"
|
|
63
|
+
require_relative "project_list"
|
|
63
64
|
require_relative "session_access_routes"
|
|
64
65
|
require_relative "principal"
|
|
65
66
|
require_relative "participant_access"
|
|
@@ -240,6 +241,7 @@ module Samagotchi
|
|
|
240
241
|
ROUTES = [
|
|
241
242
|
["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
|
|
242
243
|
["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
|
|
244
|
+
["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
|
|
243
245
|
["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
|
|
244
246
|
["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
|
|
245
247
|
["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
|
|
@@ -1046,6 +1048,16 @@ module Samagotchi
|
|
|
1046
1048
|
[200, headers, [body]]
|
|
1047
1049
|
end
|
|
1048
1050
|
|
|
1051
|
+
def handle_projects(req)
|
|
1052
|
+
return error_response(503, "no_hub", "this chi web has no session hub") unless @hub
|
|
1053
|
+
|
|
1054
|
+
dir, error = scope_dir(req.params["dir"])
|
|
1055
|
+
return error if error
|
|
1056
|
+
|
|
1057
|
+
current_root = dir && ProjectScope.root_for(dir)
|
|
1058
|
+
json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1049
1061
|
# A scope folder from the page: [dir, nil] (dir nil when none was
|
|
1050
1062
|
# given), or [nil, a 400 response] when it isn't an existing absolute
|
|
1051
1063
|
# folder (a bookmarked worktree deleted since, a typo).
|
|
@@ -490,7 +490,22 @@ module Samagotchi
|
|
|
490
490
|
<style>#{PAGE_STYLE}</style></head>
|
|
491
491
|
<body>
|
|
492
492
|
<h1>chi</h1>
|
|
493
|
-
<p>{sentence}</p>
|
|
493
|
+
<p id="ended">{sentence}</p>
|
|
494
|
+
<script>
|
|
495
|
+
if (/[#&]code=/.test(location.hash)) document.getElementById("ended").hidden = true;
|
|
496
|
+
</script>
|
|
497
|
+
<p id="trade" hidden></p>
|
|
498
|
+
<script type="module">
|
|
499
|
+
const ended = document.getElementById("ended");
|
|
500
|
+
import("/token_fragment.js").then(({ tradeFragmentCode }) => tradeFragmentCode({
|
|
501
|
+
onRefused: (words) => {
|
|
502
|
+
const line = document.getElementById("trade");
|
|
503
|
+
line.textContent = words;
|
|
504
|
+
line.hidden = false;
|
|
505
|
+
ended.hidden = false;
|
|
506
|
+
}
|
|
507
|
+
})).catch(() => { ended.hidden = false; });
|
|
508
|
+
</script>
|
|
494
509
|
</body></html>
|
|
495
510
|
HTML
|
|
496
511
|
BOUND_PAGE = ENDED_PAGE.sub("<title>chi: review link ended</title>", "<title>chi: review link for another account</title>")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../paths"
|
|
4
|
+
require_relative "../project_scope"
|
|
5
|
+
require_relative "push/attention"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Web
|
|
9
|
+
# Projects represented by the session hub, plus the start project even
|
|
10
|
+
# when it has no sessions.
|
|
11
|
+
module ProjectList
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(summaries, start_dir:, current_root:)
|
|
15
|
+
start_dir = File.expand_path(start_dir.to_s)
|
|
16
|
+
default_root = ProjectScope.root_for(start_dir)
|
|
17
|
+
groups = {}
|
|
18
|
+
summaries.each do |summary|
|
|
19
|
+
next unless summary.is_a?(Hash) && !summary[:archived]
|
|
20
|
+
|
|
21
|
+
root = summary[:project_root]
|
|
22
|
+
next unless valid_root?(root)
|
|
23
|
+
|
|
24
|
+
(groups[root] ||= []) << summary
|
|
25
|
+
end
|
|
26
|
+
groups[default_root] ||= [] if valid_root?(default_root)
|
|
27
|
+
|
|
28
|
+
rows = groups.map do |root, sessions|
|
|
29
|
+
default = root == default_root
|
|
30
|
+
{
|
|
31
|
+
name: File.basename(root),
|
|
32
|
+
root: root,
|
|
33
|
+
root_label: Paths.tilde(root),
|
|
34
|
+
dir: default ? start_dir : root,
|
|
35
|
+
sessions: sessions.size,
|
|
36
|
+
waiting: sessions.count { |summary| Push::Attention.waits?(summary) },
|
|
37
|
+
updated_at: sessions.map { |summary| summary[:updated_at] }.compact.max_by(&:to_s),
|
|
38
|
+
default: default,
|
|
39
|
+
current: root == current_root
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
defaults, others = rows.partition { |row| row[:default] }
|
|
43
|
+
defaults + others.sort_by { |row| [row[:updated_at].to_s, row[:root]] }.reverse
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def valid_root?(root)
|
|
47
|
+
root && File.directory?(root) && ProjectScope.root_for(root) == root
|
|
48
|
+
end
|
|
49
|
+
private_class_method :valid_root?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
claimFromHash, ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf,
|
|
79
79
|
} from "./claim_link.js";
|
|
80
80
|
import { scopeDir, scopeTemplate } from "./scope.js";
|
|
81
|
+
import { createProjectPicker } from "./project_picker.js";
|
|
81
82
|
import { html, render } from "./vendor/lit-html.js";
|
|
82
83
|
import { shouldFollowScroll } from "./scroll.js";
|
|
83
84
|
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
@@ -609,6 +610,11 @@ function renderScope() {
|
|
|
609
610
|
updateSessionTitle();
|
|
610
611
|
}
|
|
611
612
|
|
|
613
|
+
createProjectPicker({
|
|
614
|
+
el: $("#scope"), scope, scopeError: () => scopeError, api,
|
|
615
|
+
where: () => ({ search: location.search }),
|
|
616
|
+
});
|
|
617
|
+
|
|
612
618
|
// The session lists (lists_view.js): the strip, all sessions, their cards,
|
|
613
619
|
// select mode, the family and chain popovers.
|
|
614
620
|
const lists = createListsView({
|
|
@@ -1604,6 +1610,7 @@ const streamHandlers = createStreamHandlers({
|
|
|
1604
1610
|
participantsControl,
|
|
1605
1611
|
placeApply,
|
|
1606
1612
|
placeVerdict,
|
|
1613
|
+
restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
|
|
1607
1614
|
sentIds,
|
|
1608
1615
|
earlyRestores,
|
|
1609
1616
|
stream: {
|
|
@@ -1711,6 +1718,7 @@ const sendTurnControl = createSendTurn({
|
|
|
1711
1718
|
api: { createIdleSession, uploadImage, sendTurn },
|
|
1712
1719
|
verify: () => pendingVerifyAnchor,
|
|
1713
1720
|
clearVerify: (anchor) => { if (pendingVerifyAnchor === anchor) pendingVerifyAnchor = null; },
|
|
1721
|
+
restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
|
|
1714
1722
|
dir: scope.dir,
|
|
1715
1723
|
startPage,
|
|
1716
1724
|
takeDelivery: () => deliveryControl.take(),
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/* The composer (composer_mount.js): the message box, its image chips, grip and fold, its key hint and its toolbar row
|
|
2
2
|
(the image button, Send). Shared by the owner's page (index.html) and the participant page (participant.html);
|
|
3
3
|
linked after tokens.css and before a page's own <style>, so a page's rules of the same weight win. The owner's
|
|
4
|
-
own parts stay in index.html: the card (#dock), the delivery line, Stop turn, the start page's picker and chips,
|
|
4
|
+
own parts stay in index.html: the card's placing (#dock), the delivery line's links, Stop turn, the start page's picker and chips,
|
|
5
5
|
the / list, and the 16px text fields for iOS. */
|
|
6
6
|
/* The image button's icon mask (the stroke only shapes the mask; the icon takes currentColor); index.html's phone info
|
|
7
7
|
bar and all view's cards use it too. */
|
|
8
8
|
:root{--icon-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
|
|
9
|
+
/* The composer card: the glass card both pages' composers sit in (the owner's #dock, the stage view's .dock-card, the
|
|
10
|
+
participant page's .p-card), with its focus ring; its footer (the owner's info bar, the participant's usage) under
|
|
11
|
+
a hairline; and the composer's line, the quiet row above the text field (the owner's delivery line, the
|
|
12
|
+
participant's key hint). A page's own rules only place the card. */
|
|
13
|
+
.composer-card{display:flex;flex-direction:column;border-radius:16px;position:relative;background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);transition:border-color .12s ease,box-shadow .12s ease}
|
|
14
|
+
.composer-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
|
|
15
|
+
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.composer-card{background:var(--bg-panel)}}
|
|
16
|
+
@media (prefers-reduced-transparency:reduce){.composer-card{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
|
|
17
|
+
body.no-glass .composer-card{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
18
|
+
.composer-card-foot{padding:8px 14px 10px;border-top:1px solid var(--glass-border)}
|
|
19
|
+
.composer-line{flex:1 0 100%;min-width:0;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
|
|
9
20
|
#composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
|
|
10
21
|
#composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
11
22
|
#composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
|
23
23
|
body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
|
|
24
24
|
/* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
|
|
25
|
-
|
|
25
|
+
.turn-stage,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
|
|
26
26
|
#topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}
|
|
27
27
|
#topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
|
|
28
28
|
/* A parent whose folded family holds the open session: a quieter mark than .active. */
|
|
@@ -30,9 +30,9 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
|
|
|
30
30
|
/* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
|
|
31
31
|
#topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
|
|
32
32
|
#topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}
|
|
33
|
-
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
|
|
34
|
-
@media (prefers-reduced-transparency:reduce){
|
|
35
|
-
body.no-glass
|
|
33
|
+
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
|
|
34
|
+
@media (prefers-reduced-transparency:reduce){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
|
|
35
|
+
body.no-glass .turn-stage,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
36
36
|
body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
37
37
|
body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
|
|
38
38
|
button,input,textarea,select{font:inherit}
|
|
@@ -566,8 +566,8 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
|
|
|
566
566
|
.bubble.output.markdown .code-wrap>pre{margin:0}
|
|
567
567
|
.code-wrap>.copy-btn{top:5px;right:5px}
|
|
568
568
|
@media (hover:none){.copy-btn{opacity:.55}}
|
|
569
|
-
/* The dock is the composer card: the text field, a toolbar row, the info bar as its footer. */
|
|
570
|
-
#dock{
|
|
569
|
+
/* The dock is the composer card (composer.css .composer-card): the text field, a toolbar row, the info bar as its footer. */
|
|
570
|
+
#dock{flex-shrink:0;margin:0 12px 12px}
|
|
571
571
|
/* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from dock_reserve.js). */
|
|
572
572
|
/* The session panel lets the page's glow through (the history's own layers go clear), so the card and its footer read as glass over it, not as a flat box on black. */
|
|
573
573
|
body.has-session #center{background:color-mix(in srgb,var(--bg) 35%,transparent);border-color:var(--glass-border);position:relative}
|
|
@@ -576,9 +576,8 @@ body.has-session #stage,body.has-session #history{background:transparent}
|
|
|
576
576
|
body.no-glass.has-session #center{background:var(--bg);border-color:var(--border)}
|
|
577
577
|
body.has-session #dock{position:absolute;left:16px;right:16px;bottom:16px;margin:0;z-index:3}
|
|
578
578
|
body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
579
|
-
#dock:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
|
|
580
579
|
/* The delivery line (delivery_line.js): a session's row above the text field, always there so the composer never changes height: the key hint while idle; while a turn runs, where a message typed now goes in, then the cut and queue links. One quiet row, ellipsised; a phone gets the short text and chips. */
|
|
581
|
-
#deliveryLine{display:flex;align-items:center;gap:10px
|
|
580
|
+
#deliveryLine{display:flex;align-items:center;gap:10px}
|
|
582
581
|
#deliveryLine[hidden]{display:none}
|
|
583
582
|
#deliveryLine .delivery-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
584
583
|
#deliveryLine .delivery-where.idle{color:var(--text-faint)}
|
|
@@ -672,8 +671,6 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
|
|
|
672
671
|
/* D2: its own glass card above the composer card (as a section of one card the composer's focus ring wrapped the turn too):
|
|
673
672
|
in the stage view #dock is a clear column holding the stage and .dock-card (the composer and its footer, stage_view.js). */
|
|
674
673
|
body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;border:0;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;gap:10px}
|
|
675
|
-
.dock-card{display:flex;flex-direction:column;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
|
|
676
|
-
.dock-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
|
|
677
674
|
/* The live slots' heights (headline, tool row, trail); --ts-live-h is their running height with the two 8 px gaps. */
|
|
678
675
|
.turn-stage{--ts-head-h:42px;--ts-now-h:32px;--ts-trail-h:18px;--ts-live-h:calc(var(--ts-head-h) + var(--ts-now-h) + var(--ts-trail-h) + 16px)}
|
|
679
676
|
.turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
|
|
@@ -995,7 +992,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
|
995
992
|
.ts-trail-item{max-width:24ch}
|
|
996
993
|
}
|
|
997
994
|
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
|
|
998
|
-
#infoBar{display:flex;
|
|
995
|
+
#infoBar{display:flex;background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
|
|
999
996
|
#infoBar.visible{display:flex}
|
|
1000
997
|
/* The meta row keeps its room: with a plain flex:1 the bar's own buttons (continue, archive, stop session, delete)
|
|
1001
998
|
shrink it to a narrow column in a small window and its items wrap into five lines. With a 640px basis they share
|
|
@@ -1268,13 +1265,35 @@ body.has-session #newBtn .brand-new{display:inline}
|
|
|
1268
1265
|
/* The scope chip: "<project> · all", or "all projects" and where a new session starts (start page only). */
|
|
1269
1266
|
#scope{display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;color:var(--text-muted)}
|
|
1270
1267
|
#scope[hidden]{display:none}
|
|
1271
|
-
#scope .scope-name{flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw}
|
|
1268
|
+
#scope .scope-name{display:inline-flex;align-items:center;flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;background:transparent;color:var(--text-2);font:inherit;line-height:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw;cursor:pointer;transition:none}
|
|
1269
|
+
#scope .scope-name:hover:not(:disabled){filter:none}
|
|
1272
1270
|
#scope .scope-name.error{color:var(--warn-text);border-color:var(--warn-border)}
|
|
1273
1271
|
#scope .scope-all{color:var(--text-muted);text-decoration:none}
|
|
1274
1272
|
#scope .scope-all:hover{color:var(--accent);text-decoration:underline}
|
|
1275
1273
|
#scope .scope-where{display:inline-flex;gap:5px;min-width:0;white-space:nowrap;font-size:11px}
|
|
1276
1274
|
/* The path cuts on the left, so its last folders stay in view. */
|
|
1277
1275
|
#scope .scope-where .path{overflow:hidden;text-overflow:ellipsis;direction:rtl;min-width:0;font-family:ui-monospace,monospace}
|
|
1276
|
+
#projectPicker{width:min(380px,calc(100vw - 32px))}
|
|
1277
|
+
.project-picker.sheet{border-radius:14px;padding:14px 16px}
|
|
1278
|
+
.project-picker .ctx-pop-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
1279
|
+
.project-picker .ctx-pop-head strong{font-family:inherit}
|
|
1280
|
+
.project-list{display:flex;flex-direction:column;gap:6px;margin:2px 0;padding:0}
|
|
1281
|
+
.project-row{display:flex;align-items:center;gap:12px;padding:8px 10px;border:1px solid transparent;border-radius:9px;background:var(--bg-inset);color:var(--text);text-decoration:none}
|
|
1282
|
+
.project-row:hover{background:var(--bg-hover);color:var(--text-hover)}
|
|
1283
|
+
.project-row:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
1284
|
+
.project-row[aria-current="page"]{border-color:var(--accent-border);background:var(--accent-bg)}
|
|
1285
|
+
.project-main{display:flex;flex:1;align-items:center;gap:7px;min-width:0;flex-wrap:wrap}
|
|
1286
|
+
.project-name{font-weight:600;overflow-wrap:anywhere}
|
|
1287
|
+
.project-root{flex-basis:100%;min-width:0;color:var(--text-muted);font:11px ui-monospace,monospace;overflow-wrap:anywhere}
|
|
1288
|
+
.project-meta{display:flex;flex-shrink:0;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;white-space:nowrap}
|
|
1289
|
+
.project-default{padding:1px 6px;border-radius:999px;background:var(--bg-raised);color:var(--text-muted);font-size:10px}
|
|
1290
|
+
.project-check{color:var(--accent);font-weight:700}
|
|
1291
|
+
.project-hint{flex-basis:100%;color:var(--text-muted);font-size:12px}
|
|
1292
|
+
/* The divider is its own hairline above the row: a border-top on the rounded row curved up at both ends. */
|
|
1293
|
+
.project-all{position:relative;margin-top:12px;background:transparent}
|
|
1294
|
+
.project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
|
|
1295
|
+
.project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
|
|
1296
|
+
.project-error{color:var(--warn-text)}
|
|
1278
1297
|
body.has-session #scope .scope-where{display:none}
|
|
1279
1298
|
/* A phone has no room for both; the hidden strip's pill (count, live) wins. */
|
|
1280
1299
|
@media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
|
|
@@ -1525,7 +1544,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
1525
1544
|
.card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
|
|
1526
1545
|
@keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
|
|
1527
1546
|
@media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting,.family-row .dot.running,.family-row .dot.waiting{animation:none}}
|
|
1528
|
-
.card .attn,.family-row .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
|
|
1547
|
+
.card .attn,.family-row .attn,.project-picker .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
|
|
1529
1548
|
.card .stopped-badge,.family-row .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
|
|
1530
1549
|
.status.running{background:var(--ok-bg);color:var(--ok-text)}
|
|
1531
1550
|
.status.idle{background:var(--bg-raised)}
|
|
@@ -1569,7 +1588,7 @@ a{color:var(--accent)}
|
|
|
1569
1588
|
<div id="notice" role="status" hidden></div>
|
|
1570
1589
|
</div>
|
|
1571
1590
|
<div id="lightbox" hidden><img alt=""></div>
|
|
1572
|
-
<div id="dock">
|
|
1591
|
+
<div id="dock" class="composer-card">
|
|
1573
1592
|
<div id="initRow" role="status" aria-live="polite" hidden></div>
|
|
1574
1593
|
<div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
|
|
1575
1594
|
<div id="composer" style="position:relative">
|
|
@@ -1578,7 +1597,7 @@ a{color:var(--accent)}
|
|
|
1578
1597
|
<label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
|
|
1579
1598
|
<div id="modelList" role="listbox" aria-label="Models"></div>
|
|
1580
1599
|
</div>
|
|
1581
|
-
<div id="deliveryLine" aria-live="off" hidden></div>
|
|
1600
|
+
<div id="deliveryLine" class="composer-line" aria-live="off" hidden></div>
|
|
1582
1601
|
<div class="compose-col">
|
|
1583
1602
|
<div id="chips" hidden></div>
|
|
1584
1603
|
<textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
|
|
@@ -1597,7 +1616,7 @@ a{color:var(--accent)}
|
|
|
1597
1616
|
</div>
|
|
1598
1617
|
</div>
|
|
1599
1618
|
</div>
|
|
1600
|
-
<div id="infoBar">
|
|
1619
|
+
<div id="infoBar" class="composer-card-foot">
|
|
1601
1620
|
<div class="info" id="infoText"></div>
|
|
1602
1621
|
<div id="contextChips" aria-label="Attached context" hidden></div>
|
|
1603
1622
|
<button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
@@ -163,7 +163,9 @@ export function attentionText(session, attention) {
|
|
|
163
163
|
const title = clip(name, 61);
|
|
164
164
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
165
165
|
const verdict = session?.last_verdict;
|
|
166
|
-
|
|
166
|
+
// An owner's verdict has no participant author (none, "" or "owner").
|
|
167
|
+
const byParticipant = !!verdict?.by && verdict.by !== "owner";
|
|
168
|
+
const who = verdict?.by_name || (byParticipant ? "A participant" : "You");
|
|
167
169
|
const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
|
|
168
170
|
const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
|
|
169
171
|
const verdictBody = `${who}: ${verdictIcon} ${verdict?.verdict || ""} — ${verdictReason}`;
|
|
@@ -105,42 +105,42 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
|
105
105
|
.p-tool-one.error{color:var(--danger-text)}
|
|
106
106
|
.p-tag{font-size:11px;padding:0 6px;border-radius:999px;background:var(--bg-raised);color:var(--text-3)}
|
|
107
107
|
.p-waiting{align-self:flex-start;font-size:13px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:999px;padding:3px 12px}
|
|
108
|
-
|
|
108
|
+
/* The dock is the owner's composer card (index.html's #dock): a glass card over the page's bottom edge, holding the
|
|
109
|
+
comment box (key hint, text field, usage footer); the queue and errors sit above it. */
|
|
110
|
+
.p-dock{padding:6px 16px calc(16px + env(safe-area-inset-bottom))}
|
|
109
111
|
.p-dock-in{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
|
|
110
112
|
.p-queue{display:flex;flex-direction:column;gap:4px;font-size:13px}
|
|
111
113
|
.p-queued{display:flex;align-items:center;gap:8px;color:var(--text-3)}
|
|
112
114
|
.p-queued span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
113
115
|
.p-queue-note{font-size:12.5px;color:var(--text-muted)}
|
|
114
116
|
.p-error{font-size:13px;color:var(--danger-text);background:var(--danger-bg-dim);border-radius:8px;padding:6px 10px}
|
|
115
|
-
/* The comment box: the shared composer (composer.css, composer_mount.js), as the owner's session
|
|
116
|
-
(participant.js sets body.has-session for a participant:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#composer:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
|
|
121
|
-
body.has-session #composer{padding:6px 6px 6px 10px}
|
|
117
|
+
/* The comment box: the shared composer in the shared card (composer.css, composer_mount.js), as the owner's session
|
|
118
|
+
composer lays it out (participant.js sets body.has-session for a participant): the key hint as the composer's line,
|
|
119
|
+
the text field and Send, the usage as the card's footer. Its grip sits on the card's top edge, as the owner's does;
|
|
120
|
+
folded, the card keeps its footer under the grip, the owner's info bar's way. No `/` list, delivery choices or model
|
|
121
|
+
picker here. */
|
|
122
122
|
#composer textarea{font:inherit;line-height:1.45;padding:2px 0}
|
|
123
123
|
#composer textarea:disabled{cursor:not-allowed}
|
|
124
124
|
#composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
.p-foot
|
|
130
|
-
|
|
125
|
+
/* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line in
|
|
126
|
+
the card's footer, the end cut. Folded, it stays under the grip. */
|
|
127
|
+
.p-hint.composer-line{color:var(--text-faint);white-space:nowrap;text-overflow:ellipsis}
|
|
128
|
+
.p-foot{min-width:0}
|
|
129
|
+
.p-foot:has(> .p-usage[hidden]){display:none}
|
|
130
|
+
/* Folded before the usage loads, the card keeps a footer's height under its grip, not a 2 px sliver. */
|
|
131
|
+
body.composer-folded .p-foot:has(> .p-usage[hidden]){display:block;min-height:18px}
|
|
131
132
|
.p-usage{display:flex;align-items:baseline;min-width:0;max-width:100%;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
132
133
|
.p-usage span{flex:0 0 auto}
|
|
133
134
|
.p-usage span+span::before{content:"·";padding:0 6px;color:var(--text-faint)}
|
|
134
135
|
.p-usage span.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
135
136
|
.p-usage .warn{color:var(--warn-text)}
|
|
136
137
|
.p-usage .over{color:var(--danger-text);font-weight:600}
|
|
137
|
-
body.composer-folded .p-foot{
|
|
138
|
+
body.composer-folded .p-foot{border-top-color:transparent}
|
|
138
139
|
button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text);padding:6px 12px}
|
|
139
140
|
button:hover{background:var(--bg-hover)}
|
|
140
141
|
button.primary{background:var(--primary);border-color:var(--primary);color:var(--on-solid);height:42px;padding:0 18px}
|
|
141
142
|
button.primary:disabled{opacity:.5;cursor:default}
|
|
142
143
|
button.small{font-size:12px;padding:2px 8px}
|
|
143
|
-
.p-hint{font-size:12px;color:var(--text-faint)}
|
|
144
144
|
.p-ended{max-width:30rem;margin:18vh auto 0;padding:0 16px;text-align:center;display:flex;flex-direction:column;gap:10px}
|
|
145
145
|
.p-ended h1{font-size:20px;color:var(--text-bright)}
|
|
146
146
|
.p-ended p{color:var(--text-3)}
|
|
@@ -155,8 +155,6 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
155
155
|
}
|
|
156
156
|
/* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
|
|
157
157
|
@media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
|
|
158
|
-
/* A phone's grip (composer.css: 26 px tall, the pill at 17 px): the pill on the dock's top border. */
|
|
159
|
-
@media (max-width:600px){.p-dock #composeGrip{margin:-29px auto 3px}}
|
|
160
158
|
</style>
|
|
161
159
|
</head>
|
|
162
160
|
<body>
|
|
@@ -185,25 +183,27 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
185
183
|
<div class="p-thread" id="thread"></div>
|
|
186
184
|
</main>
|
|
187
185
|
<footer class="p-dock" id="dock" hidden>
|
|
188
|
-
<div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
|
|
189
186
|
<div class="p-dock-in">
|
|
190
187
|
<div class="p-queue" id="queue"></div>
|
|
191
188
|
<div class="p-error" id="error" role="alert" hidden></div>
|
|
192
|
-
<div
|
|
193
|
-
<div class="
|
|
194
|
-
|
|
195
|
-
<
|
|
189
|
+
<div class="p-card composer-card">
|
|
190
|
+
<div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
|
|
191
|
+
<div id="composer">
|
|
192
|
+
<div class="p-hint composer-line" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
|
|
193
|
+
<div class="compose-col">
|
|
194
|
+
<div id="chips" hidden></div>
|
|
195
|
+
<textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="actions-col">
|
|
198
|
+
<button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
|
|
199
|
+
<input type="file" id="attachInput" accept="image/*" multiple hidden>
|
|
200
|
+
<div class="split"><button type="button" class="primary" id="send">Send</button></div>
|
|
201
|
+
</div>
|
|
196
202
|
</div>
|
|
197
|
-
<div class="
|
|
198
|
-
<
|
|
199
|
-
<input type="file" id="attachInput" accept="image/*" multiple hidden>
|
|
200
|
-
<div class="split"><button type="button" class="primary" id="send">Send</button></div>
|
|
203
|
+
<div class="p-foot composer-card-foot">
|
|
204
|
+
<div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
|
|
201
205
|
</div>
|
|
202
206
|
</div>
|
|
203
|
-
<div class="p-foot">
|
|
204
|
-
<div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
|
|
205
|
-
<div class="p-hint" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
|
|
206
|
-
</div>
|
|
207
207
|
</div>
|
|
208
208
|
</footer>
|
|
209
209
|
</div>
|
|
@@ -234,6 +234,7 @@ function render() {
|
|
|
234
234
|
const copy = modeCopy(viewer.mode);
|
|
235
235
|
$("note").textContent = ownerView ? "" : copy.note;
|
|
236
236
|
$("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
|
|
237
|
+
$("hint").title = $("hint").textContent; // one line in the card: the whole text on hover
|
|
237
238
|
$("head").hidden = false;
|
|
238
239
|
$("main").hidden = false;
|
|
239
240
|
renderPlan();
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The project picker opened by the top-bar scope chip. The server supplies
|
|
2
|
+
// the project roots and their current/default marks; links carry the view
|
|
3
|
+
// without carrying a session hash into another project's list.
|
|
4
|
+
|
|
5
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
6
|
+
import { allScopeHref, projectScopeHref } from "./scope.js";
|
|
7
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
8
|
+
|
|
9
|
+
function homeHref(search) {
|
|
10
|
+
const view = new URLSearchParams(search || "").get("view");
|
|
11
|
+
return view ? `/?view=${encodeURIComponent(view)}` : "/";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sessionCount(count) {
|
|
15
|
+
const n = Number(count) || 0;
|
|
16
|
+
return `${n} session${n === 1 ? "" : "s"}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function projectRow(row, search) {
|
|
20
|
+
return html`<a class=${`project-row${row.default ? " default" : ""}${row.current ? " current" : ""}`}
|
|
21
|
+
href=${row.default ? homeHref(search) : projectScopeHref(row.dir, "", search)}
|
|
22
|
+
aria-current=${row.current ? "page" : nothing}>
|
|
23
|
+
<span class="project-main"><span class="project-name">${row.name}</span
|
|
24
|
+
>${row.default ? html`<span class="project-default">default</span>` : nothing
|
|
25
|
+
}<span class="project-root">${row.root_label}</span></span>
|
|
26
|
+
<span class="project-meta">${Number(row.waiting) > 0 ? html`<span class="attn">${Number(row.waiting)} waiting</span>` : nothing
|
|
27
|
+
}<span class="project-count">${sessionCount(row.sessions)}</span>${row.current ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}</span>
|
|
28
|
+
</a>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// +rows+ is the API's ordered list. When there is no project scope, the
|
|
32
|
+
// all-projects row is current; a bad ?dir= has no current project to mark.
|
|
33
|
+
export function pickerTemplate({ rows = [], loading = false, error = "", search = "", currentDir = null,
|
|
34
|
+
allCurrent = false } = {}) {
|
|
35
|
+
const allHref = allScopeHref("", currentDir, search);
|
|
36
|
+
return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
|
|
37
|
+
data-act="close" aria-label="Close">×</button></div>
|
|
38
|
+
${loading ? html`<div class="project-note" role="status">Loading projects…</div>` : nothing}
|
|
39
|
+
${error ? html`<div class="project-note project-error" role="alert">${error}</div>` : nothing}
|
|
40
|
+
<nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row, search))}
|
|
41
|
+
<a id="projectAll" class=${`project-row project-all${allCurrent ? " current" : ""}`}
|
|
42
|
+
href=${allHref} aria-current=${allCurrent ? "page" : nothing}>
|
|
43
|
+
<span class="project-main"><span class="project-name">All projects</span
|
|
44
|
+
><span class="project-hint">Every session</span></span>
|
|
45
|
+
${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
|
|
46
|
+
</a>
|
|
47
|
+
</nav>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// +scopeError+ returns the page's current folder error, if any. +where+
|
|
51
|
+
// supplies the live query string so links retain ?view= without a hash.
|
|
52
|
+
export function createProjectPicker({ el, scope, scopeError = () => null, api, where = () => ({ search: "" }),
|
|
53
|
+
doc = globalThis.document, win = globalThis.window }) {
|
|
54
|
+
let rows = [];
|
|
55
|
+
let loading = false;
|
|
56
|
+
let error = "";
|
|
57
|
+
let loads = 0;
|
|
58
|
+
|
|
59
|
+
const currentDir = () => scopeError() ? null : scope.dir || null;
|
|
60
|
+
const paint = (focus = false) => {
|
|
61
|
+
const failedScope = !!scopeError();
|
|
62
|
+
render(pickerTemplate({
|
|
63
|
+
rows, loading, error, search: where().search, currentDir: currentDir(),
|
|
64
|
+
allCurrent: !scope.dir && !failedScope,
|
|
65
|
+
}), shell.pop);
|
|
66
|
+
if (focus) {
|
|
67
|
+
const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
|
|
68
|
+
shell.pop.querySelector(".project-row");
|
|
69
|
+
target?.focus();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
async function load(focus = false) {
|
|
74
|
+
const turn = ++loads;
|
|
75
|
+
loading = true;
|
|
76
|
+
error = "";
|
|
77
|
+
if (shell.isOpen()) {
|
|
78
|
+
paint();
|
|
79
|
+
shell.replace();
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
// A dead ?dir (the folder-not-found chip) is left out: the list is the way out of it.
|
|
83
|
+
const dir = currentDir();
|
|
84
|
+
const answer = await api(`/api/projects${dir ? `?dir=${encodeURIComponent(dir)}` : ""}`);
|
|
85
|
+
if (!Array.isArray(answer)) throw new Error("the server returned an invalid project list");
|
|
86
|
+
if (turn !== loads) return;
|
|
87
|
+
rows = answer;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (turn !== loads) return;
|
|
90
|
+
error = `Can't read projects: ${e?.message || e}`;
|
|
91
|
+
rows = [];
|
|
92
|
+
}
|
|
93
|
+
if (turn !== loads) return;
|
|
94
|
+
loading = false;
|
|
95
|
+
if (shell.isOpen()) {
|
|
96
|
+
paint(focus);
|
|
97
|
+
shell.replace();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const shell = createChipPopover({
|
|
102
|
+
id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
|
|
103
|
+
summary: () => ({}), sheet: true, label: "Projects", doc, win,
|
|
104
|
+
show: () => {
|
|
105
|
+
rows = [];
|
|
106
|
+
loading = true;
|
|
107
|
+
error = "";
|
|
108
|
+
paint();
|
|
109
|
+
void load(true);
|
|
110
|
+
},
|
|
111
|
+
onClose: () => { loads += 1; },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return { pop: shell.pop, isOpen: shell.isOpen, load };
|
|
115
|
+
}
|
|
@@ -7,18 +7,21 @@ import { clientLabel, isOwn, withImages } from "./turn_events.js";
|
|
|
7
7
|
// What a prompt_restored (the worker rolled a failed turn back and handed its
|
|
8
8
|
// prompt back) does in this tab: `refill` is the text for an empty composer,
|
|
9
9
|
// only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
|
|
10
|
-
// replay after a reload never refills stale text
|
|
10
|
+
// replay after a reload never refills stale text, with a Verify turn's claim
|
|
11
|
+
// (`verify`) so a resend still records a verdict; `own`/`label` mark the
|
|
11
12
|
// failed bubble shown again after the resync.
|
|
12
13
|
export function restoreAction(event, { myId, sentIds, composerEmpty }) {
|
|
13
14
|
const origin = event?.origin || {};
|
|
14
15
|
const own = isOwn(origin.client_id, myId);
|
|
15
16
|
const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
|
|
16
|
-
// The images go back as chips with the text.
|
|
17
|
+
// The images go back as chips with the text, and its Verify claim.
|
|
18
|
+
const back = sent && composerEmpty;
|
|
17
19
|
return withImages({
|
|
18
|
-
refill:
|
|
20
|
+
refill: back ? event.prompt : null,
|
|
19
21
|
own,
|
|
20
22
|
label: own ? null : clientLabel(origin.client_id),
|
|
21
|
-
|
|
23
|
+
...(back && event.verify ? { verify: event.verify } : {}),
|
|
24
|
+
}, back ? event.images : null);
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
// A turn that fails fast can have its prompt_restored come over the stream
|
|
@@ -49,24 +49,22 @@ export function cardFolder(workingDirectory, projectName) {
|
|
|
49
49
|
return folder === projectName ? "" : folder;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// The header's
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// () => { hash, search } (the page's location, read again at a click: the
|
|
58
|
-
// links follow an open session or the all-sessions view).
|
|
52
|
+
// The header's project chip opens the picker. The "all" link remains the
|
|
53
|
+
// direct way to every session from a project view. +scope+: { dir,
|
|
54
|
+
// projectName, projectDir, serverDir, backDir, backName }; +error+: why
|
|
55
|
+
// the ?dir= folder can't be shown; +where+: () => { hash, search } (the
|
|
56
|
+
// page's location, read again at a click).
|
|
59
57
|
export function scopeTemplate(scope, { error = null, where = () => ({ hash: "", search: "" }) } = {}) {
|
|
60
58
|
const at = where();
|
|
61
59
|
const allHref = () => allScopeHref(where().hash, scope.dir, where().search);
|
|
62
60
|
const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir, at.search)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
|
|
63
|
-
if (error) return html`<
|
|
64
|
-
if (scope.projectName) return html`<
|
|
61
|
+
if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
|
|
62
|
+
if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
|
|
65
63
|
const dir = scope.dir || scope.serverDir;
|
|
66
64
|
const backHref = () => projectScopeHref(scope.backDir, where().hash, where().search);
|
|
67
65
|
const back = scope.backName
|
|
68
66
|
? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
|
|
69
67
|
: nothing;
|
|
70
68
|
const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
|
|
71
|
-
return html`<
|
|
69
|
+
return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
|
|
72
70
|
}
|
|
@@ -29,7 +29,7 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
|
|
|
29
29
|
// +alert+: the browser's.
|
|
30
30
|
export function createSendTurn({
|
|
31
31
|
session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
|
|
32
|
-
verify = () => null, clearVerify = () => {},
|
|
32
|
+
verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
|
|
33
33
|
historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
|
|
34
34
|
addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
|
|
35
35
|
leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
|
|
@@ -158,6 +158,8 @@ export function createSendTurn({
|
|
|
158
158
|
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
159
159
|
// A restored line must be seen: the composer opens for this visit.
|
|
160
160
|
if (restored?.refill && sentTo === session()) composer.openForVisit();
|
|
161
|
+
// Its Verify claim too, so the resend still records a verdict.
|
|
162
|
+
if (restored?.refill && restored.verify && sentTo === session()) restoreVerify(restored.verify);
|
|
161
163
|
const before = promptEl.value;
|
|
162
164
|
promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
|
|
163
165
|
// A line back from sentTo is a draft of that session, wherever the page is now.
|
|
@@ -66,7 +66,7 @@ export function createStageView({
|
|
|
66
66
|
// The dock becomes a clear column: the stage's card, then the composer's
|
|
67
67
|
// (its rows moved into .dock-card, which wears the glass and the focus ring).
|
|
68
68
|
document.body.classList.add("stage-view");
|
|
69
|
-
const card = node("div", "dock-card");
|
|
69
|
+
const card = node("div", "dock-card composer-card");
|
|
70
70
|
card.append(...dockEl.children);
|
|
71
71
|
dockEl.appendChild(card);
|
|
72
72
|
const stage = node("section", "turn-stage");
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// guess (takeGuess: whether, and forget it); restart:
|
|
27
27
|
// close the stream and resync
|
|
28
28
|
// composer: {empty(), refill(text), setChips(chips)}
|
|
29
|
+
// restoreVerify(anchor) a restored Verify turn's claim, armed again for its resend
|
|
29
30
|
// getSession(id, opts) data.js
|
|
30
31
|
// resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
|
|
31
32
|
// markRecapStale(), setTurnRunning(running), setLiveStatus(status),
|
|
@@ -244,8 +245,9 @@ export function createStreamHandlers(deps) {
|
|
|
244
245
|
prompt_restored: async (data) => {
|
|
245
246
|
const id = selected();
|
|
246
247
|
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
247
|
-
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
|
|
248
|
+
const { refill, label, images: restoredImages, verify } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
|
|
248
249
|
if (refill) composer.refill(refill);
|
|
250
|
+
if (refill && verify) deps.restoreVerify?.(verify);
|
|
249
251
|
if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
|
|
250
252
|
const failedText = liveTurn.failedText;
|
|
251
253
|
liveTurn.failedText = null;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../steer"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
module Web
|
|
5
7
|
module Push
|
|
@@ -118,15 +120,31 @@ module Samagotchi
|
|
|
118
120
|
# @return [Array<String>]
|
|
119
121
|
def open_keys(by_id)
|
|
120
122
|
by_id.each_value.flat_map do |summary|
|
|
121
|
-
question = summary
|
|
122
|
-
card = summary
|
|
123
|
+
question = open_question(summary)
|
|
124
|
+
card = open_card(summary)
|
|
123
125
|
keys = []
|
|
124
|
-
keys << key(summary[:id], question[:id]) if question
|
|
125
|
-
keys << card_key(summary[:id], card[:id]) if card
|
|
126
|
+
keys << key(summary[:id], question[:id]) if question
|
|
127
|
+
keys << card_key(summary[:id], card[:id]) if card
|
|
126
128
|
keys
|
|
127
129
|
end
|
|
128
130
|
end
|
|
129
131
|
|
|
132
|
+
# Whether this summary contributes to the project's waiting count:
|
|
133
|
+
# the same open question or card #open_keys badges.
|
|
134
|
+
def waits?(summary) = !!(open_question(summary) || open_card(summary))
|
|
135
|
+
|
|
136
|
+
# The summary's unanswered question not relayed elsewhere, or nil.
|
|
137
|
+
def open_question(summary)
|
|
138
|
+
question = summary[:pending_question]
|
|
139
|
+
question if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The summary's pending card, or nil.
|
|
143
|
+
def open_card(summary)
|
|
144
|
+
card = summary[:pending_card]
|
|
145
|
+
card if card.is_a?(Hash) && card[:id]
|
|
146
|
+
end
|
|
147
|
+
|
|
130
148
|
def key(session_id, id) = "#{session_id}:#{id}"
|
|
131
149
|
def card_key(session_id, card_id) = "#{session_id}:card:#{card_id}"
|
|
132
150
|
|
|
@@ -151,7 +169,8 @@ module Samagotchi
|
|
|
151
169
|
result = verdict[:verdict].to_s
|
|
152
170
|
icon = { "confirmed" => "✅", "refuted" => "❌", "unclear" => "❓" }.fetch(result, "")
|
|
153
171
|
by = verdict[:by_name]
|
|
154
|
-
|
|
172
|
+
# An owner's verdict has no participant author (nil, "" or "owner").
|
|
173
|
+
by = Steer.participant_author?(verdict[:by]) ? "A participant" : "You" unless present?(by)
|
|
155
174
|
reason = clip(verdict[:reason].to_s, 120)
|
|
156
175
|
"#{by}: #{icon} #{result} — #{reason}"
|
|
157
176
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -697,7 +697,7 @@ module Samagotchi
|
|
|
697
697
|
images: prompt.images, **(prompt.verify ? { verify: prompt.verify } : {})) do |result, error|
|
|
698
698
|
if error
|
|
699
699
|
# The Engine announced :turn_failed (with the error's one line).
|
|
700
|
-
restore_failed_turn([[prompt.text, origin, prompt.images], *@merged_this_turn], error: error)
|
|
700
|
+
restore_failed_turn([[prompt.text, origin, prompt.images, prompt.verify], *@merged_this_turn], error: error)
|
|
701
701
|
else
|
|
702
702
|
@continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
|
|
703
703
|
end
|
|
@@ -1383,8 +1383,10 @@ module Samagotchi
|
|
|
1383
1383
|
# or the restored ones, never the one without the other. A turn that
|
|
1384
1384
|
# got somewhere stays instead (TurnFlow#prompt_turn_failed), with the
|
|
1385
1385
|
# prompts it took: nothing goes back.
|
|
1386
|
-
# @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil)>]
|
|
1387
|
-
# origin, images] (a web client gets its image chips
|
|
1386
|
+
# @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil, VerifyAnchor|nil)>]
|
|
1387
|
+
# [prompt, origin, images, verify] (a web client gets its image chips
|
|
1388
|
+
# back, and a Verify turn's claim, so sending it again still records
|
|
1389
|
+
# a verdict)
|
|
1388
1390
|
# @param error [Exception, nil] what failed: its one line stays in the
|
|
1389
1391
|
# conversation as a turn note
|
|
1390
1392
|
# Not saved here: run_engine_turn saves after its block.
|
|
@@ -1392,9 +1394,10 @@ module Samagotchi
|
|
|
1392
1394
|
@engine.synchronize_events do
|
|
1393
1395
|
next if @turn_flow.prompt_turn_failed(error: error) == :kept
|
|
1394
1396
|
|
|
1395
|
-
prompts.each do |prompt, origin, images|
|
|
1397
|
+
prompts.each do |prompt, origin, images, verify|
|
|
1396
1398
|
restored = { type: :prompt_restored, prompt: prompt, origin: origin.wire }
|
|
1397
1399
|
restored[:images] = images unless Array(images).empty?
|
|
1400
|
+
restored[:verify] = verify.to_h if verify
|
|
1398
1401
|
@engine.announce(restored)
|
|
1399
1402
|
end
|
|
1400
1403
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.127.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -752,6 +752,7 @@ files:
|
|
|
752
752
|
- lib/samagotchi/web/participants_admin.rb
|
|
753
753
|
- lib/samagotchi/web/principal.rb
|
|
754
754
|
- lib/samagotchi/web/private_files.rb
|
|
755
|
+
- lib/samagotchi/web/project_list.rb
|
|
755
756
|
- lib/samagotchi/web/proxy_gate.rb
|
|
756
757
|
- lib/samagotchi/web/public/activity.js
|
|
757
758
|
- lib/samagotchi/web/public/annotate_actions.js
|
|
@@ -822,6 +823,7 @@ files:
|
|
|
822
823
|
- lib/samagotchi/web/public/participant_names.js
|
|
823
824
|
- lib/samagotchi/web/public/participants_panel.js
|
|
824
825
|
- lib/samagotchi/web/public/plain_line.js
|
|
826
|
+
- lib/samagotchi/web/public/project_picker.js
|
|
825
827
|
- lib/samagotchi/web/public/prompt_history.js
|
|
826
828
|
- lib/samagotchi/web/public/push.js
|
|
827
829
|
- lib/samagotchi/web/public/push_devices.js
|