diff --git a/.agents/skills/fork-audit/SKILL.md b/.agents/skills/fork-audit/SKILL.md new file mode 100644 index 000000000000..b00eab2347b8 --- /dev/null +++ b/.agents/skills/fork-audit/SKILL.md @@ -0,0 +1,49 @@ +--- +name: fork-audit +description: Compare upstream commits since the last audit against the q1code feature registry and report what upstream now provides, what upstream changed near a seam, and which new upstream extension points would shrink a seam. Use after a sync, on a weekly cadence, or before planning fork work, to keep the series small and catch semantic conflicts a clean rebase hides. +--- + +# Fork Audit + +Read `fork/FORK.md` and `fork/FEATURES.md` first. A rebase that applies cleanly can still be wrong: upstream may have renamed the function a seam calls, reimplemented a fork feature, or added the hook a seam was faking. This skill finds those. + +## 1. Find the range + +The last audit is the newest file in `fork/docs/audits/` (its first line records the upstream SHA it ended at) or, if none, the upstream SHA in the newest `fork/docs/sync-log/*.md`. The range is `..main`. If it is empty, say so and stop. + +``` +git log --oneline --no-merges ..main +git diff --stat ..main +``` + +## 2. Per feature, three questions + +Walk every `active`, `paused`, and `planned` entry in `fork/FEATURES.md`. + +**(a) Does upstream now provide it?** Search the range for commits whose title, touched paths, or contract changes overlap the feature's purpose (settings keys, capabilities fields, provider drivers, workflow files, docs under `docs/user/`). If upstream shipped the same behavior, propose `upstreamed` (drop the fork commits at the next sync) or a narrowing of the feature to what upstream still lacks. If upstream shipped something that makes the feature pointless, propose `dropped`. + +**(b) Did upstream change near a seam?** For each seam file in the entry (and in `fork/SEAMS.md`), check whether the range touches it: + +``` +git log --oneline ..main -- +git diff ..main -- +``` + +Any hit is a semantic conflict risk even when the rebase was clean. Read the hunk against the seam's 3 lines: is the callee still there, same signature, same call order, same guard? Then run that feature's tests as listed in its entry, plus a flags-off parity check for the seam. Report pass or fail with the exact test command. + +**(c) New extension points?** Scan the range for additions that a seam could use instead of a hand-placed hook: new optional keys on `ExecutionEnvironmentCapabilities`, new registries or arrays (drivers, settings search items, commands, routes), new slot components, new per-instance settings, new env passthroughs, new `docs/internals/` pages describing an extension mechanism. For each, name the seam it would shrink or delete and whether the change is a fork edit or an upstream candidate. + +## 3. Also check + +- Upstream workflows added in the range (`.github/workflows/`): confirm they are disabled on q1/q1code (`gh workflow list -R q1/q1code`). +- Upstream changes to `AGENTS.md`, `CONTRIBUTING.md`, `.agents/skills/`: anything that changes how the fork skills should behave. +- Version or packaging changes (`apps/server/package.json`, `pinnedRuntime.ts`, release workflow) that affect the `base` update seams. +- `Upstream: pr:` commits in the series: check PR state with `gh pr view -R pingdotgg/t3code`. Merged means note the SHA for the next sync; closed means decide `no` or retry. + +## 4. Output + +If an open sync PR exists for this range, append the audit as a comment there. Otherwise write `fork/docs/audits/.md` (first line: `Upstream: `), commit it on `fork` or the open feature branch with `Fork-Feature: base`, `Upstream: no`. + +Format: one section per feature with findings under (a), (b), (c); a final "Proposed changes" list with concrete edits to `fork/FEATURES.md` (status changes, seam list updates) and any follow-up work (a `fork-feature` change, a `fork-upstream-pr` candidate, a test to add). Do not apply status changes yourself unless asked; the sync that drops commits does that. + +Keep findings specific: file, line, upstream commit SHA, what breaks or what improves. "Upstream touched ChatView" is not a finding. diff --git a/.agents/skills/fork-audit/agents/openai.yaml b/.agents/skills/fork-audit/agents/openai.yaml new file mode 100644 index 000000000000..283c2569055f --- /dev/null +++ b/.agents/skills/fork-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Audit" + short_description: "Compare upstream changes against the fork feature registry" + default_prompt: "Use $fork-audit to review upstream commits since the last audit against fork/FEATURES.md and report absorbed features, seam risks, and new extension points." diff --git a/.agents/skills/fork-feature/SKILL.md b/.agents/skills/fork-feature/SKILL.md new file mode 100644 index 000000000000..2f1735a88682 --- /dev/null +++ b/.agents/skills/fork-feature/SKILL.md @@ -0,0 +1,77 @@ +--- +name: fork-feature +description: Scaffold a new q1code fork feature: registry entry in fork/FEATURES.md, flag in packages/fork-core/src/flags.ts, fork-owned directories, a seam checklist, the every-surface walk from AGENTS.md, and a plan that splits the commit series into upstream candidates and fork-only wiring. Use when starting any fork feature, before writing feature code, or when an existing feature needs its seams or flags re-planned. +--- + +# Fork Feature + +Read `fork/FORK.md` first, then the `base` and `prism` entries in `fork/FEATURES.md` as examples. This skill produces the skeleton and the plan; feature code comes after and follows the plan. + +## 1. Name it + +Pick a slug: lowercase, hyphenated, unique in `fork/FEATURES.md`. It becomes the `Fork-Feature:` trailer, the flag key, and the `// fork: ` marker. Check `git log --format=%B main..fork | grep Fork-Feature` for collisions with dropped slugs. + +## 2. Registry entry + +Add an entry to `fork/FEATURES.md` with every field from its header. Status `planned` until the first commit lands, then `active`. Write the purpose as one line a stranger can test against. Fill "removal condition" honestly; a feature with no removal condition is a feature nobody will delete. + +## 3. Flag + +Add one entry to `FORK_FLAGS` in `packages/fork-core/src/flags.ts`: + +```ts +"": { description: "...", default: false, scope: "server" | "client" | "both" }, +``` + +Default is `false`. The only exception in the registry is `update-check`; do not add another without a written reason in the entry. Scope `server` for things that spawn, read files, or change env; `client` for pure UI preferences; `both` when the server decides and the client renders. Client-only state persists under a fork-namespaced localStorage key, never in `ClientSettingsSchema`. + +## 4. Directories + +Create only what the feature needs, under fork-owned locations: + +- `apps/server/src/fork//` for services, reactors, drivers, RPC handlers +- `apps/web/src/fork//`, `apps/mobile/src/fork//`, `apps/desktop/src/fork//` +- `packages/fork-core/src/*` for shared types, config schema, fork RPC contracts (fork RPCs are typed here, not in `packages/contracts`) +- `packages/client-runtime` only for logic web and mobile both need + +Config the feature reads lives in `~/.q1code/userdata/fork.json` under a `` key with a schema in `packages/fork-core`. Secrets go through the server secret store, never `fork.json`. + +## 5. Seam checklist + +For each upstream file the feature must touch, write down: file, the exact extension point, the 3 lines, the flag guard (or why it is inert), and the marker. Prefer the extension points listed in `fork/FORK.md`. Reject any seam that: + +- exceeds 3 lines, +- sits in the body of `ChatView.tsx`, `Sidebar.tsx`, or `ChatComposer.tsx` (use a ``), +- restructures upstream code (that is an upstream PR first, or `Fork-Seam-Debt: yes` with a deadline in the entry), +- would push `fork/SEAMS.md` over its 40-file budget. + +Run `node scripts/fork/seams.ts` after adding seams and paste the resulting rows into the registry entry. + +## 6. Hit every surface + +Walk the list from `AGENTS.md` and record a decision for each, including "not supported here": + +- **Entry points**: chat view, Settings (the q1code section), command palette, keybindings. +- **Clients**: web, desktop, mobile. Shared logic in `packages/client-runtime`; fork UI in each app's `src/fork/`. +- **Providers**: Codex, Claude, Cursor, Grok, OpenCode. One decision per adapter. +- **Contracts**: fork RPCs in `packages/fork-core`; the only `packages/contracts` seam is `forkFlags` unless the entry justifies another optional key. +- **Reverse states**: the flag off must fully undo the feature at runtime, and every action needs its inverse. +- **Connection modes**: local, Tailscale, relay. Multi-environment: what happens when one environment has the flag on and another off. +- **Docs**: user-facing behavior in `fork/docs/.md` (not `docs/user/`, which is upstream's); design notes in the registry entry. + +## 7. Candidate/no split + +Before writing code, split the planned commits: + +- **`Upstream: candidate`** first: generic changes upstream would plausibly take (a new optional setting, a passthrough, a bug fix, a model manifest entry). No `@q1code/` imports, no flag checks, no fork paths, no markers. Each must build and pass tests on plain `main`. Keep each under the size upstream's CONTRIBUTING.md calls small. +- **`Upstream: no`** after: the flag, the seams, the fork directories, the docs. + +Write the split into the registry entry under "upstream" as a list of planned commit titles. If nothing is a candidate, say so; if everything is, the feature may be an upstream PR and not a fork feature at all. + +## 8. Tests + +List in the entry: behavior tests for the feature (server logic gets focused tests, receipts not sleeps), and one flags-off parity test proving the seam is inert when the flag is off (no process, no env var, no rendered element, no capabilities field). + +## 9. Commit the scaffold + +One commit: registry entry, flag, empty directories with an index file if the toolchain needs one, the seam checklist as part of the entry. Trailers `Fork-Feature: `, `Upstream: no`. Then implement in the order the split says. diff --git a/.agents/skills/fork-feature/agents/openai.yaml b/.agents/skills/fork-feature/agents/openai.yaml new file mode 100644 index 000000000000..7e2dd8a0082d --- /dev/null +++ b/.agents/skills/fork-feature/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Feature" + short_description: "Scaffold a flag-gated q1code fork feature" + default_prompt: "Use $fork-feature to register a new fork feature, add its flag, plan its seams and surfaces, and split the work into upstream candidates and fork-only commits." diff --git a/.agents/skills/fork-release/SKILL.md b/.agents/skills/fork-release/SKILL.md new file mode 100644 index 000000000000..729c65bc5c3e --- /dev/null +++ b/.agents/skills/fork-release/SKILL.md @@ -0,0 +1,77 @@ +--- +name: fork-release +description: Cut a q1code release: pick the version from the upstream tag the fork sits on, tag `fork-v-q1.`, trigger `fork-release.yml`, verify the published artifacts (server tarball, web bundle, checksums.txt, install.sh), and hand off to the private deploy step. Use when `fork` is green and a new build should reach Mic's machines, or when a rollback needs the previous release located. +--- + +# Fork Release + +Read `fork/FORK.md` first. Releases are GitHub Releases on q1/q1code only; there is no npm publish and no desktop build. The desktop updater feed and the server's pinned-runtime installer both read from these releases (`T3CODE_DESKTOP_UPDATE_REPOSITORY=q1/q1code`, `pinnedRuntime.ts` seam under `base`). + +Deploying is not part of this skill and never happens from a sync or release job. Mic triggers deploys from the private repo. + +## 1. Pick the commit + +Release from `fork` only, at a commit where `fork-ci.yml` is green: + +``` +git fetch origin --tags +git switch fork && git pull --ff-only +gh run list -R q1/q1code --workflow fork-ci.yml --branch fork --limit 1 +``` + +Do not release from a `sync/` branch or with an open sync PR unresolved unless the release is explicitly meant to skip that sync. + +## 2. Compute the version + +`` is the version in `apps/server/package.json` on `main` at the merge base (`git merge-base main fork`), which upstream's nightly tagging keeps current, e.g. `0.0.39-nightly.20260902.1253`. `` starts at 1 per upstream version and increments for each fork release on the same upstream version: + +``` +git tag -l "fork-v-q1.*" | sort -V | tail -1 +``` + +Tag: `fork-v-q1.`. The release workflow derives the package version and the About label ("q1code on T3 Code ") from it. + +## 3. Tag and push + +``` +git tag -a fork-v-q1. -m "q1code -q1." +git push origin fork-v-q1. +``` + +The tag push triggers `.github/workflows/fork-release.yml`. If it does not (workflow disabled, or push filter mismatch), `gh workflow run fork-release.yml -R q1/q1code -f tag=` and fix the trigger afterwards. + +## 4. Watch + +``` +gh run watch -R q1/q1code $(gh run list -R q1/q1code --workflow fork-release.yml --limit 1 --json databaseId -q '.[0].databaseId') +``` + +On failure, read the log, fix on `fork`, delete the tag locally and remotely, and start over at step 1. Never move a tag that a release already exists for. + +## 5. Verify artifacts + +``` +gh release view -R q1/q1code --json assets -q '.assets[].name' +``` + +Required assets: + +- `q1code-.tgz`: server plus bundled web. Download it; `tar tzf` shows `package/dist/bin.mjs` and the web bundle under `package/dist/`. `npm pack`-style layout so `npm install ` works in the pinned-runtime installer. +- `checksums.txt`: SHA-256 for every other asset. Verify the tarball against it; this is the integrity check `pinnedRuntime.ts` performs, since GitHub releases carry no registry integrity field. +- `install.sh`: the manual install path (`curl -fsSL | sh`). Run it with `sh -n` for syntax, and read it: it must install the tarball into the launcher layout under `~/.q1code/runtime/versions/` and nothing else. +- Web bundle archive, if the workflow publishes it separately from the tarball. +- When the `prism` feature ships: the Prism engine (CLIProxyAPI) bundle per platform under `dist/prism/`, checksummed. + +Confirm the release notes name the upstream tag and SHA the build sits on. Confirm the release is not marked pre-release unless the tag is a nightly-style build. + +## 6. Smoke + +On a disposable base directory, run the install path end to end: `sh install.sh` into a temp `HOME`, start the server with `--home-dir `, check the About label reports the expected q1code and upstream versions, stop it by the PID you captured. Do not point it at `~/.q1code` or `~/.t3`. + +## 7. Hand off + +Report: tag, release URL, asset list with sizes and checksums, upstream version and SHA, and what changed since the previous `fork-v` tag (`git log --oneline ..` grouped by `Fork-Feature`). The deploy step lives in the private repo (`services/q1code`, pin in `UPSTREAM.json`, `manage.sh`) and is Mic's call; do not run it, do not restart anything. + +## Rollback + +A release is never deleted. To roll a machine back, the deploy step pins the previous tag. To fix a bad release, cut `q1.`. diff --git a/.agents/skills/fork-release/agents/openai.yaml b/.agents/skills/fork-release/agents/openai.yaml new file mode 100644 index 000000000000..91b9668461c9 --- /dev/null +++ b/.agents/skills/fork-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Release" + short_description: "Tag and publish a q1code GitHub release" + default_prompt: "Use $fork-release to tag fork-v-q1., run fork-release.yml, verify the tarball, checksums, and install.sh, and report for deploy." diff --git a/.agents/skills/fork-sync/SKILL.md b/.agents/skills/fork-sync/SKILL.md new file mode 100644 index 000000000000..044b32c035c4 --- /dev/null +++ b/.agents/skills/fork-sync/SKILL.md @@ -0,0 +1,102 @@ +--- +name: fork-sync +description: Rebase the q1code `fork` series onto a freshly fast-forwarded upstream `main` under fixed conflict rules, run the seam, leak, typecheck, and targeted test gates, classify the result with range-diff, then either promote deterministically or open the single sync PR and write the sync-log entry. Use when upstream has moved, when the sync timer or watch workflow reports drift or a conflict, or when a human asks to bring the fork up to date. +--- + +# Fork Sync + +Read `fork/FORK.md` first. This skill implements its Sync section. The deterministic parts are also in `scripts/fork/sync.sh`; run that when possible and step in by hand only where it stops. + +Work in a clean checkout with `origin` = q1/q1code and `upstream` = pingdotgg/t3code, `rerere.enabled`, `rerere.autoupdate`, and `rebase.updateRefs` on. Never run this against a checkout that a running q1code serves. + +## 1. Fetch and fast-forward + +``` +git fetch upstream --tags +git switch main +git merge --ff-only upstream/main +git push origin main +``` + +If `main` cannot fast-forward, stop. Someone committed on `main`. Report it; do not force anything. + +## 2. Snapshot + +``` +STAMP=$(date -u +%Y%m%dT%H%M%SZ) +git tag snap/$STAMP fork +git push origin snap/$STAMP +``` + +The tag is the rollback point. `scripts/fork/rollback.sh snap/$STAMP` restores `fork` with one force-push. + +## 3. Rebase on a throwaway branch + +``` +git switch -c sync/$STAMP fork +git rebase --rerere-autoupdate --update-refs main sync/$STAMP +``` + +Record whether the rebase was clean, rerere-replayed, or needed hand resolution. This decides step 7. + +## 4. Conflict rules + +Apply in order. Never resolve a whole file with `--ours` or `--theirs`. + +1. **Upstream wins in upstream files.** Take upstream's version of the hunk unless doing so deletes a seam. +2. **Re-apply seams minimally.** If upstream rewrote the lines around a seam, put the seam back as at most 3 lines with its `// fork: ` marker at the nearest equivalent point. If the extension point moved, move the seam; if it disappeared, stop and report under "needs human eyes". +3. **Drop absorbed commits.** If a fork commit's change now exists upstream (same patch or upstream's reviewed version of it), `git rebase --skip` it. Note the fork commit and the upstream SHA. For `Upstream: pr:` commits whose PR merged, this is expected; set the feature to `upstreamed` in `fork/FEATURES.md` if no commits remain for it, otherwise update the trailer to `merged:`. +4. **Fork-owned files never conflict with upstream.** If one does, an upstream file was mislabeled; treat it as an upstream file. +5. When unsure, prefer the resolution that keeps `main..sync/$STAMP` smaller. + +After each resolution: `git add` the files, `git rebase --continue`. Commit messages and trailers stay as they were; do not reword during a sync. + +## 5. Gates + +``` +node scripts/fork/seams.ts +node scripts/fork/leak-check.ts +``` + +Then typecheck and run tests for the packages the rebase touched (files changed in `main..sync/$STAMP` plus files upstream changed in the range that sit next to a seam). Use `vp test run ` and package-scoped typecheck. Do not run repo-wide checks. + +Fix a failing gate by editing the offending fork commit in place: `git commit --fixup ` then `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main` (interactive rebase is not available to agents, so the sequence editor is bypassed). A fix here counts as hand resolution for step 7. + +## 6. Classify + +``` +node scripts/fork/range-diff-classify.ts snap/$STAMP sync/$STAMP main +``` + +It wraps `git range-diff main@{1}..snap/$STAMP main..sync/$STAMP` and reports `clean` (only context and offset changes), `content` (any `!` line), or `dropped` (commit count changed). It also lists seam files upstream touched in the range. + +## 7. Promote or PR + +Push the branch first: `git push origin sync/$STAMP`. `fork-ci.yml` runs on it. + +Promote with `scripts/fork/promote.sh sync/$STAMP` only when every one of these holds: + +- the rebase was clean or fully rerere-replayed with no hand edits, +- classification is `clean`, +- no seam file was touched by upstream in the range, +- `fork-ci.yml` is green on the pushed branch. + +Otherwise open or update the single sync PR `sync/$STAMP` into `fork` on q1/q1code with `gh pr create` (or `gh pr edit` if one is open; close older sync PRs). Body = the sync report: upstream range, dropped commits, conflicts and resolutions, seam files upstream touched, range-diff excerpt, anything needing human eyes. A human promotes by label or approval; the next run executes it. + +**The agent never promotes when it resolved conflicts by hand or edited any commit during the gates.** Promotion is for the deterministic path only, no matter how confident the resolution looks. + +## 8. Log + +Write `fork/docs/sync-log/$STAMP.md` per `fork/docs/sync-log/README.md`. Commit it on `sync/$STAMP` as the last commit (`Fork-Feature: base`, `Upstream: no`) before pushing, so it lands with the promotion or the PR. + +## 9. Workflows + +``` +gh workflow list -R q1/q1code --json name,path,state +``` + +Disable every enabled workflow whose file does not start with `fork-`: `gh workflow disable -R q1/q1code `. Upstream adds workflows often and each new one starts enabled on the fork. Never delete a workflow file. + +## Failure + +On any stop condition: leave `sync/$STAMP` pushed, leave the conflict list in the PR body or the report, exit non-zero. Do not touch `fork`. The `snap/` tag stays until the next successful promotion. diff --git a/.agents/skills/fork-sync/agents/openai.yaml b/.agents/skills/fork-sync/agents/openai.yaml new file mode 100644 index 000000000000..54de18fa6032 --- /dev/null +++ b/.agents/skills/fork-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Sync" + short_description: "Rebase the q1code fork series onto upstream main" + default_prompt: "Use $fork-sync to fetch upstream, rebase fork onto main under the conflict rules, run the gates, and promote or open the sync PR." diff --git a/.agents/skills/fork-triage-branches/SKILL.md b/.agents/skills/fork-triage-branches/SKILL.md new file mode 100644 index 000000000000..27aca5431f74 --- /dev/null +++ b/.agents/skills/fork-triage-branches/SKILL.md @@ -0,0 +1,75 @@ +--- +name: fork-triage-branches +description: One-off triage of the legacy fork-only branches on q1/q1code: for each branch report whether it rebases cleanly onto main, whether it is still relevant, and whether its work is an upstream candidate, a fork feature, or a drop; then archive every branch as an `archive/` tag and delete it, ending with a summary table. Use once to clear the pre-fork branches, or again for any stray branch that is not main, fork, up/, sync/, or a series ref. +--- + +# Fork Triage Branches + +Read `fork/FORK.md` first. Nothing is lost: every branch becomes a tag before deletion, so the branch list can be empty without any history disappearing. + +The branches to triage are given by the caller. If none are given, use every remote branch that is not `main`, `fork`, `fork/*`, `up/*`, `sync/*`, or `fork-rerere`. + +## 1. Facts per branch + +``` +git fetch origin --prune && git fetch upstream +for b in ; do + echo "== $b" + git rev-list --left-right --count origin/$b...main + git log --oneline main..origin/$b + git diff --stat main...origin/$b | tail -1 +done +``` + +Record ahead, behind, commit list, files changed, and the merge base date. + +## 2. Rebase test + +On a throwaway worktree so the main checkout is never disturbed: + +``` +git worktree add /tmp/triage- origin/ +git -C /tmp/triage- rebase main +``` + +Record: clean, conflicts (list the files), or already empty (every commit is upstream now; `git rebase` drops them). Abort and remove the worktree afterwards (`git rebase --abort`, `git worktree remove --force`). Do not resolve conflicts here; the question is only whether they exist. + +## 3. Relevance + +For each branch answer, with evidence: + +- **Does upstream already have it?** Search `main` for the same fix or feature (`git log --oneline -S main`, or the docs page it would have produced). A merged upstream PR from this branch (for example #9078 from `feat/claude-fable-5-1`) means the remaining commits are the only live part. +- **Is the code it touches still there?** A branch that patches a file upstream deleted or rewrote is stale regardless of its idea. +- **Is the idea still wanted?** Check the spec's note for the branch if one exists, and whether a `fork/FEATURES.md` entry covers it. + +## 4. Classification + +One of: + +- **upstream candidate**: small, generic, still applies. Next step is `fork-upstream-pr` from a fresh `up/` branch (re-implement on `main` if the rebase was not clean; do not fight an old branch into shape). +- **fork feature**: wanted, not upstream-shaped. Next step is `fork-feature` with a new slug; the old branch is reference material, not a base. +- **split**: part candidate, part fork. Say which commits go where. +- **drop**: stale, absorbed, or not wanted. The tag keeps it findable. + +Say what a re-implementation would cost (files, rough size) for anything not `drop`. + +## 5. Archive and delete + +Only after the report is written and the caller has seen it, unless told to proceed without review: + +``` +git tag archive/ origin/ +git push origin archive/ +git push origin --delete +``` + +`archive/` uses the branch name with `/` kept (`archive/t3code/fix-mobile-thread-scrolling`). Confirm each tag resolves to the same SHA the branch had before deleting. Close any open PR from the branch on q1/q1code with a comment pointing at the tag. + +## 6. Summary table + +End with one table: + +| branch | ahead/behind | rebase | relevant | class | next step | tag | +| ------ | ------------ | ------ | -------- | ----- | --------- | --- | + +followed by the per-branch reports. If the caller wants it durable, write it to `fork/docs/audits/-triage.md` and commit with `Fork-Feature: base`, `Upstream: no`. Any `upstream candidate` or `fork feature` rows become follow-up items for the respective skill; do not start that work inside this one. diff --git a/.agents/skills/fork-triage-branches/agents/openai.yaml b/.agents/skills/fork-triage-branches/agents/openai.yaml new file mode 100644 index 000000000000..bddf320d95a2 --- /dev/null +++ b/.agents/skills/fork-triage-branches/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Triage Branches" + short_description: "Report on, archive, and delete legacy fork branches" + default_prompt: "Use $fork-triage-branches to assess each legacy branch against main, classify it, tag it as archive/, delete it, and summarize in a table." diff --git a/.agents/skills/fork-upstream-pr/SKILL.md b/.agents/skills/fork-upstream-pr/SKILL.md new file mode 100644 index 000000000000..e8299f319ff2 --- /dev/null +++ b/.agents/skills/fork-upstream-pr/SKILL.md @@ -0,0 +1,80 @@ +--- +name: fork-upstream-pr +description: Extract upstream-candidate commits from the q1code fork series onto an up/ branch cut from main, scrub every fork trace, verify the branch builds without fork code, write the PR in upstream's voice with evidence from the up/ build, and open it against pingdotgg/t3code. Use when a fork commit is marked Upstream: candidate, when a bug fix found during fork work belongs upstream, or when a maintainer asks to contribute something back. +--- + +# Fork Upstream PR + +Read `fork/FORK.md` (Upstream PRs without leaks) and upstream's `CONTRIBUTING.md` first. Precedent: pingdotgg/t3code#9078, one JSON object in `model-manifest.json`, from `q1:feat/claude-fable-5-1`. That is the size and shape that gets merged. + +## What upstream accepts + +From `CONTRIBUTING.md`: contributions are not actively accepted. Most likely merged: small focused bug fixes, small reliability fixes, small performance improvements, tightly scoped maintenance. Least likely: large PRs, drive-by features, rewrites, scope expansion. Non-trivial changes get an Ideas discussion first (`https://github.com/pingdotgg/t3code/discussions/categories/ideas`), not an issue and not a surprise PR. PRs are auto-labeled `size:*` and `vouch:*`; q1 is unvouched. One concern per PR. UI changes need before/after images; motion needs a short video. + +If the candidate is not small, stop and propose a discussion post instead. + +## 1. Select commits + +By trailer: + +``` +git log --format='%h %s' main..fork --grep='^Fork-Feature: ' --grep='^Upstream: candidate' --all-match +``` + +or by explicit SHAs. Every selected commit must already be `Upstream: candidate`; a `no` commit is never extracted, it is rewritten as a candidate first on `fork`. + +## 2. Cut the branch from main + +``` +git fetch upstream --tags +git switch main && git merge --ff-only upstream/main && git push origin main +git switch -c up/ main +git cherry-pick -x ... +``` + +Never branch from `fork`. If a cherry-pick conflicts, the commit depends on fork-only code or on an earlier candidate you did not select; fix the selection or the commit on `fork`, do not patch it here. + +## 3. Scrub + +Strip trailers from every commit on the branch (`Fork-Feature:`, `Upstream:`, `Fork-Seam-Debt:`), keeping the conventional title and body: + +``` +git filter-branch --msg-filter 'sed -E "/^(Fork-[A-Za-z-]+|Upstream):/d"' main..HEAD +``` + +Then: + +``` +node scripts/fork/leak-check.ts main..HEAD +``` + +It greps the diff and the messages for `@q1code/`, `/fork/`, `T3FORK_`, `// fork:`, flag keys from the registry, `q1`, `q1code`, and fails on any hit. Fix the commit on `fork` and restart from step 2; do not hand-edit the `up/` branch into shape. + +## 4. Verify without fork code + +On `up/` (which has no fork code at all): package-scoped typecheck for the touched packages, `vp test run `, and a build of the touched app when the change is not test-only. Nothing repo-wide. + +## 5. PR body + +Written from the commits only, in upstream's voice, following their PR conventions from `AGENTS.md`: the problem in a sentence or two, then how it was fixed, then a final line naming the model and harness that did the work. Conventional commit title, plain language, e.g. `fix(web): copying a code block no longer copies backticks`. + +Forbidden words anywhere in title, body, commit messages, or screenshots: **fork, downstream, q1code, q1**. Do not mention where the change was found or which product it ships in. + +UI evidence is captured from the `up/` build (`test-t3-app` or `test-t3-mobile` on that branch), never from a fork build, so no fork UI leaks into a screenshot. Upload evidence to GitHub; never commit it. + +## 6. Open + +``` +git push origin up/ +gh pr create -R pingdotgg/t3code --head q1:up/ --title "" --body-file <body> +``` + +Note the PR number. + +## 7. Record on the fork + +At the next rebase (or now, with `git commit --amend` on the relevant series commits followed by a normal `fork-sync`), set `Upstream: pr:<n>` on each extracted fork commit and add the PR link to the feature's entry in `fork/FEATURES.md`. When the PR merges, the next `fork-sync` drops the fork commits and sets `merged:<sha>` or `upstreamed`. + +## 8. Babysit + +Per `AGENTS.md`: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones by amending on `up/<topic>` (then mirror the fix onto the `fork` commit), dismiss false positives with a reason, stay quiet when nothing is new. Replies follow the same forbidden-word rule as the body. diff --git a/.agents/skills/fork-upstream-pr/agents/openai.yaml b/.agents/skills/fork-upstream-pr/agents/openai.yaml new file mode 100644 index 000000000000..c809c755dffd --- /dev/null +++ b/.agents/skills/fork-upstream-pr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fork Upstream PR" + short_description: "Extract fork commits into a clean upstream PR" + default_prompt: "Use $fork-upstream-pr to cherry-pick Upstream: candidate commits onto an up/<topic> branch from main, scrub fork traces, verify, and open the PR against pingdotgg/t3code." diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs new file mode 100644 index 000000000000..dc4b55bc6517 --- /dev/null +++ b/.github/scripts/check-nightly-release.cjs @@ -0,0 +1,44 @@ +const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; + +// Runs after the workflow acquires the nightly concurrency lock. +async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, + per_page: 100, + }); + const lastNightly = releases + .filter( + (release) => + !release.draft && + release.published_at && + (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), + ) + .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; + + if (!lastNightly) { + core.info("No published nightly found. Proceeding with release."); + return true; + } + + if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { + core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + return false; + } + + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${lastNightly.tag_name}...${context.sha}`, + per_page: 1, + }); + if (comparison.status !== "ahead") { + core.info( + `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, + ); + return false; + } + + core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + return true; +} + +module.exports = { shouldReleaseNightly }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs new file mode 100644 index 000000000000..476773bc4e5a --- /dev/null +++ b/.github/scripts/check-nightly-release.test.cjs @@ -0,0 +1,101 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); + +const now = Date.parse("2026-09-05T12:00:00Z"); +const hour = 60 * 60 * 1000; +const nightly = (hoursAgo, overrides = {}) => ({ + tag_name: "v1.0.1-nightly.20260905.123", + draft: false, + published_at: new Date(now - hoursAgo * hour).toISOString(), + ...overrides, +}); + +function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { + const calls = []; + return { + calls, + options: { + now, + context: { repo: { owner: "example", repo: "app" }, sha: "new" }, + core: { info() {} }, + github: { + rest: { + repos: { + listReleases() {}, + async compareCommitsWithBasehead(params) { + calls.push(params); + return { data: { status: comparisonStatus } }; + }, + }, + }, + async paginate() { + return releases; + }, + }, + }, + }; +} + +test("releases the first nightly when no nightly is published", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("waits six hours after publication, including manual nightlies", async () => { + for (const age of [0, 3, 6 - 1 / 3600]) { + const { options, calls } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.equal(calls.length, 0); + } +}); + +test("releases new commits at six hours and after an idle period", async () => { + for (const age of [6, 7, 24]) { + const { options } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), true); + } +}); + +test("skips unchanged commits after the gap", async () => { + const { options } = fixture({ comparisonStatus: "identical" }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("uses publication time, not release order or the tagged commit date", async () => { + const { options } = fixture({ + releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("ignores stable releases and drafts when checking the gap", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("compares against the published tag, including legacy nightly tags", async () => { + const tag = "nightly-v0.9.0"; + const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); + assert.equal(await shouldReleaseNightly(options), true); + assert.equal(calls[0].basehead, `${tag}...new`); +}); + +test("fails instead of releasing when GitHub cannot supply release state", async () => { + const { options } = fixture(); + options.github.paginate = async () => { + throw new Error("GitHub unavailable"); + }; + await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); +}); + +for (const status of ["behind", "diverged"]) { + test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => { + const { options } = fixture({ comparisonStatus: status }); + assert.equal(await shouldReleaseNightly(options), false); + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0866fa63a6..e23dbd60d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,10 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Files/dependencies are repo-wide; export checks cover clean workspaces only. + - name: Check unused code + run: vp run knip:check + - name: Check run: vp check @@ -99,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test nightly release checks + run: node --test .github/scripts/check-nightly-release.test.cjs + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml new file mode 100644 index 000000000000..ecd5c3a1683b --- /dev/null +++ b/.github/workflows/fork-ci.yml @@ -0,0 +1,370 @@ +name: Fork CI + +# Fork-owned CI. Mirrors ci.yml's jobs on hosted runners and adds the fork +# gates: seam budget, upstream-PR leak check, and flags-off parity. Upstream's +# ci.yml stays in the tree but is disabled on this repository. + +on: + pull_request: + branches: + - fork + push: + branches: + - fork + - "sync/**" + - "up/**" + +permissions: + contents: read + +concurrency: + group: fork-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/fork' }} + +jobs: + check: + name: Check + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Reject repository-owned PR assets + run: | + files="$(git ls-files .github/pr-assets)" + if test -n "$files"; then + printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2 + exit 1 + fi + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Check + run: vp check + + - name: Typecheck + run: vpr typecheck + + - uses: ./.github/actions/setup-apt-mirrors + + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + + - name: Build desktop pipeline + run: vp run build:desktop + + - name: Verify preload bundle output + run: node apps/desktop/scripts/verify-preload-bundle.mjs + + # Everything except the server package (`q1code`, apps/server). Same shape as + # upstream's Test job; the assertion step is the flags-off parity guard. + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - uses: ./.github/actions/setup-apt-mirrors + + - name: Install browser secret helper build libraries + run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + + - name: Assert no fork flags are set + run: | + if env | grep -q '^T3FORK_'; then + echo 'T3FORK_* is set; the upstream suites must run with every fork flag off.' >&2 + env | grep '^T3FORK_' >&2 + exit 1 + fi + + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' --filter '!@t3tools/mobile' test + + # Shiki has a 500 ms per-line tokenization budget. Running mobile beside + # every other package can exhaust it before a short line is fully parsed. + - name: Test mobile + run: vp run --filter @t3tools/mobile test --maxWorkers 2 + + # apps/server runs its files one at a time; sharding spreads them over runners. + test_server: + name: Test Server ${{ matrix.shard }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Assert no fork flags are set + run: | + if env | grep -q '^T3FORK_'; then + echo 'T3FORK_* is set; the upstream suites must run with every fork flag off.' >&2 + env | grep '^T3FORK_' >&2 + exit 1 + fi + + - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} + + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + fi + + rust: + name: Rust + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check resource monitor formatting + run: cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check + + - name: Test resource monitor + run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + + # Seam budget: every upstream file the series touches must carry a `fork:` + # marker and the total stays under budget. Dependency-free, no install. + seams: + name: Seams + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Check seams + run: node scripts/fork/seams.ts --check --base origin/main --head HEAD + + - name: Publish seam table + if: always() + run: | + if test -f fork/SEAMS.md; then + cat fork/SEAMS.md >> "$GITHUB_STEP_SUMMARY" + fi + + # up/<topic> branches become upstream PRs; nothing fork-shaped may leak in. + leak_check: + name: Leak Check + if: startsWith(github.ref, 'refs/heads/up/') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Check for fork leakage + run: node scripts/fork/leak-check.ts --range origin/main..HEAD + + # Flags-off parity: upstream's own suites (Test, Test Server) are the check. + # They ran on this fork tree with no T3FORK_* set, so a green Test + Test + # Server on fork/sync is the invariant; this job names it as one gate. + parity: + name: Flags-off Parity + needs: [test, test_server] + if: ${{ !cancelled() && (github.ref == 'refs/heads/fork' || startsWith(github.ref, 'refs/heads/sync/') || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'sync/'))) }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Assert no fork flags are set + run: | + if env | grep -q '^T3FORK_'; then + echo 'T3FORK_* is set in this workflow environment.' >&2 + exit 1 + fi + + - name: Require upstream suites to pass + env: + TEST_RESULT: ${{ needs.test.result }} + TEST_SERVER_RESULT: ${{ needs.test_server.result }} + run: | + echo "test=$TEST_RESULT test_server=$TEST_SERVER_RESULT" + test "$TEST_RESULT" = success && test "$TEST_SERVER_RESULT" = success + + # The static analysis below needs a macOS runner, so it is gated on the native + # sources it lints, exactly as upstream does. Fails open when the diff cannot + # be resolved. A fresh branch (no `before` sha) is compared against its base. + mobile_native_changes: + name: Mobile Native Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + changed: ${{ steps.detect.outputs.changed }} + steps: + - name: Detect mobile native changes + id: detect + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -uo pipefail + + fail_open() { + echo "$* Running native static analysis." + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + } + + count_rows() { + printf '%s\n' "$1" | grep -c . || true + } + + row='[.filename, (.previous_filename // empty)] | @tsv' + + if [[ -n "${PR_NUMBER}" ]]; then + expected=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.changed_files') \ + || fail_open "Could not read the pull request." + rows=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq ".[] | ${row}") \ + || fail_open "Could not resolve changed files." + + listed=$(count_rows "$rows") + if [[ "$listed" -lt "$expected" ]]; then + fail_open "GitHub listed only ${listed} of ${expected} changed files." + fi + else + base="${BEFORE_SHA}" + if [[ -z "$base" || "$base" =~ ^0+$ ]]; then + case "${GITHUB_REF_NAME}" in + up/*) base=main ;; + *) base=fork ;; + esac + fi + rows=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${base}...${GITHUB_SHA}" --jq ".files[]? | ${row}") \ + || fail_open "Could not resolve changed files." + + listed=$(count_rows "$rows") + if [[ "$listed" -ge 300 ]]; then + fail_open "GitHub listed ${listed} changed files, the compare endpoint maximum." + fi + fi + + paths=$(tr '\t' '\n' <<< "$rows") + + pattern='^apps/mobile/.*\.(swift|kt|kts)$|^apps/mobile/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts/mobile-native-static-check\.ts$|^package\.json$|^\.github/workflows/fork-ci\.yml$' + + if grep -qE "$pattern" <<< "$paths"; then + echo "Native sources or lint configuration changed:" + grep -E "$pattern" <<< "$paths" + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "No mobile native sources or lint configuration changed." + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + mobile_native_static_analysis: + name: Mobile Native Static Analysis + needs: mobile_native_changes + if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} + runs-on: macos-15 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=@t3tools/scripts... + + - name: Install mobile native static analysis tools + run: brew bundle install --file apps/mobile/Brewfile + + - name: Lint mobile native sources + run: vp run lint:mobile diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 000000000000..b58a923d281e --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,340 @@ +name: Fork Release + +# Publishes a q1code release to GitHub: the server tarball (web bundle inside, +# resource monitors and the CLIProxyAPI sidecar bundled per platform), +# checksums.txt, and install.sh. +# No npm publish, no signing, no desktop or mobile artifacts, no Vercel, +# Discord, or AUR. Upstream's release.yml stays disabled on this repository. +# +# Tag `fork-v<version>` (or dispatch with a version). The GitHub release is +# created under `v<version>` so clients and install.sh download from +# releases/download/v<version>/. A version containing `nightly` is a prerelease. + +on: + push: + tags: + - "fork-v*" + workflow_dispatch: + inputs: + version: + description: "Release version without prefix (for example 0.0.38-q1.1)" + required: true + type: string + +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + is_prerelease: ${{ steps.meta.outputs.is_prerelease }} + make_latest: ${{ steps.meta.outputs.make_latest }} + ref: ${{ github.sha }} + steps: + - name: Resolve release version + id: meta + env: + DISPATCH_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + raw="$DISPATCH_VERSION" + else + raw="${GITHUB_REF_NAME#fork-}" + fi + version="${raw#v}" + if [[ "$version" == *+* ]]; then + echo "Version '$version' contains '+': GitHub rewrites '+' in asset names and npm drops build metadata. Use a prerelease suffix such as -q1.1 instead." >&2 + exit 1 + fi + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 + exit 1 + fi + if [[ "$version" == *nightly* ]]; then + prerelease=true + else + prerelease=false + fi + { + echo "version=$version" + echo "tag=v$version" + echo "is_prerelease=$prerelease" + echo "make_latest=$([[ "$prerelease" == true ]] && echo false || echo true)" + } >> "$GITHUB_OUTPUT" + + # One native build per platform the fork deploys to. release.yml gets these + # as a by-product of the desktop build; here cargo runs directly. + resource_monitor: + name: Resource monitor ${{ matrix.resource_key }} + needs: [preflight] + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - resource_key: linux-x64 + runner: ubuntu-latest + rust_target: x86_64-unknown-linux-gnu + - resource_key: linux-arm64 + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu + - resource_key: darwin-arm64 + runner: macos-15 + rust_target: aarch64-apple-darwin + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor + key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} + + - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Build resource monitor + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' + run: cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml --target ${{ matrix.rust_target }} + + - name: Collect resource monitor + shell: bash + run: | + set -euo pipefail + source_path="native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor" + target_dir="resource-monitor-publish/${{ matrix.resource_key }}" + mkdir -p "$target_dir" + cp "$source_path" "$target_dir/t3-resource-monitor" + + - name: Upload resource monitor + uses: actions/upload-artifact@v7 + with: + name: resource-monitor-${{ matrix.resource_key }} + path: resource-monitor-publish/${{ matrix.resource_key }}/* + if-no-files-found: error + + build: + name: Build server tarball + needs: [preflight, resource_monitor] + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + VERSION: ${{ needs.preflight.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "$VERSION" + + # The server build task depends on @t3tools/web#build, so the web client + # lands in apps/server/dist/client as part of this step. + - name: Build server package + run: vp run --filter t3 build + + - name: Download resource monitors + uses: actions/download-artifact@v8 + with: + pattern: resource-monitor-* + path: ${{ runner.temp }}/resource-monitors + + - name: Bundle resource monitors into server package + shell: bash + run: | + set -euo pipefail + for artifact_dir in "$RUNNER_TEMP"/resource-monitors/resource-monitor-*; do + resource_key="${artifact_dir##*/resource-monitor-}" + target_dir="apps/server/dist/resource-monitor/${resource_key}" + mkdir -p "$target_dir" + cp "$artifact_dir"/t3-resource-monitor* "$target_dir/" + chmod +x "$target_dir"/t3-resource-monitor 2>/dev/null || true + done + + # The Prism engine (CLIProxyAPI): the pinned upstream release per platform, verified + # against the release's checksums.txt, unpacked to where + # apps/server/src/fork/prism/PrismBinary.ts looks first. Platform keys + # and asset names come from packages/fork-core/src/prism.pin.json so the + # server's on-demand download and this step can never disagree. + - name: Bundle Prism engine into server package + shell: bash + run: | + set -euo pipefail + pin="packages/fork-core/src/prism.pin.json" + version="$(node -p "require('./$pin').version")" + repository="$(node -p "require('./$pin').repository")" + base_url="https://github.com/${repository}/releases/download/v${version}" + work="$RUNNER_TEMP/prism" + mkdir -p "$work" + curl -fsSL --retry 3 -o "$work/checksums.txt" "$base_url/checksums.txt" + for platform_key in linux-x64 linux-arm64 darwin-arm64; do + target="$(node -p "require('./$pin').platforms['$platform_key']")" + asset="CLIProxyAPI_${version}_${target}.tar.gz" + curl -fsSL --retry 3 -o "$work/$asset" "$base_url/$asset" + (cd "$work" && grep -E " \*?${asset}\$" checksums.txt | sha256sum -c -) + out_dir="apps/server/dist/prism/${platform_key}" + mkdir -p "$out_dir" + member="$(tar -tzf "$work/$asset" | awk '$0 == "cli-proxy-api" || $0 == "./cli-proxy-api" { print; exit }')" + test -n "$member" + tar -xzf "$work/$asset" -C "$out_dir" "$member" + chmod +x "$out_dir/cli-proxy-api" + done + rm -rf "$work" + + # `vp pm pack` (pnpm) resolves the workspace `catalog:` specifiers into + # concrete versions the way `pnpm publish` does; plain `npm pack` would + # ship an uninstallable manifest. + - name: Pack server tarball + id: pack + shell: bash + run: | + set -euo pipefail + package_name="$(node -p "require('./apps/server/package.json').name")" + pack_dir="$RUNNER_TEMP/pack" + mkdir -p "$pack_dir" release-assets + # Same manifest rewrite as `cli.ts publish`: resolved runtime deps only. + node scripts/fork/prepare-pack-manifest.ts "$VERSION" + vp pm pack --filter "$package_name" --pack-destination "$pack_dir" + node scripts/fork/prepare-pack-manifest.ts --restore + packed="$(ls "$pack_dir"/*.tgz)" + test "$(printf '%s\n' "$packed" | wc -l)" -eq 1 + tarball="release-assets/q1code-${VERSION}.tgz" # asset prefix from BRAND.releaseAssetPrefix + mv "$packed" "$tarball" + echo "package_name=$package_name" >> "$GITHUB_OUTPUT" + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + + - name: Verify server tarball + shell: bash + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + PACKAGE_NAME: ${{ steps.pack.outputs.package_name }} + run: | + set -euo pipefail + listing="$(tar -tzf "$TARBALL")" + for required in package/dist/bin.mjs package/dist/service-launcher.mjs package/dist/client/index.html \ + package/dist/resource-monitor/linux-x64/t3-resource-monitor \ + package/dist/resource-monitor/linux-arm64/t3-resource-monitor \ + package/dist/resource-monitor/darwin-arm64/t3-resource-monitor \ + package/dist/prism/linux-x64/cli-proxy-api \ + package/dist/prism/linux-arm64/cli-proxy-api \ + package/dist/prism/darwin-arm64/cli-proxy-api; do + grep -qx "$required" <<< "$listing" || { echo "missing $required in tarball" >&2; exit 1; } + done + tar -xzOf "$TARBALL" package/package.json > "$RUNNER_TEMP/packed-package.json" + node -e ' + const pkg = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); + const [name, version] = process.argv.slice(2); + if (pkg.name !== name) throw new Error(`packed name ${pkg.name} != ${name}`); + if (pkg.version !== version) throw new Error(`packed version ${pkg.version} != ${version}`); + for (const field of ["dependencies", "optionalDependencies", "overrides"]) { + for (const [dep, spec] of Object.entries(pkg[field] ?? {})) { + if (/^(catalog|workspace):/.test(String(spec))) throw new Error(`${field}.${dep} is unresolved: ${spec}`); + } + } + console.log(`packed ${pkg.name}@${pkg.version} with ${Object.keys(pkg.dependencies ?? {}).length} dependencies`); + ' "$RUNNER_TEMP/packed-package.json" "$PACKAGE_NAME" "$VERSION" + + - name: Collect installer and checksums + shell: bash + run: | + set -euo pipefail + cp scripts/fork/install.sh release-assets/install.sh + chmod +x release-assets/install.sh + (cd release-assets && sha256sum -- *.tgz install.sh > checksums.txt) + cat release-assets/checksums.txt + + - name: Write release notes + shell: bash + env: + TAG: ${{ needs.preflight.outputs.tag }} + run: | + set -euo pipefail + upstream_base="$(git merge-base HEAD origin/main 2>/dev/null || echo unknown)" + { + echo "q1code $TAG" + echo + echo "- Built from \`$GITHUB_SHA\`" + echo "- Upstream base: \`$upstream_base\` (\`git log $upstream_base..$GITHUB_SHA\` is the fork series)" + echo + echo "Install or update:" + echo + echo '```sh' + echo "curl -fsSL https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/install.sh | sh -s -- ${TAG#v}" + echo '```' + } > release-assets/release-notes.md + + - name: Upload release assets + uses: actions/upload-artifact@v7 + with: + name: release-assets + path: release-assets/* + if-no-files-found: error + + release: + name: Publish GitHub Release + needs: [preflight, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@v8 + with: + name: release-assets + path: ${{ runner.temp }}/assets + + - name: List downloaded assets + shell: bash + run: find "$RUNNER_TEMP/assets" -maxdepth 4 -type f | sort + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.ref }} + name: q1code ${{ needs.preflight.outputs.tag }} + body_path: ${{ runner.temp }}/assets/release-notes.md + prerelease: ${{ needs.preflight.outputs.is_prerelease }} + make_latest: ${{ needs.preflight.outputs.make_latest }} + files: | + ${{ runner.temp }}/assets/*.tgz + ${{ runner.temp }}/assets/checksums.txt + ${{ runner.temp }}/assets/install.sh + fail_on_unmatched_files: true + token: ${{ github.token }} diff --git a/.github/workflows/fork-sync-watch.yml b/.github/workflows/fork-sync-watch.yml new file mode 100644 index 000000000000..f1fc9bc2c3a5 --- /dev/null +++ b/.github/workflows/fork-sync-watch.yml @@ -0,0 +1,165 @@ +name: Fork Sync Watch + +# Last-resort visibility when the fleet sync unit is down. Uses only +# GITHUB_TOKEN, never pushes: it measures how far `fork` trails upstream main, +# dry-runs the rebase, and keeps exactly one open `fork-sync` issue while +# there is drift or a conflict. + +on: + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: fork-sync-watch + cancel-in-progress: false + +env: + UPSTREAM_REPO: https://github.com/pingdotgg/t3code.git + STALE_DAYS: 3 + +jobs: + watch: + name: Watch upstream drift + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout fork + uses: actions/checkout@v6 + with: + ref: fork + fetch-depth: 0 + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Fetch upstream main + run: | + git remote add upstream "$UPSTREAM_REPO" + git fetch --no-tags upstream main + + - name: Measure drift + id: drift + run: | + set -euo pipefail + base="$(git merge-base HEAD upstream/main)" + behind="$(git rev-list --count "$base..upstream/main")" + series="$(git rev-list --count "$base..HEAD")" + if [[ "$behind" -eq 0 ]]; then + days=0 + else + base_time="$(git log -1 --format=%ct "$base")" + tip_time="$(git log -1 --format=%ct upstream/main)" + days=$(( (tip_time - base_time) / 86400 )) + fi + { + echo "base=$base" + echo "behind=$behind" + echo "series=$series" + echo "days=$days" + echo "upstream_sha=$(git rev-parse upstream/main)" + echo "fork_sha=$(git rev-parse HEAD)" + } >> "$GITHUB_OUTPUT" + + - name: Dry-run rebase onto upstream main + id: rebase + run: | + set -uo pipefail + git config user.name "fork-sync-watch" + git config user.email "fork-sync-watch@users.noreply.github.com" + git config rerere.enabled false + git checkout -q -B dry-run-rebase HEAD + if git rebase upstream/main >rebase.log 2>&1; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + echo "stopped_at=" >> "$GITHUB_OUTPUT" + echo "files=" >> "$GITHUB_OUTPUT" + else + stopped="$(git rev-parse -q --verify REBASE_HEAD || true)" + files="$(git diff --name-only --diff-filter=U | tr '\n' ' ')" + git rebase --abort || true + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "stopped_at=$stopped" >> "$GITHUB_OUTPUT" + echo "files=$files" >> "$GITHUB_OUTPUT" + git log -1 --format='%h %s' "$stopped" || true + fi + git checkout -q --detach "${{ steps.drift.outputs.fork_sha }}" + + - name: Write summary + id: report + env: + BEHIND: ${{ steps.drift.outputs.behind }} + SERIES: ${{ steps.drift.outputs.series }} + DAYS: ${{ steps.drift.outputs.days }} + BASE: ${{ steps.drift.outputs.base }} + UPSTREAM_SHA: ${{ steps.drift.outputs.upstream_sha }} + FORK_SHA: ${{ steps.drift.outputs.fork_sha }} + CONFLICT: ${{ steps.rebase.outputs.conflict }} + STOPPED_AT: ${{ steps.rebase.outputs.stopped_at }} + FILES: ${{ steps.rebase.outputs.files }} + run: | + set -euo pipefail + { + echo "# Fork sync watch" + echo + echo "- fork: \`$FORK_SHA\` ($SERIES commit(s) in the series)" + echo "- upstream main: \`$UPSTREAM_SHA\`" + echo "- series base: \`$BASE\`" + echo "- behind upstream: $BEHIND commit(s), spanning $DAYS day(s)" + if [[ "$CONFLICT" == "true" ]]; then + echo "- dry-run rebase: **conflict** at \`$STOPPED_AT\` in: $FILES" + else + echo "- dry-run rebase: clean" + fi + echo + echo "_Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}_" + } > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + if [[ "$CONFLICT" == "true" || "$DAYS" -gt "$STALE_DAYS" ]]; then + echo "drift=true" >> "$GITHUB_OUTPUT" + else + echo "drift=false" >> "$GITHUB_OUTPUT" + fi + + - name: Reconcile the fork-sync issue + env: + GH_TOKEN: ${{ github.token }} + DRIFT: ${{ steps.report.outputs.drift }} + CONFLICT: ${{ steps.rebase.outputs.conflict }} + DAYS: ${{ steps.drift.outputs.days }} + run: | + set -euo pipefail + label=fork-sync + open_issues="$(gh issue list --label "$label" --state open --json number --jq '.[].number')" + first="$(printf '%s\n' "$open_issues" | head -n 1)" + + if [[ "$DRIFT" != "true" ]]; then + for number in $open_issues; do + gh issue comment "$number" --body-file report.md + gh issue close "$number" --comment "fork is within ${STALE_DAYS} days of upstream and rebases cleanly." + done + exit 0 + fi + + if [[ "$CONFLICT" == "true" ]]; then + title="fork-sync: rebase onto upstream main conflicts" + else + title="fork-sync: fork is ${DAYS} days behind upstream main" + fi + + gh label create "$label" --description "fork drifts from upstream" --color D93F0B --force >/dev/null + + if [[ -z "$first" ]]; then + gh issue create --title "$title" --label "$label" --body-file report.md + else + gh issue edit "$first" --title "$title" --body-file report.md + # Keep exactly one: close any duplicates that were opened by hand. + for number in $open_issues; do + [[ "$number" == "$first" ]] && continue + gh issue close "$number" --comment "Duplicate of #$first." + done + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2785ecb8fa78..48cd451e3fea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - # Off minute zero: GitHub delays scheduled runs most at the top of the hour. - - cron: "38 */3 * * *" + # Avoid minute zero, when GitHub scheduled jobs are busiest. + - cron: "8,38 * * * *" workflow_dispatch: inputs: channel: @@ -28,7 +28,7 @@ on: # own group so a nightly never blocks them. Running publishers are never # canceled, and queue: max keeps every pending run instead of the default # newest-wins single slot, so a queued stable tag can never be silently -# dropped. Queued nightlies with no new commits skip via check_changes. +# dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} cancel-in-progress: false @@ -40,41 +40,25 @@ permissions: jobs: check_changes: - name: Check for changes since last nightly + name: Check automatic nightly release if: github.event_name == 'schedule' runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 outputs: - has_changes: ${{ steps.check.outputs.has_changes }} + has_changes: ${{ steps.check.outputs.result }} steps: - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + sparse-checkout: .github/scripts - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi + name: Check release gap and new commits + uses: actions/github-script@v8 + with: + script: | + const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); + return await shouldReleaseNightly({ github, context, core }); preflight: name: Preflight @@ -228,7 +212,7 @@ jobs: name: Resolve T3 Connect public config # Consumes only the commit SHA, not preflight's resolved version, so it runs # alongside preflight instead of after it. The condition mirrors preflight's: - # check_changes is skipped on non-schedule events (skipped is neither failure + # check_changes is skipped on manual and tag releases (skipped is neither failure # nor success, so success() would be wrong here). needs: [check_changes] if: | diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2d361..8ef16c570b08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ @AGENTS.md +@fork/FORK.md diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cb587e152aaa..0719fe1865f8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,6 +16,7 @@ "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", "@napi-rs/keyring": "^1.3.0", + "@q1code/core": "workspace:*", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..d21eefd65f85 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -194,7 +194,10 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (settings.serverExposureMode === "network-accessible") { + } else if ( + settings.serverExposureMode === "network-accessible" && + serverExposureState.mode === "local-only" + ) { yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..1641149e9e35 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -63,17 +63,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { - const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - - assert.equal( - DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), - "clerk.t3.codes", - ); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); - }); - it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; @@ -208,27 +197,4 @@ describe("DesktopClerk", () => { Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), ); }); - - it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, - ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue(bridge); - - assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); - assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme, host: "app" }, - }, - ], - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..d3c99e5e1d24 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -53,7 +53,7 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} -export function resolveDesktopClerkFrontendApiHostname( +function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { const normalizedKey = publishableKey?.trim(); @@ -72,7 +72,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts index b7647b5cc10f..9c87777ea323 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts @@ -86,7 +86,7 @@ describe("DesktopEarlyElectronStartup", () => { }); }); - it("keeps implicit development state under ~/.t3/dev when T3CODE_HOME is unset", () => { + it("keeps implicit development state under ~/.q1code/dev when T3CODE_HOME is unset", () => { const preference = resolveEarlyLinuxPasswordStorePreference({ env: { VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", @@ -94,7 +94,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.q1code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "kwallet" }); }, }); @@ -111,7 +111,7 @@ describe("DesktopEarlyElectronStartup", () => { homeDirectory: "/home/user", joinPath, readFileString: (path) => { - assert.equal(path, "/home/user/.t3/dev/desktop-settings.json"); + assert.equal(path, "/home/user/.q1code/dev/desktop-settings.json"); return JSON.stringify({ linuxPasswordStore: "gnome-libsecret" }); }, }); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 89cc592831a7..0efccf8b7698 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -130,8 +130,8 @@ describe("DesktopEnvironment", () => { ); const production = yield* makeEnvironment(); - assert.equal(development.stateDir, "/Users/alice/.t3/dev"); - assert.equal(production.stateDir, "/Users/alice/.t3/userdata"); + assert.equal(development.stateDir, "/Users/alice/.q1code/dev"); + assert.equal(production.stateDir, "/Users/alice/.q1code/userdata"); }), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a180f45937d8 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -37,45 +37,22 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); - it("reads an explicit Electron command-line switch value", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: (switchName) => switchName === "password-store", - getSwitchValue: (switchName) => { - assert.equal(switchName, "password-store"); - return "basic"; - }, - }, - "password-store", + it.effect("preserves an explicit Linux password-store switch", () => { + hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); + getSwitchValueMock.mockReturnValue(" basic "); + + return Effect.gen(function* () { + const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + + assert.equal(options.linuxPasswordStoreCommandLine, "basic"); + assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); + }).pipe( + Effect.provide( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ), ); - - assert.equal(value, "basic"); - }); - - it("treats valueless Electron command-line switches as absent", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => true, - getSwitchValue: () => "", - }, - "password-store", - ); - - assert.isNull(value); - }); - - it("returns null for missing Electron command-line switches", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => false, - getSwitchValue: () => { - throw new Error("Unexpected switch value read."); - }, - }, - "password-store", - ); - - assert.isNull(value); }); it.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0bb..718f54115065 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -17,7 +17,7 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } -export function readCommandLineSwitchValue( +function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, ): string | null { diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 006dd97092d4..49147967c036 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,3 +1,4 @@ +import { BRAND } from "@q1code/core/brand"; // fork: base import * as Option from "effect/Option"; export type JoinPath = (first: string, ...segments: string[]) => string; @@ -15,8 +16,9 @@ export function resolveDesktopBaseDir(input: { readonly joinPath: JoinPath; readonly t3Home: Option.Option<string>; }): string { - return Option.getOrElse(normalizeConfiguredBaseDir(input.t3Home), () => - input.joinPath(input.homeDirectory, ".t3"), + return Option.getOrElse( + normalizeConfiguredBaseDir(input.t3Home), + () => input.joinPath(input.homeDirectory, BRAND.homeDirName), // fork: base ); } diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..747663b80ac0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -218,14 +218,6 @@ const withPackagedWslHarness = <A, E, R>( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it("accepts only normalized SHA-256 archive identities", () => { - assert.equal( - DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), - "a".repeat(64), - ); - assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); - }); - it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..7a8dc8334cf1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -243,7 +243,7 @@ const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; -export const parseWslRuntimeArchiveHash = (value: string): string | null => { +const parseWslRuntimeArchiveHash = (value: string): string | null => { const trimmed = value.trim(); return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..eb0becee0981 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -272,8 +272,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +288,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); @@ -307,9 +304,9 @@ describe("DesktopServerExposure", () => { ); }); - it.effect("resolves advertised endpoints from the scoped runtime state", () => + it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () => withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); @@ -324,6 +321,32 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("keeps Tailscale-only hosts network-accessible", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, null); + assert.equal(state.endpointUrl, null); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]), + [ + ["loopback", "http://127.0.0.1:4173/"], + ["private-network", "http://100.90.1.2:4173/"], + ], + ); + }), + ), + ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, @@ -345,7 +368,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("uses ConfigProvider desktop exposure overrides", () => + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -353,17 +376,17 @@ describe("DesktopServerExposure", () => { yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"], ); }), { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_LAN_HOST: "100.90.1.2", T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", }, ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..24c24c15a00f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => }; const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); + !address.startsWith("127.") && + !address.startsWith("169.254.") && + !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { @@ -244,7 +246,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +253,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; @@ -378,7 +378,14 @@ function resolveRuntimeState(input: { ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = - input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + input.requestedMode === "network-accessible" && + requestedExposure.endpointUrl === null && + !Object.values(input.networkInterfaces).some((addresses) => + addresses?.some( + (address) => + !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), + ), + ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only", diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option<Electron.BrowserWindow>; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass<Electro } } -export const isElectronThemeSetSourceError = Schema.is(ElectronThemeSetSourceError); - export class ElectronTheme extends Context.Service< ElectronTheme, { diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index c2acc9ce120a..1e005d26fdf5 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -71,7 +71,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass<ElectronW } } -export const isElectronWindowCreateError = Schema.is(ElectronWindowCreateError); - export class ElectronWindowOperationError extends Schema.TaggedErrorClass<ElectronWindowOperationError>()( "ElectronWindowOperationError", { diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod<E, R> { readonly channel: string; diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer<FileSystem.FileSystem>) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 0d9ddc8fde91..89e1a7fb19e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, proactivePanelsEnabled: true, @@ -57,6 +58,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; @@ -136,6 +139,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -222,17 +278,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect<ClientSettings, Schema.SchemaError> => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass<DesktopClientSettingsReadError>()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass<Des export class DesktopClientSettings extends Context.Service< DesktopClientSettings, { - readonly get: Effect.Effect<Option.Option<ClientSettings>>; + readonly get: Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect<void, DesktopClientSettingsWriteError>; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect<Option.Option<ClientSettings>> => +): Effect.Effect<Option.Option<ClientSettings>, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none<string>()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none<string>()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none<ClientSettings>()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 20f005f2ab2d..344d135a1024 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -155,7 +155,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 9dbe43b9650d..e1188e1a3387 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -12,21 +12,17 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - buildWslNodeEnvPreamble, buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, - formatNodePtyProbeFailureReason, - formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, parseWslRuntimeRoot, probeWslDistros, - sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -144,46 +140,19 @@ describe("probeWslDistros", () => { }); }); -describe("formatNodePtyProbeFailureReason", () => { - it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { - const reason = formatNodePtyProbeFailureReason(4); - - expect(reason).toContain("packaged Linux node-pty binary was not included"); - expect(reason).toContain("--wsl-prebuild"); - }); - - it("leaves other node-pty load failures to the compatibility diagnostic", () => { - expect(formatNodePtyProbeFailureReason(1)).toBeNull(); - }); -}); - -describe("formatWslShellTransportFailureReason", () => { - it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { - expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); - expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); - expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); - expect(formatWslShellTransportFailureReason(null)).toBeNull(); - }); -}); - -describe("buildWslNodeEnvPreamble", () => { - it("passes the required Node engine range into the shared resolver", () => { - const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); - - expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); - expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( - preamble.lastIndexOf("ensure_remote_node_path || true"), - ); - }); - - it("keeps the shared resolver permissive when no Node engine range is provided", () => { - expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); - }); -}); - describe("WSL runtime cache", () => { - it("sanitizes cache ids before interpolating them into Linux paths", () => { - expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + it.each([ + [ + "install", + (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)), + ], + ["prune", buildWslRuntimePruneScript], + ["invalidate", buildWslRuntimeInvalidateScript], + ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => { + const runtimeId = "1.2.3/x64; touch /tmp/nope"; + const script = buildScript(runtimeId); + expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); + expect(script).not.toContain(runtimeId); }); it("installs through a temporary directory and only reuses valid completed caches", () => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b0c9f5ffe44b..d49d95676e64 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -147,7 +147,7 @@ const TIMEOUT_RESULT: ShellResult = { transportFailure: "timeout", }; -export const formatWslShellTransportFailureReason = ( +const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { @@ -165,7 +165,7 @@ export const formatWslShellTransportFailureReason = ( // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. -export const buildWslNodeEnvPreamble = ( +const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true @@ -263,8 +263,7 @@ const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; -export const sanitizeWslRuntimeId = (value: string): string => - value.replace(/[^A-Za-z0-9._-]/g, "_"); +const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); // `archiveSha256` is the digest the build recorded alongside the archive. The // install verifies the bytes before extracting, so an archive can never be @@ -491,7 +490,7 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; -export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => +const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild <path-to-linux-pty.node>` or install a build that includes WSL support." : null; diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 41e358227e1e..dd750164b381 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect } from "vite-plus/test"; import { - DISTRO_NAME_PATTERN, extractDistroFromUncPath, isValidDistroName, parseWslDistroList, - resolveWslHomeUncPath, resolveWslPickFolderDefaultPath, wslUncPathToLinuxPath, } from "./wslPathParsing.ts"; @@ -116,29 +114,6 @@ describe("wslUncPathToLinuxPath", () => { }); }); -describe("resolveWslHomeUncPath", () => { - const distros = [ - { name: "Debian", isDefault: true, version: 2 as const }, - { name: "Ubuntu", isDefault: false, version: 2 as const }, - ]; - - it("uses the configured distro when one is selected", () => { - expect(resolveWslHomeUncPath({ distro: "Ubuntu" }, distros)).toBe( - "\\\\wsl.localhost\\Ubuntu\\home", - ); - }); - - it("uses the actual default distro when config uses the WSL default", () => { - expect(resolveWslHomeUncPath({ distro: null }, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - - it("omits the default path when no default distro is known", () => { - expect(resolveWslHomeUncPath({ distro: null }, [])).toBeNull(); - }); -}); - describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; @@ -184,23 +159,22 @@ describe("resolveWslPickFolderDefaultPath", () => { }); }); -describe("DISTRO_NAME_PATTERN / isValidDistroName", () => { +describe("isValidDistroName", () => { it("accepts common distro names", () => { for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(true); expect(isValidDistroName(name)).toBe(true); } }); it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); it("rejects names containing control or shell-meta characters", () => { for (const name of ["bad\nname", "bad\tname", "bad/name", "bad!name", "bad;name"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index edbab81f6dc2..baae217c823d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -10,7 +10,7 @@ export interface WslConfig { // Literal space — \s would also match \n/\t/\r and corrupt UNC paths like \\wsl.localhost\<distro>\... // Trailing char must also be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects. -export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; @@ -61,10 +61,7 @@ export function wslUncPathToLinuxPath(windowsPath: string): string | null { return `/${rest.split("\\").filter(Boolean).join("/")}`; } -export function resolveWslHomeUncPath( - config: WslConfig, - distros: readonly WslDistro[], -): string | null { +function resolveWslHomeUncPath(config: WslConfig, distros: readonly WslDistro[]): string | null { const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; return distroName ? `\\\\wsl.localhost\\${distroName}\\home` : null; } diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 6f37ae922dad..5ba3da4fba10 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; export default defineConfig({ + site: "https://t3.codes", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/public/95/providers/antigravity-320.webp b/apps/marketing/public/95/providers/antigravity-320.webp new file mode 100644 index 000000000000..979fa8b67ab9 Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-320.webp differ diff --git a/apps/marketing/public/95/providers/antigravity-640.webp b/apps/marketing/public/95/providers/antigravity-640.webp new file mode 100644 index 000000000000..dfc03912b3f6 Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-640.webp differ diff --git a/apps/marketing/public/95/providers/antigravity-960.webp b/apps/marketing/public/95/providers/antigravity-960.webp new file mode 100644 index 000000000000..19f27adf2eaa Binary files /dev/null and b/apps/marketing/public/95/providers/antigravity-960.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-320.webp b/apps/marketing/public/95/providers/claude-code-320.webp new file mode 100644 index 000000000000..28a15e299b2b Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-320.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-640.webp b/apps/marketing/public/95/providers/claude-code-640.webp new file mode 100644 index 000000000000..2df6de1ac6ae Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-640.webp differ diff --git a/apps/marketing/public/95/providers/claude-code-960.webp b/apps/marketing/public/95/providers/claude-code-960.webp new file mode 100644 index 000000000000..0b6ed03fefce Binary files /dev/null and b/apps/marketing/public/95/providers/claude-code-960.webp differ diff --git a/apps/marketing/public/95/providers/codex-320.webp b/apps/marketing/public/95/providers/codex-320.webp new file mode 100644 index 000000000000..ec67c7949954 Binary files /dev/null and b/apps/marketing/public/95/providers/codex-320.webp differ diff --git a/apps/marketing/public/95/providers/codex-640.webp b/apps/marketing/public/95/providers/codex-640.webp new file mode 100644 index 000000000000..9d1d041f0c1e Binary files /dev/null and b/apps/marketing/public/95/providers/codex-640.webp differ diff --git a/apps/marketing/public/95/providers/codex-960.webp b/apps/marketing/public/95/providers/codex-960.webp new file mode 100644 index 000000000000..a19fe1010ffa Binary files /dev/null and b/apps/marketing/public/95/providers/codex-960.webp differ diff --git a/apps/marketing/public/95/providers/cursor-320.webp b/apps/marketing/public/95/providers/cursor-320.webp new file mode 100644 index 000000000000..32e5e9d27e99 Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-320.webp differ diff --git a/apps/marketing/public/95/providers/cursor-640.webp b/apps/marketing/public/95/providers/cursor-640.webp new file mode 100644 index 000000000000..f5bbf729993e Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-640.webp differ diff --git a/apps/marketing/public/95/providers/cursor-960.webp b/apps/marketing/public/95/providers/cursor-960.webp new file mode 100644 index 000000000000..4480a3ebda96 Binary files /dev/null and b/apps/marketing/public/95/providers/cursor-960.webp differ diff --git a/apps/marketing/public/95/providers/grok-320.webp b/apps/marketing/public/95/providers/grok-320.webp new file mode 100644 index 000000000000..0e8a945c0392 Binary files /dev/null and b/apps/marketing/public/95/providers/grok-320.webp differ diff --git a/apps/marketing/public/95/providers/grok-640.webp b/apps/marketing/public/95/providers/grok-640.webp new file mode 100644 index 000000000000..374fcc4bc1ff Binary files /dev/null and b/apps/marketing/public/95/providers/grok-640.webp differ diff --git a/apps/marketing/public/95/providers/grok-960.webp b/apps/marketing/public/95/providers/grok-960.webp new file mode 100644 index 000000000000..bcb0cc8902af Binary files /dev/null and b/apps/marketing/public/95/providers/grok-960.webp differ diff --git a/apps/marketing/public/95/providers/opencode-320.webp b/apps/marketing/public/95/providers/opencode-320.webp new file mode 100644 index 000000000000..86e97872beda Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-320.webp differ diff --git a/apps/marketing/public/95/providers/opencode-640.webp b/apps/marketing/public/95/providers/opencode-640.webp new file mode 100644 index 000000000000..44720f0e7681 Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-640.webp differ diff --git a/apps/marketing/public/95/providers/opencode-960.webp b/apps/marketing/public/95/providers/opencode-960.webp new file mode 100644 index 000000000000..312f1144fc53 Binary files /dev/null and b/apps/marketing/public/95/providers/opencode-960.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp new file mode 100644 index 000000000000..a05a5738a6cd Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-chrome.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp new file mode 100644 index 000000000000..1a16b037cf0b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-desktop.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp new file mode 100644 index 000000000000..b380194d23e1 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp new file mode 100644 index 000000000000..046fdff0c0dc Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp new file mode 100644 index 000000000000..ebce442dba7f Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-lime.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp new file mode 100644 index 000000000000..3ab511b49eef Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp new file mode 100644 index 000000000000..6fa9e045056b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp new file mode 100644 index 000000000000..f9f76140e243 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-clouds.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp new file mode 100644 index 000000000000..dd72a9110265 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp new file mode 100644 index 000000000000..3aa1c274507b Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp new file mode 100644 index 000000000000..e2510c829bb1 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-foil.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp new file mode 100644 index 000000000000..f3ba3edb5190 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-320.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp new file mode 100644 index 000000000000..8971323e7af5 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender-640.webp differ diff --git a/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp new file mode 100644 index 000000000000..aa535e600a57 Binary files /dev/null and b/apps/marketing/public/95/t3-code-concepts/t3-code-nightly-lavender.webp differ diff --git a/apps/marketing/src/components/RetroBox.astro b/apps/marketing/src/components/RetroBox.astro new file mode 100644 index 000000000000..81fcc1eb159f --- /dev/null +++ b/apps/marketing/src/components/RetroBox.astro @@ -0,0 +1,23 @@ +--- +interface Props { + image: string; + alt: string; + sizes: string; + eager?: boolean; +} + +const { image, alt, sizes, eager = false } = Astro.props; +const base = `/95/t3-code-concepts/${image}`; +--- + +<img + src={`${base}-640.webp`} + srcset={`${base}-320.webp 320w, ${base}-640.webp 640w, ${base}.webp 960w`} + {sizes} + {alt} + width="960" + height="1200" + loading={eager ? "eager" : "lazy"} + fetchpriority={eager ? "high" : "auto"} + decoding="async" +/> diff --git a/apps/marketing/src/components/RetroIcon.astro b/apps/marketing/src/components/RetroIcon.astro new file mode 100644 index 000000000000..21ff375bc5a3 --- /dev/null +++ b/apps/marketing/src/components/RetroIcon.astro @@ -0,0 +1,57 @@ +--- +interface Props { + name: "computer" | "disk" | "globe" | "bin" | "folder" | "cursor"; + size?: number; +} + +const { name, size = 32 } = Astro.props; +--- + +<svg width={size} height={size} viewBox="0 0 32 32" fill="none" shape-rendering="crispEdges" aria-hidden="true"> + {name === "computer" && <> + <path fill="#000" d="M3 2h25v22H3zM10 24h11v3h7v4H3v-4h7z" /> + <path fill="#c0c0c0" d="M3 2h23v20H3zM12 22h7v5h7v2H4v-2h8z" /> + <path fill="#fff" d="M3 2h23v2H5v18H3zM4 27h22v1H4z" /> + <path fill="#808080" d="M6 5h17v14H6zM5 20h20v2H5z" /> + <path fill="#000080" d="M8 7h15v10H8z" /> + <path fill="#51e5cf" d="M9 8h13v2H9zM9 11h5v4H9z" /> + <path fill="#fff" d="M16 11h5v1h-5zM16 14h4v1h-4z" /> + <path fill="#baff00" d="M21 20h2v1h-2z" /> + </>} + {name === "disk" && <> + <path fill="#000" d="M3 2h23l4 4v24H3z" /> + <path fill="#4747b8" d="M3 2h22l3 3v23H3z" /> + <path fill="#9c9cff" d="M3 2h2v26H3zM5 2h20v2H5z" /> + <path fill="#c0c0c0" d="M8 3h14v9H8z" /> + <path fill="#fff" d="M8 3h2v9H8z" /> + <path fill="#000" d="M17 4h4v6h-4z" /> + <path fill="#fff" d="M7 16h17v12H7z" /> + <path fill="#ff65b3" d="M7 16h17v3H7z" /> + <path fill="#808080" d="M10 21h11v1H10zM10 24h8v1h-8z" /> + </>} + {name === "globe" && <> + <path fill="#000" d="M11 1h10v2h5v5h3v16h-4v5h-5v2H10v-3H5v-5H2V10h3V5h6z" /> + <path fill="#5353dd" d="M11 2h9v2h5v5h3v13h-4v5h-5v3h-8v-3H6v-5H3V11h3V6h5z" /> + <path fill="#80ffff" d="M11 3h8v2h-7v3H8v5H5v-3h2V6h4z" /> + <path fill="#00ba80" d="M14 5h7v4h4v5h-6v-3h-5zM7 13h7v3h6v4h-4v6h-5v-5H7zM23 21h3v3h-3z" /> + <path fill="#baff00" d="M14 5h6v2h-6zM7 13h6v2H7z" /> + </>} + {name === "bin" && <> + <path fill="#000" d="M6 8h21v3h-2v18H8V11H6zM10 3h12v3h5v3H5V6h5z" /> + <path fill="#c0c0c0" d="M7 10h17v18H9V13H7zM7 6h17v3H7zM12 3h7v3h-7z" /> + <path fill="#fff" d="M9 11h2v15H9zM7 6h17v1H7zM13 12h2v13h-2zM18 12h2v13h-2z" /> + <path fill="#808080" d="M15 12h2v13h-2zM20 12h2v13h-2zM11 27h13v2H11z" /> + <path fill="#2f986b" d="M13 15h7v2h-4v2h-3zM12 20h3v2h5v2h-8zM20 18h2v4h-2z" /> + </>} + {name === "folder" && <> + <path fill="#000" d="M2 6h12v3h15v20H2z" /> + <path fill="#e3ba3f" d="M2 6h11v3h14v18H2z" /> + <path fill="#fff4a0" d="M2 6h11v2H4v18H2z" /> + <path fill="#fff4a0" d="M5 13h25v3h-2v6h-2v5H3v-8h2z" /> + <path fill="#e3ba3f" d="M6 15h23v1h-2v6h-2v4H5v-7h1z" /> + </>} + {name === "cursor" && <> + <path fill="#000" d="M5 1h3v3h3v3h3v3h3v3h3v3h3v3h3v4H16l5 8h-7l-5-9-4 4z" /> + <path fill="#fff" d="M7 5v16l4-4 6 12h2l-6-12h10z" /> + </>} +</svg> diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index ca5ea15f61de..4c95bc86560e 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- -import { Image } from "astro:assets"; +import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; +import desktopScreenshot from "../assets/app-desktop.webp"; import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; import "../styles/fonts.css"; import { @@ -21,6 +22,21 @@ const { description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; + +// Social preview card. Link unfurlers (Instagram, iMessage, X, Slack) want a +// 1200x630 jpg or png at an absolute URL. Built from the hero screenshot so it +// stays in sync with the homepage. +const socialImage = await getImage({ + src: desktopScreenshot, + width: 1200, + height: 630, + fit: "cover", + position: "top", + format: "jpg", + quality: 90, +}); +const socialImageUrl = new URL(socialImage.src, Astro.site); +const canonicalUrl = new URL(Astro.url.pathname, Astro.site); --- <html lang="en"> @@ -28,6 +44,21 @@ const { <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="description" content={description} /> + <link rel="canonical" href={canonicalUrl} /> + <meta property="og:type" content="website" /> + <meta property="og:site_name" content="T3 Code" /> + <meta property="og:url" content={canonicalUrl} /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={description} /> + <meta property="og:image" content={socialImageUrl} /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="630" /> + <meta property="og:image:alt" content="The T3 Code desktop app with a thread open." /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:site" content="@t3dotgg" /> + <meta name="twitter:title" content={title} /> + <meta name="twitter:description" content={description} /> + <meta name="twitter:image" content={socialImageUrl} /> <link rel="preload" href={dmSansLatinUrl} diff --git a/apps/marketing/src/pages/95.astro b/apps/marketing/src/pages/95.astro new file mode 100644 index 000000000000..4c3edabbd68a --- /dev/null +++ b/apps/marketing/src/pages/95.astro @@ -0,0 +1,354 @@ +--- +import RetroIcon from "../components/RetroIcon.astro"; +import RetroBox from "../components/RetroBox.astro"; +import { GITHUB_REPOSITORY_URL, MARKETING_STATS } from "../lib/site"; +import "../styles/retro.css"; + +const agents = [ + { + name: "Claude Code", + slug: "claude-code", + caption: "Now with manners.", + artwork: "Cream and orange software box with a bow-tied desktop computer and a gold PLEASE sticker.", + }, + { + name: "Codex", + slug: "codex", + caption: "Some assembly automated.", + artwork: "Silver and green software box with a robot hand stacking glowing code blocks.", + }, + { + name: "Cursor", + slug: "cursor", + caption: "Point. Click. Question everything.", + artwork: "Black and chrome software box with an enormous silver arrow and lightning bolts.", + }, + { + name: "Grok", + slug: "grok", + caption: "Adult supervision sold separately.", + artwork: "Black and red software box with a spacecraft and an UH OH sticker.", + }, + { + name: "OpenCode", + slug: "opencode", + caption: "Please copy this floppy.", + artwork: "Purple shareware box with floppy disks spilling from an open carton.", + }, + { + name: "Antigravity", + slug: "antigravity", + caption: "System requirement: no gravity.", + artwork: "Blue and violet software box with a floating chrome triangle and metallic spheres.", + }, +]; +const userDigits = MARKETING_STATS.users.replaceAll(",", "").padStart(7, "0").split(""); +--- + +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="Introducing T3 Code. All your coding agents in one happy little window. Free, open source, and available without six CD-ROMs." /> + <meta name="theme-color" content="#000000" /> + <link rel="icon" href="/favicon.ico" sizes="48x48" /> + <title>T3 Code '95 | The future is now. Like, right now. + + + + + + +
+
+
+ T3 Code '95 - Internet Explorer +
+ + + +
+
+ + + +
+ Address +
https://t3.codes/95
+ Go +
+ +
+
+
+

All your agents.
One happy
little window.

+

Your coding agents in one free, open-source app.
You bring the subscriptions. We bring the buttons.

+
AVAILABLE FORWindows·macOS·Linux
+
+ + +
+ +
+

Bring your favorite agents.

+
+ {agents.map((agent, index) => ( +
+ `/95/providers/${agent.slug}-${width}.webp ${width}w`).join(", ")} + sizes="auto, (max-width: 620px) 43vw, (max-width: 1100px) 27vw, 280px" + alt={agent.artwork} + width="640" + height="800" + loading={index < 3 ? "eager" : "lazy"} + decoding="async" + /> +
+

{agent.name}

+

{agent.caption}

+
+
+ ))} +
+

Use the providers and subscriptions you already have. Provider charges still apply.

+

Parody packaging. No actual boxes for sale.

+
+ +
+

Inside every copy.

+
+
+
01_multitasking.exe
+

More agents.
Less window aerobics.

Run agents side by side. Keep your threads, terminals, and diffs together. Reclaim your Alt-Tab finger.

+
+
+
02_remote_access.exe
+

Your computer.
Now over there.

Connect from the web, desktop, or mobile. Your agents keep working on your machine. You may approach the couch.

+
+
+
03_source_code.txt
+

We left the
source code in.

Read it. Change it. Fork the whole thing. It's MIT licensed, because your tools should actually be yours.

View source
+
+
+
04_terminal.exe
+

A terminal.
Right where you work.

Run commands alongside your agents. Keep the output with the project.

+
+
+
05_changes.diff
+

See what
actually changed.

Inspect file diffs and review the work before you ship it.

+
+
+
06_checkpoint.bak
+

A way
back.

Use turn checkpoints to inspect changes and restore earlier work.

+
+
+
+ +
+

For just 0 easy payments
of absolutely nothing.

Get the app. Keep your subscriptions.

GET T3 CODE. IT'S FREE.

T3 Code is free. Your AI provider may charge for use.

+
Run

Too cool for an installer?

Node.js required. Modem optional.

System requirements
  • A modern computer
  • A supported coding agent
  • A dream, ideally a small one

Does not actually run on Windows 95.

+
+ +
+

Questions

+
Wait. Is this a real product?

Yes. T3 Code is a real, free, open-source app used by {MARKETING_STATS.users} developers. The packaging is a joke. The app is not. Visit the regular website.

+
Does this replace my Claude or Codex subscription?

No. T3 Code connects to the coding agents you already use. Keep your provider accounts and subscriptions. T3 Code gives you one app to work with them.

+
Will it run on Windows 95?

Absolutely not. We brought back the look, not the driver problems. Get a build for a current version of Windows, macOS, or Linux.

+
Where do I mail my check?

Please do not mail us a check for zero dollars. Just download the app. The entire accounts department is a download button.

+
+ + +
+ +
Done. Internet
+
+ + +
+ +
+
Start
+ + +
4:04 PM
+
+ +
T3 Code '95

Get T3 Code
+ + + + diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a4fdc966b9d8..db31f24e6bec 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -205,7 +205,11 @@ const screenshot = await getImage({
-
+ @@ -826,8 +830,11 @@ const screenshot = await getImage({ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; - padding: 4px max(32px, calc((100vw - 1240px) / 2 + 32px)); - scroll-padding-inline: max(32px, calc((100vw - 1240px) / 2 + 32px)); + /* Percentages resolve against this element's own box rather than the + viewport, so the first card lines up with the heading's .container edge + even when a classic scrollbar makes 100vw wider than the layout. */ + padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); + scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); } .endorsement-card { @@ -899,6 +906,11 @@ const screenshot = await getImage({ align-items: stretch; gap: 20px; } + .git-visual .btn { + line-height: normal; + pointer-events: none; + } + .pr-card { padding: 20px; } .pr-head { display: flex; align-items: center; gap: 10px; @@ -952,6 +964,7 @@ const screenshot = await getImage({ padding: 12px 20px; background: var(--fg); color: #09090b; font-weight: 600; font-size: 13px; + line-height: normal; border-radius: 10px; box-shadow: 0 8px 24px -8px rgba(255, 255, 255, 0.2); } diff --git a/apps/marketing/src/styles/retro.css b/apps/marketing/src/styles/retro.css new file mode 100644 index 000000000000..df166eb71ded --- /dev/null +++ b/apps/marketing/src/styles/retro.css @@ -0,0 +1,1244 @@ +/* This page has its own document so the retro styles do not affect other pages. */ +:root { + color-scheme: dark; + font-family: Tahoma, Verdana, Arial, sans-serif; + color: #fff; + background: #000; + --silver: #c0c0c0; + --yellow: #eaff00; + --pink: #ff79bd; + --navy: #000080; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + height: 100dvh; + overflow: hidden; +} +button, +input { + font: inherit; +} +button, +a, +summary { + -webkit-tap-highlight-color: transparent; +} +button, +summary { + cursor: pointer; +} +button { + color: inherit; +} +a { + color: inherit; +} +button { + border-radius: 0; +} +svg { + flex-shrink: 0; +} +[hidden] { + display: none !important; +} +:focus-visible { + outline: 2px dashed var(--pink); + outline-offset: 4px; +} +section { + scroll-margin-top: 24px; +} +.skip-link { + position: fixed; + top: -80px; + left: 12px; + z-index: 100; +} +.skip-link:focus { + top: 12px; +} +.raised { + border: 2px solid; + border-color: #fff #333 #333 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; +} +.sunken { + border: 2px solid; + border-color: #808080 #fff #fff #808080; + box-shadow: inset 1px 1px #000; +} +.retro-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 2px solid; + border-color: #fff #000 #000 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; + padding: 8px 16px; + background: var(--silver); + color: #000; + text-decoration: none; + font-size: 12px; + font-weight: 700; +} +.retro-button:active { + border-color: #000 #fff #fff #000; + box-shadow: inset 1px 1px #808080; +} +.retro-button:hover { + background: #d7d7d7; +} +.desktop { + position: fixed; + inset: 0 0 42px; + z-index: 1; + max-width: 1280px; + margin: 0 auto; + padding: 28px 30px 40px 112px; + pointer-events: none; +} +.desktop-icons { + position: absolute; + top: 37px; + left: max(10px, calc((100vw - 1280px) / 2 + 10px)); + width: 83px; + display: grid; + gap: 29px; +} +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + border: 0; + background: none; + color: #fff; + text-align: center; + text-decoration: none; + font-size: 11px; + line-height: 1.4; + padding: 3px 0; +} +.desktop-icon:hover span, +.desktop-icon:focus-visible span { + background: var(--navy); +} +.browser-window { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 3px; + background: var(--silver); + pointer-events: auto; + transform: translate(var(--window-x, 0px), var(--window-y, 0px)); +} +.browser-window > :not(main) { + flex-shrink: 0; +} +#window-title { + cursor: grab; + touch-action: none; + user-select: none; +} +#window-title.dragging { + cursor: grabbing; +} +#window-title:focus-visible { + outline-offset: -2px; +} +.window-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 27px; + padding: 3px 4px 3px 6px; + background: linear-gradient(90deg, #000080, #2253a4); + color: #fff; + font-size: 12px; + font-weight: 700; +} +.window-name { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} +.window-name > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.window-controls { + display: flex; + gap: 3px; +} +.window-control { + width: 20px; + height: 20px; + min-width: 20px; + padding: 0; + font: + 700 18px Arial, + sans-serif; +} +.window-control:first-child { + font-size: 17px; +} +.maximize-icon { + width: 10px; + height: 10px; + border: 1px solid #000; + border-top-width: 3px; +} +.browser-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 4px; + color: #000; +} +.browser-menu > a, +.browser-menu > button { + padding: 5px 8px; + border: 0; + background: none; + text-decoration: none; + font-size: 11px; +} +.browser-menu > a:hover, +.browser-menu > button:hover { + color: #fff; + background: var(--navy); +} +.address-bar { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 7px 9px; + color: #000; + font-size: 11px; +} +.address-field { + display: flex; + align-items: center; + gap: 7px; + flex: 1; + padding: 4px 6px; + background: #fff; + min-width: 0; +} +.address-field > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.address-go { + align-self: stretch; + padding: 2px 9px; + font-weight: 400; +} +main { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + border: 2px solid; + border-color: #555 #fff #fff #555; + background: #000; +} +main::-webkit-scrollbar { + width: 16px; + height: 16px; +} +main::-webkit-scrollbar-track, +main::-webkit-scrollbar-corner { + background: #dfdfdf; +} +main::-webkit-scrollbar-thumb { + border: 2px solid; + border-color: #fff #333 #333 #fff; + background: var(--silver); + box-shadow: inset -1px -1px #808080; +} +.hero { + display: grid; + grid-template-columns: 1.1fr 1fr; + align-items: center; + padding: 16px 35px 20px; + gap: 8px; +} +.hero h1 { + font-size: clamp(36px, 3.4vw, 48px); + letter-spacing: -2px; +} +.hero .hero-explanation { + margin: 14px 0 0; +} +.edition-packages { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: end; +} +.hero-package { + display: block; + width: min(100%, 170px); + margin: 0 auto; + text-decoration: none; +} +.hero-package > img { + display: block; + width: 100%; + height: auto; +} +.hero-package > .edition-download { + display: flex; + justify-content: center; + padding: 6px 8px; + margin-top: 4px; + font-size: 11px; + background: var(--yellow); +} +.nightly-package > .edition-download { + background: #c9bdff; +} +h1 { + margin: 0; + font: + 900 clamp(40px, 4.7vw, 62px)/0.99 Arial, + Helvetica, + sans-serif; + letter-spacing: -3.3px; +} +h1 > span { + color: var(--yellow); +} +.hero-explanation { + max-width: 360px; + margin: 0 0 23px; + font-size: 12px; + line-height: 1.7; + color: #c0c0c0; +} +.primary-cta { + padding: 13px 17px; + gap: 12px; + background: var(--yellow); + border-color: #ffffd1 #737c00 #737c00 #ffffd1; + box-shadow: + inset -1px -1px #a3b000, + inset 1px 1px #ffffbd; + font: + 900 13px Arial, + sans-serif; + letter-spacing: 0.3px; +} +.primary-cta > span:last-child { + font-size: 22px; + margin-left: 8px; +} +.primary-cta:hover { + background: #f2ff73; +} +.platform-line { + display: flex; + margin-top: 16px; + flex-wrap: wrap; + align-items: center; + gap: 11px; + font-size: 10px; +} +.platform-line > span { + font: + 8px "Courier New", + monospace; + color: #ababab; + letter-spacing: 0.5px; +} +.platform-line > strong { + font-weight: 400; +} +.platform-line > b { + color: #656565; +} +.box-95 { + position: absolute; + right: 9px; + bottom: -6px; + font: + italic 900 75px Arial, + sans-serif; + color: var(--yellow); + letter-spacing: -6px; +} +.agents-section { + padding: 14px 35px 24px; + border-top: 1px solid #363636; + border-bottom: 1px solid #363636; +} +.agent-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px 20px; + margin: 10px 0 24px; +} +.agent { + min-width: 0; + margin: 0; + text-align: center; +} +.agent-box { + display: block; + width: 100%; + max-width: 200px; + height: auto; + margin: 0 auto; + object-fit: contain; +} +.agents-section > .section-heading { + margin-bottom: 0; +} +.agent > figcaption { + padding: 12px 4px 0; + border-top: 3px ridge #777; +} +.agent h3 { + margin: 0 0 6px; + font-size: 14px; +} +.agent p { + max-width: 27ch; + margin: 0 auto; + color: var(--yellow); + font: + 11px/1.5 "Courier New", + monospace; +} +.agents-note { + margin: 0; + color: #aaa; + font: + 9px/1.5 "Courier New", + monospace; +} +.features-section { + padding: 33px 35px 38px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; + margin-bottom: 21px; +} +.section-heading h2 { + font: + 900 20px/1.2 Arial, + sans-serif; + letter-spacing: -0.6px; + margin: 0; +} +.section-heading h2 > span { + color: var(--yellow); +} +.section-heading > span { + font: + 8px/1.5 "Courier New", + monospace; + color: #aaa; + text-align: right; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; +} +.feature-window { + min-width: 0; + padding: 3px; + background: var(--silver); +} +.feature-titlebar { + display: flex; + justify-content: space-between; + gap: 5px; + padding: 5px 6px; + background: #393939; + font: + 9px "Courier New", + monospace; +} +.feature-body { + background: #0b0b0b; + padding: 18px 15px 17px; + display: flex; + flex-direction: column; + align-items: flex-start; + height: calc(100% - 21px); +} +.feature-body h3 { + font: + 700 18px/1.15 Arial, + sans-serif; + margin: 16px 0 12px; + letter-spacing: -0.3px; +} +.feature-body p { + margin: 0 0 22px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; +} +.feature-tag { + display: block; + margin-top: auto; + color: var(--yellow); + font: + 700 8px/1.6 "Courier New", + monospace; + letter-spacing: 0.3px; + text-decoration: none; +} +a.feature-tag { + text-decoration: underline; + text-underline-offset: 3px; +} +.order-section { + position: relative; + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 45px; + padding: 32px 35px; + border-top: 1px solid #546124; + border-bottom: 1px solid #546124; + background: #121707; + align-items: center; +} +.order-pitch h2 { + font: + 900 30px/1.1 Arial, + sans-serif; + letter-spacing: -1px; + margin: 15px 0 12px; +} +.order-pitch h2 > span { + color: var(--yellow); +} +.order-pitch > p { + font-size: 11px; + line-height: 1.6; + margin: 0 0 23px; +} +.order-pitch .offer-note { + color: #b9bea9; + font: + 9px/1.7 "Courier New", + monospace; + margin: 13px 0 0; +} +.run-window { + padding: 3px; + background: var(--silver); + color: #000; +} +.run-window .window-title { + min-height: 23px; + font-size: 11px; +} +.run-body { + padding: 14px 13px; + font-size: 11px; +} +.run-body > p:first-child { + margin: 0 0 14px; + font-weight: 700; +} +.run-body label { + font-size: 10px; +} +.command-row { + display: flex; + gap: 7px; + margin-top: 7px; +} +.command-row input { + width: 0; + min-width: 0; + flex: 1; + border-radius: 0; + padding: 7px 8px; + background: #fff; + color: #000; + font: + 700 15px "Courier New", + monospace; +} +.command-row button { + padding: 5px 12px; +} +.command-feedback { + min-height: 27px; + margin: 8px 0 10px; + font: + 9px/1.5 "Courier New", + monospace; +} +.run-divider { + border-top: 1px solid #808080; + border-bottom: 1px solid #fff; + margin: 0 0 13px; +} +.run-body > b { + font-size: 10px; +} +.run-body ul { + padding-left: 17px; + margin: 7px 0 10px; + font-size: 10px; + line-height: 1.8; +} +.requirements-note { + font: + 8px "Courier New", + monospace; + margin-bottom: 0; +} +.faq-section { + padding: 34px 35px; +} +.faq-section > h2 { + margin: 0 0 20px; + color: var(--pink); + font: + 700 11px "Courier New", + monospace; +} +.faq-section > details { + border-top: 1px dotted #626262; +} +.faq-section > details:last-child { + border-bottom: 1px dotted #626262; +} +.faq-section summary { + padding: 13px 2px; + font-size: 12px; +} +.faq-section summary::marker { + color: var(--yellow); +} +.faq-section details p { + margin: 0; + padding: 0 20px 16px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; + max-width: 740px; +} +.faq-section a { + color: var(--yellow); + text-underline-offset: 3px; +} +.site-footer { + margin: 0 35px; + padding: 25px 0 27px; + text-align: center; + border-top: 1px solid #363636; +} +.visitor-counter { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + font: + 9px "Courier New", + monospace; + color: #c0c0c0; +} +.counter-digits { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 2px inset #666; + background: #141414; +} +.counter-digits > span { + display: block; + padding: 2px 4px; + background: #252b1a; + color: var(--yellow); + font: + 700 15px "Courier New", + monospace; +} +.site-footer > p { + color: #b0b0b0; + font: + 9px/1.6 "Courier New", + monospace; + margin: 0 0 10px; +} +.site-footer nav { + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: center; + font: + 9px "Courier New", + monospace; +} +.site-footer nav a { + color: var(--pink); + text-underline-offset: 3px; +} +.browser-status { + display: flex; + align-items: stretch; + gap: 4px; + height: 24px; + padding-top: 4px; + color: #000; + font-size: 10px; +} +.browser-status > span { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; +} +.browser-status > span:first-child { + flex: 1; +} +.browser-status > span:nth-child(2) { + min-width: 120px; +} +.resize-grip { + width: 13px; + background: repeating-linear-gradient(135deg, transparent 0 2px, #808080 2px 3px, #fff 3px 4px); + clip-path: polygon(100% 0, 100% 100%, 0 100%); +} +.taskbar { + position: fixed; + z-index: 10; + left: 0; + right: 0; + bottom: 0; + min-height: 40px; + padding: 3px 5px; + background: var(--silver); + color: #000; + display: flex; + align-items: center; + gap: 8px; +} +.start-menu { + position: relative; +} +.start-button { + gap: 7px; + padding: 4px 8px; + min-height: 30px; + font-size: 14px; + list-style: none; +} +.start-button::-webkit-details-marker { + display: none; +} +.start-mark { + display: grid; + grid-template-columns: 8px 8px; + gap: 2px; + transform: skewY(-8deg); +} +.start-mark i { + width: 8px; + height: 8px; + background: #f3433c; +} +.start-mark i:nth-child(2) { + background: #75b53c; +} +.start-mark i:nth-child(3) { + background: #347be3; +} +.start-mark i:nth-child(4) { + background: #ffe348; +} +.start-panel { + position: absolute; + bottom: calc(100% + 5px); + left: -1px; + display: flex; + width: 253px; + padding: 3px; + background: var(--silver); +} +.start-brand { + writing-mode: vertical-rl; + transform: rotate(180deg); + background: #808080; + color: #dedede; + padding: 12px 8px; + font: + 900 18px Arial, + sans-serif; + white-space: nowrap; +} +.start-brand b { + color: #fff; +} +.start-panel > div:last-child { + flex: 1; +} +.start-panel a, +.start-panel button { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 10px; + width: 100%; + background: none; + border: 0; + color: #000; + text-decoration: none; + font-size: 11px; + text-align: left; +} +.start-panel a:hover, +.start-panel button:hover { + background: var(--navy); + color: #fff; +} +.taskbar-divider { + align-self: stretch; + border-left: 1px solid #808080; + border-right: 1px solid #fff; +} +.task-button { + display: flex; + align-items: center; + gap: 8px; + background: #d7d7d7; + color: #000; + padding: 3px 8px; + min-height: 29px; + min-width: 170px; + font-size: 11px; + font-weight: 700; + text-align: left; +} +.taskbar-clock { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 4px 10px; + min-height: 29px; + font-size: 11px; + white-space: nowrap; +} +.taskbar-clock > span:first-child { + font-size: 16px; +} +.maximized .desktop { + max-width: none; + padding: 0; +} +.maximized .browser-window { + transform: none; +} +.maximized #window-title { + cursor: default; +} +.minimized-message { + height: 100%; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + text-align: center; +} +.minimized-message h1 { + font: + 700 25px Arial, + sans-serif; + letter-spacing: -0.5px; +} +.minimized-message p { + font: + 12px "Courier New", + monospace; + margin: 0 0 10px; +} +.retro-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 3px; + background: var(--silver); + color: #000; +} +.retro-dialog::backdrop { + background: #000b; +} +.dialog-content { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 20px 13px; +} +.dialog-content > p { + font-size: 12px; + line-height: 1.7; + margin: 0; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 16px 17px; +} + +@media (min-width: 1450px) { + .maximized .hero { + grid-template-columns: 1fr 1fr; + padding-left: 65px; + padding-right: 65px; + } + .maximized h1 { + font-size: 76px; + } +} + +@media (max-width: 1100px) { + .desktop { + padding-left: 98px; + padding-right: 15px; + } + .hero { + padding-left: 25px; + padding-right: 25px; + } + h1 { + font-size: 47px; + letter-spacing: -2.6px; + } + .order-section { + gap: 23px; + } + .order-pitch h2 { + font-size: 27px; + } + .feature-grid { + gap: 10px; + } + .feature-body { + padding: 15px 11px; + } +} + +@media (max-width: 820px) { + .desktop { + padding: 14px 12px; + } + .desktop-icons { + display: none; + } + .hero { + grid-template-columns: 1.1fr 0.9fr; + gap: 0; + padding-top: 29px; + } + h1 { + font-size: 44px; + } + .hero-explanation { + max-width: 295px; + } + .primary-cta { + font-size: 11px; + gap: 7px; + padding: 12px; + } + .primary-cta > span:last-child { + margin-left: 3px; + } + .section-heading { + display: block; + } + .section-heading > span { + display: block; + text-align: left; + margin-top: 8px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 25px; + padding-right: 25px; + } + .site-footer { + margin-left: 25px; + margin-right: 25px; + } + .feature-body h3 { + font-size: 16px; + } + .taskbar-clock { + margin-left: auto; + } +} + +@media (max-width: 620px) { + .browser-menu { + flex-wrap: wrap; + } + .hero-package { + width: min(100%, 160px); + } + .edition-packages { + width: 100%; + margin-top: 12px; + } + .desktop { + padding: 9px 7px; + } + .window-name { + font-size: 10px; + } + .window-controls { + gap: 2px; + } + .browser-menu { + gap: 0; + } + .browser-menu > a, + .browser-menu > button { + padding: 6px 7px; + font-size: 10px; + } + .address-bar { + padding: 3px 4px 7px; + gap: 6px; + font-size: 10px; + } + .address-go { + font-size: 10px; + } + .hero { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 29px 20px 15px; + } + h1 { + font-size: clamp(41px, 10.5vw, 63px); + letter-spacing: -2.5px; + } + .hero-explanation { + max-width: 420px; + font-size: 11px; + } + .primary-cta { + font-size: 12px; + padding: 12px 15px; + gap: 10px; + } + .platform-line { + font-size: 9px; + gap: 9px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding: 25px 20px; + } + .agent-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 12px; + margin-top: 20px; + } + .agent h3 { + font-size: 13px; + } + .agent p { + font-size: 10px; + } + .agents-note { + font-size: 8px; + } + .section-heading h2 { + font-size: 20px; + } + .section-heading h2 > span { + display: block; + } + .feature-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .feature-body { + padding: 17px; + height: auto; + } + .feature-body h3 { + font-size: 21px; + margin-top: 13px; + } + .feature-body p { + font-size: 12px; + margin-bottom: 18px; + } + .feature-tag { + font-size: 9px; + } + .feature-titlebar { + font-size: 10px; + } + .order-section { + grid-template-columns: 1fr; + gap: 25px; + } + .order-pitch h2 { + font-size: 29px; + } + .order-pitch > p { + font-size: 11px; + } + .run-body { + padding: 16px; + } + .command-feedback { + min-height: 15px; + } + .faq-section h2 { + font-size: 10px; + line-height: 1.5; + } + .faq-section summary { + font-size: 11px; + line-height: 1.5; + } + .site-footer { + margin-left: 20px; + margin-right: 20px; + } + .visitor-counter { + font-size: 8px; + } + .site-footer > p { + font-size: 8px; + } + .site-footer nav { + font-size: 8px; + gap: 15px; + } + .browser-status { + font-size: 8px; + height: 26px; + } + .browser-status > span:nth-child(2) { + min-width: 67px; + } + .browser-status > span:first-child { + white-space: nowrap; + overflow: hidden; + } + .resize-grip { + display: none !important; + } + .taskbar { + gap: 6px; + } + .task-button { + min-width: 0; + flex: 1; + max-width: 170px; + } + .taskbar-clock { + padding: 4px 7px; + gap: 5px; + font-size: 10px; + } + .dialog-content { + padding: 19px 13px 10px; + gap: 12px; + } +} + +@media (max-width: 620px) { + .hero { + padding-top: 14px; + padding-bottom: 14px; + } + .hero h1 { + font-size: 30px; + } + .hero-package { + max-width: 120px; + } + .hero .platform-line { + display: none; + } + .agents-section { + padding-top: 14px; + } + .agents-section .agent-list { + margin-top: 8px; + } +} + +@media (min-width: 821px) and (max-height: 820px) { + .hero { + padding-top: 10px; + padding-bottom: 12px; + } + .hero h1 { + font-size: 40px; + } + .hero-package { + max-width: 140px; + } + .agent-box { + max-width: 180px; + } + .agents-section { + padding-top: 8px; + } +} + +@media (max-width: 360px) { + .hero-package { + max-width: 110px; + } + .hero, + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 14px; + padding-right: 14px; + } + .primary-cta { + font-size: 10px; + } + .taskbar-clock > span:first-child { + display: none; + } +} diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 427d370acb57..1953880fa946 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -28,6 +28,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "#fffbeb", + "--color-warning-border": "#fde68a", + "--color-warning-foreground": "#b45309", "--color-danger": "#fef2f2", "--color-danger-border": "rgba(239, 68, 68, 0.12)", "--color-danger-foreground": "#dc2626", @@ -95,6 +98,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "rgba(69, 26, 3, 0.4)", + "--color-warning-border": "rgba(120, 53, 15, 0.6)", + "--color-warning-foreground": "#fcd34d", "--color-danger": "rgba(239, 68, 68, 0.14)", "--color-danger-border": "rgba(248, 113, 113, 0.18)", "--color-danger-foreground": "#fca5a5", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 8ba542165f18..e9352e1b38b9 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -150,6 +150,9 @@ --color-switch-active-thumb: #ffffff; --color-switch-inactive-track: #f1c4e6; --color-switch-inactive-thumb: #8d1255; + --color-warning: #fcf0ea; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #b05109; --color-danger: #fde4f1; --color-danger-border: rgba(247, 8, 108, 0.32); --color-danger-foreground: #9d174d; @@ -275,6 +278,9 @@ --color-switch-active-thumb: #fbd0e8; --color-switch-inactive-track: #362d3d; --color-switch-inactive-thumb: #e7d0dd; + --color-warning: #412f20; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #fbbf24; --color-danger: #331a2b; --color-danger-border: rgba(157, 23, 77, 0.32); --color-danger-foreground: #fbd0e8; @@ -400,6 +406,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e2ede7; --color-switch-inactive-thumb: #6e696f; + --color-warning: #f4f0e1; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b64a00; --color-danger: #f4e7e5; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -525,6 +534,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #2a4b39; --color-switch-inactive-thumb: #9da5a2; + --color-warning: #3f3a1c; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3f2c28; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6668; @@ -650,6 +662,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e4ecf2; --color-switch-inactive-thumb: #6f6873; + --color-warning: #f6efe4; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b74b00; --color-danger: #f5e6e9; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -775,6 +790,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #293f52; --color-switch-inactive-thumb: #969ca6; + --color-warning: #3c3424; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3c2630; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -900,6 +918,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #f3eae5; --color-switch-inactive-thumb: #74686f; + --color-warning: #f9efe2; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f9e7e6; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1025,6 +1046,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #513728; --color-switch-inactive-thumb: #a59996; + --color-warning: #4b3215; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #4a2321; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -1150,6 +1174,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #edeaf4; --color-switch-inactive-thumb: #726874; + --color-warning: #f8efe5; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f8e6ea; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1275,6 +1302,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #362d51; --color-switch-inactive-thumb: #9690a1; + --color-warning: #412e23; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #40202e; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e6961eac4eea..e153107b1811 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -52,6 +52,11 @@ --color-switch-inactive-track: rgba(0, 0, 0, 0.08); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: #fffbeb; + --color-warning-border: #fde68a; + --color-warning-foreground: #b45309; + /* Danger */ --color-danger: #fef2f2; --color-danger-border: rgba(239, 68, 68, 0.12); @@ -151,6 +156,11 @@ --color-switch-inactive-track: rgba(255, 255, 255, 0.06); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: rgba(69, 26, 3, 0.4); + --color-warning-border: rgba(120, 53, 15, 0.6); + --color-warning-foreground: #fcd34d; + /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); --color-danger-border: rgba(248, 113, 113, 0.18); diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png new file mode 100644 index 000000000000..87eab9f5218f Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png differ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 25f1e94c110f..d42be2e174db 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments( NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; UIImage *image = images[imageUri]; - if ([imageUri hasPrefix:@"sf:"]) { - NSString *symbolName = [imageUri substringFromIndex:3]; - UIColor *foregroundColor = - [attributedString attribute:NSForegroundColorAttributeName - atIndex:attachmentRange.location - effectiveRange:nil] ?: UIColor.labelColor; - image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; + const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; + if (isSymbol) { + image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; + } + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil]; + if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { + image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); @@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments( const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - NSAttributedString *attachmentString = - [NSAttributedString attributedStringWithAttachment:attachment]; + NSMutableAttributedString *attachmentString = + [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + // Keep the run color on the attachment so a later re-apply (after the image + // loads asynchronously) still tints with the link color, not labelColor. + if (foregroundColor != nil) { + [attachmentString addAttribute:NSForegroundColorAttributeName + value:foregroundColor + range:NSMakeRange(0, attachmentString.length)]; + } [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; } } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63b..e6ce2b3226f0 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. + bool tintWithForeground; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb94..60bbcf2e4f84 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -11,6 +11,7 @@ static constexpr Float ParagraphStyleEncodingOffset = 1000; static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; +static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -192,6 +193,7 @@ static void applyAttachments( utf16Offset, 1, props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), + false, }); } else if ( props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { @@ -200,6 +202,15 @@ static void applyAttachments( 1, props.nativeId.substr( std::char_traits::length(SkillAttachmentNativeIdPrefix)), + false, + }); + } else if ( + props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), + true, }); } utf16Offset += fragmentLength; diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 1e52d7695ec6..8922c8868c44 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -21,6 +21,7 @@ "exports": { ".": "./index.ts", "./file-icons": "./src/markdownFileIcons.ts", + "./link-icons": "./src/markdownLinkIcons.ts", "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 590a2fb1bd1b..a5c6cf540f1c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -12,6 +12,8 @@ import { import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; +import { markdownLinkIconSource } from "./markdownLinkIcons"; +import { resolveMarkdownLinkIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { MarkdownFileContextMenu, @@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); - const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const containsInlineIcon = props.runs.some( + (run) => + run.fileIcon != null || + (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), + ); const attachAndroidText = useCallback( (textView: RNText | null) => { - if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + if (Platform.OS !== "android" || !containsInlineIcon || textView === null) { return; } const reactTag = findNodeHandle(textView); @@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: { installMarkdownCopySanitizer(reactTag); } }, - [containsInlineFileIcon], + [containsInlineIcon], ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); @@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; + let linkIcon = null; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { @@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: { : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); - text = `${EXTERNAL_LINK_PREFIX}${text}`; + linkIcon = resolveMarkdownLinkIcon(run.externalHost); + if (linkIcon === null) { + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } else if (Platform.OS === "ios") { + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; + } } - return { key: `${signature}:${occurrence}`, run, text }; + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. @@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: { lineHeight: props.textStyle.lineHeight, }} > - {keyedRuns.map(({ key, run, text }) => { + {keyedRuns.map(({ key, run, text, linkIcon }) => { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( @@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: { ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName ? "t3-skill:sf:cube" - : undefined + : linkIcon + ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` + : undefined : undefined } contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} @@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: { > {Platform.OS === "android" && run.fileIcon ? ( + ) : Platform.OS === "android" && linkIcon ? ( + ) : null} {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts new file mode 100644 index 000000000000..568a51005798 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts @@ -0,0 +1,12 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownLinkIcon } from "./markdownLinks"; + +// Black-on-transparent marks; callers tint them with the link color. +const MARKDOWN_LINK_ICON_SOURCES = { + github: require("../assets/link-icons/github.png"), +} as const satisfies Readonly>; + +export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { + return MARKDOWN_LINK_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 176585344167..19f71f631663 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -33,6 +33,18 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; +export type MarkdownLinkIcon = "github"; + +/** + * Sites whose brand mark replaces the generic external-link glyph. The marks + * are monochrome and tinted with the link color, so they follow the theme. + */ +export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; + return null; +} + const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b6f445d7eab0..bcf6cf6316bd 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -53,6 +53,7 @@ "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@pierre/diffs": "catalog:", + "@q1code/core": "workspace:*", "@react-native-ai/apple": "0.12.0", "@react-native-menu/menu": "^2.0.0", "@react-navigation/elements": "2.9.26", diff --git a/apps/mobile/scripts/wire-widget-asset-catalog.cjs b/apps/mobile/scripts/wire-widget-asset-catalog.cjs deleted file mode 100644 index b7c70f0cdcd6..000000000000 --- a/apps/mobile/scripts/wire-widget-asset-catalog.cjs +++ /dev/null @@ -1,30 +0,0 @@ -"use strict"; - -// One-off: apply the widget asset-catalog wiring to the already-generated -// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets -// without a full `expo prebuild`. The durable equivalent lives in -// plugins/withWidgetLogoAsset.cjs and runs on prebuild. - -const path = require("path"); -const fs = require("fs"); - -const xcodePath = require.resolve("xcode", { - paths: [ - require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }), - ], -}); -const xcode = require(xcodePath); -const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs"); - -const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj"); -const proj = xcode.project(pbxprojPath); -proj.parseSync(); - -const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" }); - -if (added) { - fs.writeFileSync(pbxprojPath, proj.writeSync()); - console.log("Added widget asset-compile phase to ExpoWidgetsTarget."); -} else { - console.log("No change: phase already present."); -} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 57303a1bb001..e7ad81505155 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,8 +56,10 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; +import { prismSettingsStackScreen } from "./fork/prism/PrismSettingsScreen"; // fork: prism import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -192,6 +194,10 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsageAccount: createNativeStackScreen({ + screen: UsageLimitAccountScreen, + options: { title: "Account" }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", @@ -199,6 +205,7 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Usage", }, }), + SettingsPrism: prismSettingsStackScreen, // fork: prism }, }); diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 26fdfd24a4fb..0912693861de 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; +import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; @@ -30,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; +import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; @@ -109,11 +111,13 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bell.badge": IconBellRinging, "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, + brain: IconBrain, camera: IconCamera, "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + ticket: IconTicket, cloud: IconCloud, cube: IconBox, "chevron.down": IconChevronDown, @@ -136,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "info.circle": IconInfoCircle, laptopcomputer: IconDeviceLaptop, link: IconLink, + "line.3.horizontal.decrease": IconFilter, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, // Tabler has no Apple desktops; the closest silhouettes stand in on Android. diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 39517f0e62ee..6501d2044083 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -35,6 +35,8 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-foreground-secondary" + cursorColorClassName="accent-foreground-secondary" {...props} /> ); diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 40465012e5da..8c86fcc38e69 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -195,7 +195,7 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { {!props.compact ? ( diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index b7412bd13a83..e0936c57180a 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -9,7 +9,14 @@ import { useMemo, useRef, } from "react"; -import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native"; +import { + Platform, + Pressable, + View, + type ColorValue, + type PressableProps, + type AccessibilityProps, +} from "react-native"; import { withUniwind } from "uniwind"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -144,10 +151,11 @@ export function ControlPill(props: { // AppCompat popup can't be themed past its stock animation, metrics, and // submenu chrome. export function ControlPillMenu( - props: Omit, "children" | "themeVariant"> & { - readonly children: ReactNode; - readonly className?: string; - }, + props: Omit, "children" | "themeVariant"> & + Pick & { + readonly children: ReactNode; + readonly className?: string; + }, ) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 46fbbd814fdf..4471fdea4fd2 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -6,6 +6,7 @@ import { SymbolView } from "./AppSymbol"; const SYMBOL_BY_KIND: Record = { server: "server.rack", cloud: "cloud", + linux: "terminal", desktop: "desktopcomputer", laptop: "laptopcomputer", "mac-mini": "macmini", @@ -15,6 +16,7 @@ const SYMBOL_BY_KIND: Record = { export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { server: "Server", cloud: "Cloud VM", + linux: "Linux/WSL", desktop: "Desktop", laptop: "Laptop", "mac-mini": "Mac mini", diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 6c12c9bdd823..38f85de195b5 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,8 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - {props.message} + + {props.message} ); } diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,9 +5,13 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +20,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => Effect.sync(() => { values.set(cacheId(environmentId, kind, cacheKey), payload); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index ad5ef13b62d5..ccf4945b3bef 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; +import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -115,6 +116,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.andThen(database.clearEnvironmentCache(environmentId)), + Effect.mapError(mapDatabaseError("clear-environment")), + ), ), }); }); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index deee27ef040d..ce478962b8b5 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..152948274ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -33,7 +33,6 @@ import { mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, - normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -363,13 +362,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("normalizes relay base URLs for APNs registration requests", () => { - expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); - }); - it("overrides persisted preferences for an in-flight registration", () => { expect( mergeAgentAwarenessRegistrationPreferences( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..9f4539c44d64 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -139,16 +139,6 @@ export function mergeAgentAwarenessRegistrationPreferences( return { ...stored, ...override }; } -export function normalizeAgentAwarenessRelayBaseUrl( - value: string | null | undefined, -): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 42aa8ffebb61..feadf6c81893 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,13 +11,11 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, - normalizeRelayBaseUrl, refreshCloudEnvironmentConnection, } from "./linkEnvironment"; @@ -195,23 +193,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("normalizes configured relay base URLs before building DPoP-bound requests", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - }); - - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index c2033117f69d..b8dc8f878e0c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -43,14 +43,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [ RelayEnvironmentConnectScope, ] satisfies ReadonlyArray; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } @@ -405,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 448889549016..806499c2273b 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -299,7 +299,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-adaptive-rose-500-400" + ? "text-danger-foreground" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 75d3e8ce7a34..5555548ff799 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -96,7 +96,7 @@ export function ConnectionEnvironmentRow(props: { ({ calls: [] as string[], @@ -257,13 +253,13 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); }); - it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + it("keeps only the long line plain and resumes highlighting after it", async () => { const longLine = `${"x".repeat(1_001)} /*`; const rows = [ line(1, "export const before = 1;"), line(2, longLine), { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, - line(3, "inside the comment */"), + line(3, "export const inside = 'x';"), makeHunk("next-hunk"), line(100, "export const after = 2;"), ] satisfies ReadonlyArray; @@ -274,14 +270,36 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-2"]).toEqual([ { content: longLine, color: null, fontStyle: null }, ]); - expect(result.tokensByRowId["line-3"]).toEqual([ - { content: "inside the comment */", color: null, fontStyle: null }, - ]); - expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); - expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + for (const id of ["line-1", "line-3", "line-100"]) { + expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + } expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); }); + it("highlights the rows after a long line the same regardless of the first window", async () => { + const rows = [ + line(1, "export const before = 1;"), + line(2, `const data = "${"x".repeat(1_050)}";`), + line(3, "export const inside = 'x';"), + line(4, "export const after = 2;"), + ]; + const spanning = await highlightRows(rows); + const afterLongLine = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 2, + lastRowIndex: 3, + overscanRows: 0, + }); + + for (const id of ["line-3", "line-4"]) { + expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]); + } + }); + it("preserves multiline grammar and row mapping across character-limited batches", async () => { const opening = line(1, "const message = `open"); const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); @@ -318,21 +336,4 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.rowCount).toBe(0); expect(result.tokensByRowId).toEqual({}); }); - - it("applies the same long-line guard to streamed token chunks", async () => { - const content = "x".repeat(10_000); - const chunks: NativeReviewDiffTokenChunk[] = []; - - await streamNativeReviewDiffTokens({ - rows: [line(1, content)], - files: [TYPESCRIPT_FILE], - scheme: "dark", - engine, - onChunk: (chunk) => chunks.push(chunk), - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); - expect(tokenization.calls).toHaveLength(0); - }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 383e1e73a85f..e924ff1aa645 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -65,26 +65,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -98,7 +78,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; @@ -247,15 +226,16 @@ function createHighlighterHandle( while (start < lines.length) { if (signal?.aborted) return []; - // Skipping this line leaves its ending grammar state unknown. Keep the - // rest of this contiguous segment plain instead of guessing its syntax. + // Skipping this line leaves its ending grammar state unknown. Resume + // from a fresh state rather than leaving the rest of the segment plain: + // highlighted rows are cached for the sheet's lifetime, so a plain tail + // would stick, and which rows it covered would depend on where the + // first visible window happened to start. if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { - highlighted.push( - ...lines - .slice(start) - .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), - ); - break; + highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); + grammarState = undefined; + start += 1; + continue; } let end = start; @@ -422,20 +402,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -560,52 +526,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = await highlighter.tokenize(code, { - lang: file.language, - theme, - signal: input.signal, - }); - if (input.signal?.aborted) return highlighter.engine; - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index ca8fd9c61415..29bbdb49c890 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -190,11 +190,11 @@ function FileContent(props: { return ( {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts index 7345a7f366c5..edab2ba687b8 100644 --- a/apps/mobile/src/features/files/fileTree.test.ts +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ProjectEntry } from "@t3tools/contracts"; -import { - buildFileTree, - countFileNodes, - defaultExpandedTreePaths, - firstFilePath, - flattenFileTree, -} from "./fileTree"; +import { buildFileTree, defaultExpandedTreePaths, flattenFileTree } from "./fileTree"; const entries = [ { kind: "file", path: "README.md" }, @@ -30,8 +24,6 @@ describe("mobile file tree helpers", () => { "directory:src/components", "file:src/index.ts", ]); - expect(countFileNodes(tree)).toBe(4); - expect(firstFilePath(tree)).toBe("src/components/App.tsx"); }); it("flattens expanded directories and hides collapsed descendants", () => { diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 28b5822aaa0f..2e0b8140329c 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -117,18 +117,6 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr return [...root.children.values()].sort(compareNodes).map(freezeNode); } -export function countFileNodes(nodes: ReadonlyArray): number { - let count = 0; - for (const node of nodes) { - if (node.kind === "file") { - count += 1; - } else { - count += countFileNodes(node.children); - } - } - return count; -} - export function defaultExpandedTreePaths(nodes: ReadonlyArray): ReadonlySet { const expanded = new Set(); for (const node of nodes) { @@ -205,16 +193,3 @@ export function flattenFileTree(input: { } return output; } - -export function firstFilePath(nodes: ReadonlyArray): string | null { - for (const node of nodes) { - if (node.kind === "file") { - return node.path; - } - const child = firstFilePath(node.children); - if (child !== null) { - return child; - } - } - return null; -} diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts index 0e7d478c6bdb..937d3a1d3c8f 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.test.ts @@ -3,30 +3,10 @@ import { describe, expect, it } from "vite-plus/test"; import { buildNativeSourceRows, buildNativeSourceTokens, - NATIVE_SOURCE_ROW_HEIGHT, - NATIVE_SOURCE_STYLE, nativeSourceRowId, } from "./nativeSourceFileAdapter"; -import { - NATIVE_REVIEW_DIFF_ROW_HEIGHT, - NATIVE_REVIEW_DIFF_STYLE, -} from "../review/nativeReviewDiffAdapter"; describe("nativeSourceFileAdapter", () => { - it("uses the same compact code typography as the diff viewer", () => { - expect(NATIVE_SOURCE_ROW_HEIGHT).toBe(NATIVE_REVIEW_DIFF_ROW_HEIGHT); - expect(NATIVE_SOURCE_STYLE).toMatchObject({ - rowHeight: NATIVE_REVIEW_DIFF_STYLE.rowHeight, - gutterWidth: NATIVE_REVIEW_DIFF_STYLE.gutterWidth, - codePadding: NATIVE_REVIEW_DIFF_STYLE.codePadding, - textVerticalInset: NATIVE_REVIEW_DIFF_STYLE.textVerticalInset, - codeFontSize: NATIVE_REVIEW_DIFF_STYLE.codeFontSize, - codeFontWeight: NATIVE_REVIEW_DIFF_STYLE.codeFontWeight, - lineNumberFontSize: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontSize, - lineNumberFontWeight: NATIVE_REVIEW_DIFF_STYLE.lineNumberFontWeight, - }); - }); - it("maps plain source lines onto context rows with stable line numbers", () => { expect(buildNativeSourceRows(["const value = 1;", "\treturn value;"])).toEqual([ { diff --git a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts index 0c83134ea703..f1dbefdc383b 100644 --- a/apps/mobile/src/features/files/nativeSourceFileAdapter.ts +++ b/apps/mobile/src/features/files/nativeSourceFileAdapter.ts @@ -4,17 +4,11 @@ import type { NativeReviewDiffToken, } from "../diffs/nativeReviewDiffSurface"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; import { MOBILE_CODE_SURFACE, MOBILE_TYPOGRAPHY } from "../../lib/typography"; import type { SourceHighlightTokens } from "./sourceHighlightingState"; -export const NATIVE_SOURCE_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_SOURCE_CONTENT_WIDTH = 32_000; -export const NATIVE_SOURCE_STYLE: NativeReviewDiffStyle = createNativeSourceStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - export function createNativeSourceStyle( codeSurface: ResolvedMobileCodeSurface, ): NativeReviewDiffStyle { diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 943303202216..a9833d2d619f 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,7 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -199,7 +199,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} + onMoveThread={moveThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4c41ce2bf150..d68630b67302 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "../threads/threadOrder"; import { LegendList, type LegendListRef, @@ -11,7 +12,6 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { type EnvironmentId, resolveEnvironmentMachineKind, @@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -51,6 +52,7 @@ import { } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -114,7 +116,7 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly onMovePinnedThread: ( + readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -368,7 +370,7 @@ export function HomeScreen(props: HomeScreenProps) { ? props.pendingTasks : props.pendingTasks.filter((pendingTask) => selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), ), ), [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], @@ -416,14 +418,6 @@ export function HomeScreen(props: HomeScreenProps) { [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); - const projectCwdByKey = useMemo(() => { - const map = new Map(); - for (const project of props.projects) { - map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); - } - return map; - }, [props.projects]); - const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { @@ -496,12 +490,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - const handleSettleThread = useCallback( - (thread: EnvironmentThreadShell) => { - void props.onSettleThread(thread); - }, - [props.onSettleThread], - ); + const handleSettleThread = props.onSettleThread; const handleSnoozeThread = useCallback( (thread: EnvironmentThreadShell, snoozedUntil: string) => { void props.onSnoozeThread(thread, snoozedUntil); @@ -520,11 +509,11 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); - const handleMovePinnedThread = useCallback( + const handleMoveThread = useCallback( (thread: EnvironmentThreadShell, direction: "up" | "down") => { - void props.onMovePinnedThread(thread, direction); + void props.onMoveThread(thread, direction); }, - [props.onMovePinnedThread], + [props.onMoveThread], ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -616,6 +605,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -635,20 +633,40 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); - // Canonical arranged pinned order (reorder-capable threads only) for the - // Move up/down position flags. Computed from all shells, not the rendered - // list, so search/scope filtering never disables or misdirects a move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - props.threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, props.threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: props.threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads: props.threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + props.threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -663,6 +681,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ + pendingOrder, threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, @@ -677,6 +696,7 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -715,10 +735,10 @@ export function HomeScreen(props: HomeScreenProps) { props.pendingTasks.filter( (pendingTask) => (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && + pendingTask.environmentId === props.selectedEnvironmentId) && (v2ScopedProjectKeys === null || v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), @@ -749,8 +769,8 @@ export function HomeScreen(props: HomeScreenProps) { (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( - item.pendingTask.message.environmentId, - item.pendingTask.creation.projectId, + item.pendingTask.environmentId, + item.pendingTask.projectId, ); return ( 1 - ? (props.savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null) + ? (props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? + null) : null } - environmentMachine={machineByEnvironmentId.get(item.pendingTask.message.environmentId)} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} showPendingDivider={item.showPendingDivider} showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} @@ -792,6 +812,8 @@ export function HomeScreen(props: HomeScreenProps) { ); } const thread = item.item.thread; + const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; return ( 0} - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) + } + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} - onMovePinnedThread={handleMovePinnedThread} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null - } + onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> @@ -860,8 +880,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [ handleDeleteThread, - arrangedPinnedKeys, - handleMovePinnedThread, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, + handleMoveThread, handlePinThread, handleRegenerateThreadTitle, handleSettleThread, @@ -875,7 +897,6 @@ export function HomeScreen(props: HomeScreenProps) { machineByEnvironmentId, pinReorderEnvironmentIds, projectByKey, - projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, props.onSelectPendingTask, @@ -903,7 +924,6 @@ export function HomeScreen(props: HomeScreenProps) { const v2ExtraData = useMemo( () => ({ projectByKey, - projectCwdByKey, projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, @@ -913,7 +933,6 @@ export function HomeScreen(props: HomeScreenProps) { }), [ projectByKey, - projectCwdByKey, props.searchQuery, props.savedConnectionsById, serverConfigs, @@ -925,12 +944,11 @@ export function HomeScreen(props: HomeScreenProps) { const extraData = useMemo( () => ({ - projectCwdByKey, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, }), - [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], + [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], ); const renderItem = useCallback( @@ -961,12 +979,9 @@ export function HomeScreen(props: HomeScreenProps) { variant="compact" pendingTask={item.pendingTask} environmentLabel={ - props.savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null + props.savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} isLast={item.isLast} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} @@ -982,10 +997,6 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? - null - } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -1021,7 +1032,6 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleRegenerateThreadTitle, machineByEnvironmentId, - projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, props.onDeleteThread, diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index 6709a81e9d1e..910ddb5896b7 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -173,7 +173,7 @@ export function buildHomeListLayout(input: { for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { items.push({ type: "pending-task", - key: `pending-task:${pendingTask.message.messageId}`, + key: pendingTask.key, pendingTask, isLast: pendingIndex === group.pendingTasks.length - 1 && diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 2a9e0ec2cb86..f0c9e1bc602d 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -105,10 +105,8 @@ export function sortHomeProjectScopes(input: { } for (const pendingTask of input.pendingTasks) { recordActivity( - scopeKeyByProjectRef.get( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - ), - Date.parse(pendingTask.message.createdAt), + scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)), + Date.parse(pendingTask.createdAt), ); } @@ -177,7 +175,7 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr Number.NEGATIVE_INFINITY, ); return group.pendingTasks.reduce((latest, pendingTask) => { - const timestamp = Date.parse(pendingTask.message.createdAt); + const timestamp = Date.parse(pendingTask.createdAt); return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); }, latestThread); } @@ -235,14 +233,11 @@ export function buildHomeThreadGroups(input: { } for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.message.environmentId !== input.environmentId) { + if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) { continue; } - const physicalKey = scopedProjectKey( - pendingTask.message.environmentId, - pendingTask.creation.projectId, - ); + const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId); let groupKey = groupKeyByProjectKey.get(physicalKey); if (!groupKey) { // The project shell is not loaded (environment offline / project gone). @@ -254,16 +249,15 @@ export function buildHomeThreadGroups(input: { key: groupKey, projects: [ { - environmentId: pendingTask.message.environmentId, - id: pendingTask.creation.projectId, - title: pendingTask.creation.projectTitle ?? "Unknown project", - workspaceRoot: - pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), + environmentId: pendingTask.environmentId, + id: pendingTask.projectId, + title: pendingTask.projectTitle ?? "Unknown project", + workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId), repositoryIdentity: null, defaultModelSelection: null, scripts: [], - createdAt: pendingTask.message.createdAt, - updatedAt: pendingTask.message.createdAt, + createdAt: pendingTask.createdAt, + updatedAt: pendingTask.createdAt, }, ], pendingTasks: [], diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 052ac969c10f..93085fec6c22 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -7,6 +7,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useRef, useState, type ComponentProps, @@ -19,17 +20,23 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Pressable, View } from "react-native"; +import { Alert, Pressable, View } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; import Animated, { + cancelAnimation, + Easing, Extrapolation, + ReduceMotion, interpolate, runOnJS, + runOnUI, type SharedValue, useAnimatedReaction, useAnimatedStyle, + useSharedValue, + withTiming, } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; @@ -61,8 +68,15 @@ interface ThreadSwipeAction { readonly onPress: () => void; } +/** Dismiss before committing; false restores the row, success changes its resetKey or removes it. */ +type ThreadSwipePrimaryAction = Omit & + ( + | { readonly dismissOnPress: true; readonly onPress: () => Promise } + | { readonly dismissOnPress?: false; readonly onPress: () => void } + ); + interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; } function swipeActionsWidth(hasSecondaryAction: boolean) { @@ -80,7 +94,7 @@ function resolveSecondaryAction(input: { if (input.secondaryAction === undefined) { return { accessibilityLabel: `Delete ${input.threadTitle}`, - backgroundColor: "#ff2d55", + tone: "danger", icon: "trash", label: "Delete", onPress: () => { @@ -92,7 +106,7 @@ function resolveSecondaryAction(input: { const action = input.secondaryAction; return { ...action, - backgroundColor: "#5856d6", + tone: "secondary", menu: action.menu === undefined ? undefined @@ -218,7 +232,7 @@ export function useSwipeableScrollGate(options?: { }; } -export function ThreadSwipeable(props: { +interface ThreadSwipeableProps { readonly backgroundColor: ColorValue; readonly children: (close: () => void) => ReactNode; /** Uses action visuals that fit inside compact 44pt rows. The press target @@ -238,7 +252,7 @@ export function ThreadSwipeable(props: { readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; readonly onSwipeableWillOpen?: (methods: SwipeableMethods) => void; - readonly primaryAction: ThreadSwipeAction; + readonly primaryAction: ThreadSwipePrimaryAction; /** * Omitted keeps the v1 destructive Delete action. Explicit null opts out of * a secondary action entirely so a gated Snooze can never fall back to an @@ -255,7 +269,15 @@ export function ThreadSwipeable(props: { typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; readonly threadTitle: string; -}) { +} + +export function ThreadSwipeable(props: ThreadSwipeableProps) { + // Recycled content gets fresh native and animation state. Late callbacks + // from the previous row retain its action, never the replacement's action. + return ; +} + +function ThreadSwipeableRow(props: ThreadSwipeableProps) { const swipeableRef = useRef(null); const fullSwipeArmedRef = useRef(false); const hasSecondaryAction = props.secondaryAction !== null; @@ -265,14 +287,119 @@ export function ThreadSwipeable(props: { props.fullSwipeAction ?? (props.secondaryAction === undefined ? "delete" : "primary"); const close = useCallback(() => swipeableRef.current?.close(), []); const gateEnabled = use(SwipeableScrollGateContext); - const resetKey = props.resetKey; - useEffect(() => { - if (resetKey === undefined) { - return; + const mountedRef = useRef(true); + const pendingDismissRef = useRef<(() => Promise) | null>(null); + const activeTranslationRef = useRef | null>(null); + const [isDismissing, setIsDismissing] = useState(false); + const dismissing = useSharedValue(false); + const rowHeight = useSharedValue(0); + const rowWidth = useSharedValue(props.fullSwipeWidth); + const collapse = useSharedValue(0); + const actionOpacity = useSharedValue(1); + const primaryAction = props.primaryAction; + const onSwipeableClose = props.onSwipeableClose; + + const restoreRow = useCallback(() => { + swipeableRef.current?.close(); + collapse.set(0); + actionOpacity.set(1); + dismissing.set(false); + setIsDismissing(false); + }, [actionOpacity, collapse, dismissing]); + + const finishDismiss = useCallback(async () => { + const action = pendingDismissRef.current; + if (!action) return; + pendingDismissRef.current = null; + try { + const succeeded = await action(); + if (!succeeded && mountedRef.current) restoreRow(); + } catch (error) { + if (mountedRef.current) restoreRow(); + Alert.alert( + "Could not settle thread", + error instanceof Error ? error.message : "The thread could not be settled.", + ); } - fullSwipeArmedRef.current = false; - swipeableRef.current?.reset(); - }, [resetKey]); + }, [restoreRow]); + + useLayoutEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancelAnimation(collapse); + cancelAnimation(actionOpacity); + if (activeTranslationRef.current) cancelAnimation(activeTranslationRef.current); + // Scrolling a committed row out of the recycled list must still settle it. + void finishDismiss(); + }; + }, [actionOpacity, collapse, finishDismiss]); + + const beginDismiss = useCallback( + (translation: SharedValue) => { + if (!primaryAction.dismissOnPress) return; + pendingDismissRef.current = primaryAction.onPress; + activeTranslationRef.current = translation; + fullSwipeArmedRef.current = false; + if (!mountedRef.current) { + void finishDismiss(); + return; + } + setIsDismissing(true); + if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); + }, + [finishDismiss, primaryAction, onSwipeableClose], + ); + + const dismiss = useCallback( + (translation: SharedValue) => { + "worklet"; + if (dismissing.value) return; + dismissing.set(true); + runOnJS(beginDismiss)(translation); + const timing = { + duration: 220, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, + }; + actionOpacity.set(withTiming(0, timing)); + // Never reverse a swipe that already carried the row beyond its width. + translation.set( + withTiming(Math.min(translation.value, -rowWidth.value), timing, (finished) => { + if (!finished) return; + collapse.set( + withTiming(1, { ...timing, duration: 180 }, (collapsed) => { + if (collapsed) runOnJS(finishDismiss)(); + }), + ); + }), + ); + }, + [actionOpacity, beginDismiss, collapse, dismissing, finishDismiss, rowWidth], + ); + const dismissStyle = useAnimatedStyle(() => ({ + height: dismissing.value ? rowHeight.value * (1 - collapse.value) : undefined, + pointerEvents: dismissing.value ? "none" : "auto", + overflow: "hidden", + })); + const actionStyle = useAnimatedStyle(() => ({ opacity: actionOpacity.value, height: "100%" })); + const dismissOnPress = primaryAction.dismissOnPress === true; + const handleRelease = useCallback( + (translation: SharedValue) => { + "worklet"; + if (dismissing.value) return true; + if ( + dismissOnPress && + fullSwipeAction === "primary" && + -translation.value >= fullSwipeThreshold + ) { + dismiss(translation); + return true; + } + return false; + }, + [dismiss, dismissing, dismissOnPress, fullSwipeAction, fullSwipeThreshold], + ); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { if (armed && !fullSwipeArmedRef.current) { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -281,85 +408,101 @@ export function ThreadSwipeable(props: { }, []); return ( - { - fullSwipeArmedRef.current = false; - if (swipeableRef.current) { - props.onSwipeableClose?.(swipeableRef.current); - } - }} - onSwipeableOpenStartDrag={() => { - if (swipeableRef.current) { - props.onSwipeableWillOpen?.(swipeableRef.current); - } - }} - onSwipeableWillOpen={() => { - const methods = swipeableRef.current; - if (!methods) { - return; - } + + { + rowHeight.set(layout.height); + rowWidth.set(layout.width); + }} + > + { + fullSwipeArmedRef.current = false; + if (swipeableRef.current) { + props.onSwipeableClose?.(swipeableRef.current); + } + }} + onSwipeableRelease={handleRelease} + onSwipeableOpenStartDrag={() => { + if (swipeableRef.current) { + props.onSwipeableWillOpen?.(swipeableRef.current); + } + }} + onSwipeableWillOpen={() => { + const methods = swipeableRef.current; + if (!methods) { + return; + } - props.onSwipeableWillOpen?.(methods); - if (fullSwipeArmedRef.current) { - fullSwipeArmedRef.current = false; - methods.close(); - if (fullSwipeAction === "primary") { - props.primaryAction.onPress(); - } else { - props.onDelete(); - } - } - }} - overshootFriction={1} - overshootRight - renderRightActions={(_progress, translation, methods) => ( - { + props.onSwipeableWillOpen?.(methods); + if (fullSwipeArmedRef.current && !(dismissOnPress && fullSwipeAction === "primary")) { + fullSwipeArmedRef.current = false; methods.close(); - props.primaryAction.onPress(); - }, + if (fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } + } }} - secondaryAction={resolveSecondaryAction({ - close: () => methods.close(), - onDelete: props.onDelete, - secondaryAction: props.secondaryAction, - threadTitle: props.threadTitle, - })} - translation={translation} - /> - )} - rightThreshold={actionsWidth * 0.42} - simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} - > - {props.children(close)} - + overshootFriction={1} + overshootRight + renderRightActions={(_progress, translation, methods) => ( + + { + if (primaryAction.dismissOnPress) { + runOnUI(dismiss)(translation); + } else { + methods.close(); + primaryAction.onPress(); + } + }, + }} + secondaryAction={resolveSecondaryAction({ + close: () => methods.close(), + onDelete: props.onDelete, + secondaryAction: props.secondaryAction, + threadTitle: props.threadTitle, + })} + translation={translation} + /> + + )} + rightThreshold={actionsWidth * 0.42} + simultaneousWithExternalGesture={props.simultaneousWithExternalGesture} + > + {props.children(close)} + + + ); } function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; @@ -462,9 +605,15 @@ function SwipeActionButton(props: { > - + { + if (pendingTask.kind === "draft") { + Alert.alert("Discard draft?", `“${pendingTask.title}” will be removed.`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => { + // Same reset a submit performs: the next task in this project + // re-resolves project defaults instead of inheriting the pick. + clearComposerDraftContent(pendingTask.draftKey, { + clearModelSelection: true, + clearWorkspaceSelection: true, + }); + }, + }, + ]); + return; + } Alert.alert( "Delete pending task?", `“${pendingTask.title}” has not been sent yet and will be removed from the outbox.`, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dae6c46a89dd..7b0b7b701106 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,15 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; -import { - pinOrderKeyBetween, - planPinnedMove, - sortPinnedThreadsByOrderKey, -} from "@t3tools/client-runtime/state/thread-sort"; +import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; +import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../threads/threadListV2"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -222,7 +221,7 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly movePinnedThread: ( + readonly moveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -451,60 +450,75 @@ export function useThreadListActions(): { [updateThreadMetadata], ); - // Move up / Move down for the pinned block. Computed against the CANONICAL - // keyed pinned order (not the rendered list), so the move is valid even - // while search or a project scope filters rows: the same fractional-key - // scheme web dragging uses, one write to one thread per move (plus a - // one-time section materialization when legacy keyless pins are involved). + // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); - // One move at a time: a second tap before the first write's event lands - // would plan from the same stale snapshot and silently collapse two moves - // into one — same double-dispatch guard as snoozeThread. - const movePinnedInFlightRef = useRef(false); - const movePinnedThread = useCallback( + const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); + const moveThread = useCallback( async (thread: EnvironmentThreadShell, direction: "up" | "down") => { - if (movePinnedInFlightRef.current) return false; - if (!environmentSupportsPinReorder(thread.environmentId)) { + if (getPendingThreadOrder() !== null) return false; + const section = thread.pinnedAt != null ? "pinned" : "active"; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { + const capabilities = configs.get(environmentId)?.environment.capabilities; + return section === "pinned" + ? capabilities?.threadPinReorder === true + : capabilities?.threadActiveReorder === true; + }; + if (!supportsReorder(thread.environmentId)) { Alert.alert( "Could not move thread", - "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + "This environment's server does not support reordering these threads. Update the server to arrange them.", ); return false; } const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - const pinned = sortPinnedThreadsByOrderKey( - shells.filter( - (shell) => - shell.pinnedAt != null && - shell.archivedAt === null && - environmentSupportsPinReorder(shell.environmentId), + const ordered = getThreadListV2OrderedSection({ + threads: shells, + section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), ), - ); - const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); - const assignments = planPinnedMove({ - orderedIds, - keysById: new Map( - pinned.map((shell) => [ - scopedThreadKey(shell.environmentId, shell.id), - shell.pinOrderKey ?? null, - ]), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), ), - movedId: scopedThreadKey(thread.environmentId, thread.id), - direction, }); - if (assignments === null || assignments.length === 0) return false; + const assignments = createThreadMovePlanner({ + allThreads: shells, + ordered, + section, + reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + })(scopedThreadKey(thread.environmentId, thread.id), direction); + if (assignments === null) return false; const shellByKey = new Map( - pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); selectionHaptic(); - movePinnedInFlightRef.current = true; + const pending = beginPendingThreadOrder( + createPendingThreadOrder({ + section, + ordered, + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + assignments, + }), + ); + let succeeded = false; + const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; try { for (const assignment of assignments) { + if (!pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; - const result = await reorderPinnedMutation({ + const result = await reorder({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, }); @@ -514,20 +528,20 @@ export function useThreadListActions(): { "Could not move thread", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The pinned thread could not be moved.", + : "The thread could not be moved.", ); - // No rollback: keys already written are valid orderings on their - // own (each write is a complete, consistent placement), so a - // partial materialization leaves the list sensible, not corrupt. + // Keep confirmed keys when a later environment rejects its write. return false; } } + succeeded = true; + pending.complete(); return true; } finally { - movePinnedInFlightRef.current = false; + if (!succeeded) pending.cancel(); } }, - [reorderPinnedMutation], + [reorderActiveMutation, reorderPinnedMutation], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -541,7 +555,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/home/workspace-connection-status.test.ts b/apps/mobile/src/features/home/workspace-connection-status.test.ts index 15a990bb1cbe..f1af93316a6b 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.test.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { WorkspaceState } from "../../state/workspaceModel"; -import { - shouldShowWorkspaceConnectionStatus, - workspaceConnectionStatusLabel, - workspaceConnectionStatusPresentation, -} from "./workspace-connection-status"; +import { workspaceConnectionStatusPresentation } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { return { @@ -27,14 +23,16 @@ function workspaceState(overrides: Partial = {}): WorkspaceState describe("workspace connection status", () => { it("stays hidden while a ready environment is connected", () => { - expect(shouldShowWorkspaceConnectionStatus(workspaceState())).toBe(false); + expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); }); it("surfaces offline snapshots", () => { const state = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("You are offline"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "You are offline", + showsProgress: false, + }); }); it("names the environment while reconnecting", () => { @@ -54,8 +52,10 @@ describe("workspace connection status", () => { ], }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Reconnecting to Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Reconnecting to Julius’s Mac mini", + showsProgress: true, + }); }); it("surfaces connection errors before the generic disconnected fallback", () => { @@ -65,15 +65,19 @@ describe("workspace connection status", () => { hasReadyEnvironment: false, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Could not reach Julius’s Mac mini"); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Could not reach Julius’s Mac mini", + showsProgress: false, + }); }); it("shows shell catch-up while cached threads remain visible", () => { const state = workspaceState({ hasPendingShellSnapshot: true }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Syncing threads..."); + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Syncing threads...", + showsProgress: true, + }); }); it("distinguishes initial shell loading from cached catch-up", () => { @@ -82,39 +86,9 @@ describe("workspace connection status", () => { hasPendingShellSnapshot: true, }); - expect(shouldShowWorkspaceConnectionStatus(state)).toBe(true); - expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); - }); - - it("presents nothing while connected", () => { - expect(workspaceConnectionStatusPresentation(workspaceState())).toBeNull(); - }); - - it("presents progress while reconnecting but not while offline", () => { - const reconnecting = workspaceState({ - hasConnectingEnvironment: true, - hasReadyEnvironment: false, - connectingEnvironments: [ - { - environmentId: "environment-1" as never, - environmentLabel: "Julius’s Mac mini", - displayUrl: "", - isRelayManaged: false, - connectionState: "reconnecting", - connectionError: null, - connectionErrorTraceId: null, - }, - ], - }); - expect(workspaceConnectionStatusPresentation(reconnecting)).toEqual({ - label: "Reconnecting to Julius’s Mac mini", + expect(workspaceConnectionStatusPresentation(state)).toEqual({ + label: "Loading threads...", showsProgress: true, }); - - const offline = workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }); - expect(workspaceConnectionStatusPresentation(offline)).toEqual({ - label: "You are offline", - showsProgress: false, - }); }); }); diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index 6f9898b1bb01..d45a46adf933 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -6,7 +6,7 @@ export interface WorkspaceConnectionStatusPresentation { readonly showsProgress: boolean; } -export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { +function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): boolean { return ( state.networkStatus === "offline" || state.connectionError !== null || @@ -16,7 +16,7 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } -export function workspaceConnectionStatusLabel(state: WorkspaceState): string { +function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { return `Reconnecting to ${state.connectingEnvironments[0]!.environmentLabel}`; diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 909bbcf5a762..ad29e0f32a1a 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -12,10 +12,8 @@ import { import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; -import { useProject, useThreadShell } from "../../state/entities"; -import { useEnvironmentQuery } from "../../state/query"; +import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; -import { vcsEnvironment } from "../../state/vcs"; import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; import { dispatchHardwareKeyboardCommand, @@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({ const navigation = useNavigation(); const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); const activeThread = useThreadShell(activeThreadRef); - const activeProjectRef = useMemo( - () => - activeThread === null - ? null - : { - environmentId: activeThread.environmentId, - projectId: activeThread.projectId, - }, - [activeThread], - ); - const activeProject = useProject(activeProjectRef); - const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; - const gitStatus = useEnvironmentQuery( - activeThread !== null && - activeThread.linkedPullRequest == null && - activeThread.branch !== null && - activeThreadCwd !== null - ? vcsEnvironment.status({ - environmentId: activeThread.environmentId, - input: { cwd: activeThreadCwd }, - }) - : null, - ).data; - const detectedPullRequestUrl = - activeThread?.branch != null && gitStatus?.refName === activeThread.branch - ? (gitStatus.pr?.url ?? null) - : null; const copyTarget = useMemo( () => activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ threadId: activeThread?.id ?? activeThreadRef.threadId, - linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, - detectedPullRequestUrl, + linkedPullRequestUrl: + (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, }), - [activeThread, activeThreadRef, detectedPullRequestUrl], + [activeThread, activeThreadRef], ); const [copyFeedback, setCopyFeedback] = useState(EMPTY_COPY_FEEDBACK); const copyRequestIdRef = useRef(0); diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 80ebe1157d92..9e768112fb4a 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -80,11 +80,9 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { return ( - - - Partial diff - - {props.notice} + + Partial diff + {props.notice} ); }); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 3c2eb9016feb..39b9c0cef26e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -6,8 +6,6 @@ import type { import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences"; -import { resolveMobileCodeSurface } from "../../lib/appearancePreferences"; -import { MOBILE_CODE_SURFACE } from "../../lib/typography"; import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme"; import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme"; import { computeWordAltDiffRanges } from "./reviewWordDiffs"; @@ -25,13 +23,8 @@ const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i; const NATIVE_RGBA_COLOR = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; -export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; -export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( - resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize), -); - function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); if (hex) return color; diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index 9b39a51dc90f..406665def071 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { - it("builds stable reset keys from the rendered diff identity", () => { + it("changes reset keys when the rendered diff identity changes", () => { const input = { threadKey: "env:thread", sectionId: "turn:2", @@ -13,15 +13,12 @@ describe("native review diff bridge", () => { rowCount: 4, }; - expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); - expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( - buildNativeReviewTokensResetKey(input), - ); - expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); - }); + const resetKey = buildNativeReviewTokensResetKey(input); - it("includes diff length in the hash key to reduce accidental collisions", () => { - expect(hashReviewDiffKey("abc")).toMatch(/^3:/); - expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + expect( + buildNativeReviewTokensResetKey({ ...input, diff: "diff --git a/b.ts b/b.ts" }), + ).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).not.toBe(resetKey); }); }); diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts index d04534003b37..6c7c1e545785 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -3,7 +3,7 @@ import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffH // Pure key-derivation helpers for the native review diff bridge. Kept free of // react-native / hook imports so they stay unit-testable in node. -export function hashReviewDiffKey(diff: string | null | undefined): string { +function hashReviewDiffKey(diff: string | null | undefined): string { if (!diff) { return "empty"; } diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.ts b/apps/mobile/src/features/review/reviewFileVisibility.test.ts index 4a7a2f98af62..8fec1cbf8bd5 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.test.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - getDefaultReviewExpandedFileIds, getValidExplicitReviewFileIds, getValidReviewFileIds, removeReviewFileId, @@ -29,7 +28,6 @@ describe("review file visibility", () => { const files = [makeFile("a.ts"), makeFile("b.ts")]; it("defaults expanded files to every renderable file", () => { - expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]); expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]); }); diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts index 53f2d7f5f956..fbdfcf230225 100644 --- a/apps/mobile/src/features/review/reviewFileVisibility.ts +++ b/apps/mobile/src/features/review/reviewFileVisibility.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState"; import type { ReviewRenderableFile } from "./reviewModel"; -export function getDefaultReviewExpandedFileIds( +function getDefaultReviewExpandedFileIds( files: ReadonlyArray, ): ReadonlyArray { return files.map((file) => file.id); diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 3390afd9ff27..ee568085f7a2 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { - buildReviewListItems, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -271,84 +270,4 @@ describe("buildReviewParsedDiff", () => { actionLabel: "Load diff", }); }); - - it("flattens expanded file rows into virtualized review items", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/a.ts", - rows: [ - { - kind: "hunk", - id: "hunk-1", - header: "@@ -1,1 +1,2 @@", - context: null, - }, - { - kind: "line", - id: "line-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, - }, - ], - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "hunk", - fileId: file.id, - file, - row: file.rows[0], - }), - expect.objectContaining({ - kind: "line", - fileId: file.id, - file, - row: file.rows[1], - lineIndex: 0, - }), - ]); - }); - - it("keeps large diffs collapsed into a placeholder item until revealed", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/big.ts", - rows: Array.from({ length: 401 }, (_, index) => ({ - kind: "line" as const, - id: `line-${index}`, - change: "add" as const, - oldLineNumber: null, - newLineNumber: index + 1, - content: `const line${index} = ${index};`, - additionTokenIndex: index, - deletionTokenIndex: null, - comparison: null, - })), - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "file-suppressed", - fileId: file.id, - actionLabel: "Load diff", - }), - ]); - }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d1..202157b837cc 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -58,45 +58,6 @@ export interface ReviewRenderableFile { readonly rows: ReadonlyArray; } -export interface ReviewFileHeaderListItem { - readonly kind: "file-header"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly expanded: boolean; -} - -export interface ReviewFileSuppressedListItem { - readonly kind: "file-suppressed"; - readonly id: string; - readonly fileId: string; - readonly message: string; - readonly actionLabel: string | null; -} - -export interface ReviewHunkListItem { - readonly kind: "hunk"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableHunkRow; -} - -export interface ReviewLineListItem { - readonly kind: "line"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableLineRow; - readonly lineIndex: number; -} - -export type ReviewListItem = - | ReviewFileHeaderListItem - | ReviewFileSuppressedListItem - | ReviewHunkListItem - | ReviewLineListItem; - export type ReviewFilePreviewState = | { readonly kind: "render"; @@ -316,77 +277,6 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil return { kind: "render" }; } -// The flattened review list item model is inspired by pierre/diffs' iterator-first -// virtualization architecture, adapted here for React Native virtualization. -// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs -// Reference files: -// - src/utils/iterateOverDiff.ts -// - src/components/VirtualizedFileDiff.ts -export function buildReviewListItems(input: { - readonly files: ReadonlyArray; - readonly expandedFileIds: ReadonlyArray; - readonly revealedLargeFileIds: ReadonlyArray; -}): ReadonlyArray { - const expandedFileIds = new Set(input.expandedFileIds); - const revealedLargeFileIds = new Set(input.revealedLargeFileIds); - const items: ReviewListItem[] = []; - - input.files.forEach((file) => { - const expanded = expandedFileIds.has(file.id); - items.push({ - kind: "file-header", - id: `${file.id}:header`, - fileId: file.id, - file, - expanded, - }); - - if (!expanded) { - return; - } - - const previewState = getReviewFilePreviewState(file); - if (previewState.kind === "suppressed") { - if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { - items.push({ - kind: "file-suppressed", - id: `${file.id}:suppressed`, - fileId: file.id, - message: previewState.message, - actionLabel: previewState.actionLabel, - }); - return; - } - } - - let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { - if (row.kind === "hunk") { - items.push({ - kind: "hunk", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - }); - return; - } - - items.push({ - kind: "line", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - lineIndex, - }); - lineIndex += 1; - }); - }); - - return items; -} - function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index be723040152a..6d36171d2711 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,131 +1,60 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} - -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], - ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ - { - content: longLine, - color: null, - fontStyle: null, - }, ]); }); -}); -describe("highlightCodeSnippet", () => { - it("resolves language aliases and returns syntax-colored tokens", async () => { + it("initializes source and snippet highlighting without a warmup", async () => { + vi.resetModules(); + const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - const highlighted = await highlightCodeSnippet({ - code: source, - language: "ts", + + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, theme: "dark", }); @@ -136,18 +65,56 @@ describe("highlightCodeSnippet", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( + await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), + ).toEqual(highlighted); }); }); -describe("highlightSourceFile", () => { - it("initializes source and snippet highlighting without a warmup", async () => { - vi.resetModules(); - const highlighter = await import("./shikiReviewHighlighter"); - const source = "const answer: number = 42;"; +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + }); +}); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", theme: "dark", }); @@ -158,8 +125,5 @@ describe("highlightSourceFile", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( - await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); }); }); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c684a6686430..8fa7f69a433c 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -267,7 +233,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -507,84 +465,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index c6a656e012f7..1728da662686 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -8,7 +8,7 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 41c2076ac7b4..309b5fad9617 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -44,6 +44,7 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, supportsSharedSettingsSync, @@ -60,6 +61,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { PrismSettingsRow } from "../../fork/prism/PrismSettingsRow"; // fork: prism type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -139,6 +141,7 @@ function LocalSettingsRouteScreen() { + @@ -525,6 +528,7 @@ function ConfiguredSettingsRouteScreen() { + @@ -584,11 +588,13 @@ function AutoSettleSettingsRows() { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: reference.environmentId, primarySettings: referenceSettings, + primaryCapabilities: reference.serverConfig?.environment.capabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, + capabilities: environment.serverConfig?.environment.capabilities, })), }); @@ -652,11 +658,22 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings(referenceSettings); + const patch = pickSharedServerSettings( + referenceSettings, + reference.serverConfig?.environment.capabilities, + ); for (const mismatch of mismatches) { + const target = environments.find( + (candidate) => candidate.environmentId === mismatch.environmentId, + ); void updateSettings({ environmentId: mismatch.environmentId, - input: { patch }, + input: { + patch: filterSharedServerPatch( + patch, + target?.serverConfig?.environment.capabilities, + ), + }, }); } }} diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 79d67ebaa7c2..f161e654788b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -37,7 +37,6 @@ import { getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "../../../lib/mobileThemeRuntime"; -import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ @@ -143,8 +142,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useLayoutEffect(() => { selectedThemeIdsRef.current = themeIds; syncThemeRuntime(runtimeState); - cacheTerminalFontSize(appearance.terminalFontSize); - }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + }, [runtimeState, syncThemeRuntime, themeIds]); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 9b5d8e113f85..bc8ddfc84ba5 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -176,10 +176,9 @@ export function FontSizeSliderRow(props: { /> { - it("returns the Pierre light terminal palette", () => { - expect(getPierreTerminalTheme("light")).toMatchObject({ +describe("getMobileTerminalTheme", () => { + it("preserves the default light terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "light")).toMatchObject({ background: "#f2f2f7", foreground: "#6C6C71", cursorForeground: "#009fff", @@ -19,23 +15,14 @@ describe("getPierreTerminalTheme", () => { }); }); - it("returns the Pierre dark terminal palette", () => { - expect(getPierreTerminalTheme("dark")).toMatchObject({ + it("preserves the default dark terminal palette", () => { + expect(getMobileTerminalTheme("t3-code", "dark")).toMatchObject({ background: "#0a0a0a", foreground: "#adadb1", cursorForeground: "#009fff", cursorBackground: "#0a0a0a", }); }); -}); - -describe("getMobileTerminalTheme", () => { - it("preserves the Pierre terminal for the default theme", () => { - for (const scheme of ["light", "dark"] as const) { - expect(getMobileTerminalTheme("t3-code", scheme)).toEqual(getPierreTerminalTheme(scheme)); - } - }); - it("applies the selected palette without replacing ANSI status colors", () => { const standard = getMobileTerminalTheme("t3-code", "dark"); const ocean = getMobileTerminalTheme("ocean", "dark"); @@ -58,7 +45,7 @@ describe("getMobileTerminalTheme", () => { describe("buildGhosttyThemeConfig", () => { it("serializes theme colors into a ghostty config file", () => { - const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); + const config = buildGhosttyThemeConfig(getMobileTerminalTheme("t3-code", "dark")); expect(config).toContain("background = #0a0a0a"); expect(config).toContain("foreground = #adadb1"); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 9a913022571d..569b10f7bd55 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -74,7 +74,7 @@ const PIERRE_DARK_THEME: TerminalTheme = { ], }; -export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { +function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; } diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts index 0bb3c1395915..6879fdfdbb20 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.test.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - cacheTerminalFontSize, cacheTerminalGridSize, - getCachedTerminalFontSize, getCachedTerminalGridSize, resetTerminalUiStateCaches, } from "./terminalUiState"; @@ -14,14 +12,6 @@ describe("terminalUiState", () => { resetTerminalUiStateCaches(); }); - it("caches terminal font size using the shared normalization rules", () => { - expect(getCachedTerminalFontSize()).toBeNull(); - expect(cacheTerminalFontSize(8.5)).toBe(8.5); - expect(getCachedTerminalFontSize()).toBe(8.5); - expect(cacheTerminalFontSize(100)).toBe(14); - expect(getCachedTerminalFontSize()).toBe(14); - }); - it("stores terminal grid sizes per terminal target", () => { const primaryTarget = { environmentId: EnvironmentId.make("env-1"), diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts index 2cac0bf52b9e..84274430e8a1 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -1,7 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; - export interface TerminalGridSize { readonly cols: number; readonly rows: number; @@ -14,22 +12,11 @@ export interface TerminalUiStateTarget { } const terminalGridSizeCache = new Map(); -let cachedTerminalFontSize: number | null = null; function terminalUiStateKey(target: TerminalUiStateTarget): string { return `${target.environmentId}:${target.threadId}:${target.terminalId}`; } -export function getCachedTerminalFontSize(): number | null { - return cachedTerminalFontSize; -} - -export function cacheTerminalFontSize(value: number | null | undefined): number { - const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); - cachedTerminalFontSize = normalized; - return normalized; -} - export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; } @@ -47,6 +34,5 @@ export function cacheTerminalGridSize( } export function resetTerminalUiStateCaches() { - cachedTerminalFontSize = null; terminalGridSizeCache.clear(); } diff --git a/apps/mobile/src/features/threads/ComposerFeedback.tsx b/apps/mobile/src/features/threads/ComposerFeedback.tsx new file mode 100644 index 000000000000..dbbdc166d198 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerFeedback.tsx @@ -0,0 +1,63 @@ +import { + codexFeedbackNotice, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; + +export function ComposerFeedback({ + submission, + onDismiss, +}: { + readonly submission: CodexFeedbackSubmission; + readonly onDismiss: () => void; +}) { + const notice = codexFeedbackNotice(submission); + if (!notice) return null; + return ( + + + + + {notice.title} + + {submission.status !== "uploading" ? ( + + + + ) : null} + + {notice.description ? ( + + {notice.description} + + ) : null} + {submission.status === "sent" ? ( + + copyTextWithHaptic(submission.feedbackId, { target: "Codex feedback thread ID" }) + } + className="self-start py-1 active:opacity-60" + > + Copy ID + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..bcd76722c8e5 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -0,0 +1,103 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** + * The /usage-limits result, docked above the composer. It is the Usage → Limits + * card one size down, so the two read as the same thing. The surface is opaque + * because nothing blurs the feed behind it. + */ +export function ComposerUsageLimits({ + report, + environmentId, + onClose, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; + readonly onClose: () => void; +}) { + const now = Date.parse(report.createdAt); + const { height } = useWindowDimensions(); + const close = ( + + + + ); + return ( + + + {report.accounts.map((account, index) => { + const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + return ( + + ) : undefined + } + /> + ); + })} + {report.accounts.length === 0 ? ( + // Nothing but notices, so the close control needs a row of its own. + + Usage limits + {close} + + ) : null} + {report.notices.map((notice) => ( + + {notice} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index fb12a35d6c23..802759b5a2c7 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -143,9 +143,7 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) { } const bgClass = - progress.phase === "error" - ? "border-adaptive-red-200-800 bg-adaptive-red-50-950-a80" - : "bg-card border-border"; + progress.phase === "error" ? "border-danger-border bg-danger" : "bg-card border-border"; return ( + diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378a6d..dc1ee942d13a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -9,6 +9,7 @@ type NewTaskDraftRouteParams = { readonly projectId?: string | string[]; readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; + readonly draftId?: string | string[]; readonly incomingShareId?: string | string[]; }; @@ -43,6 +44,7 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e1cc7405bde2..e2afdbb498a4 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -82,6 +83,7 @@ import { restoreComposerDraftSnapshot, scheduleUnusedComposerAttachmentCleanup, type ComposerDraft, + waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { @@ -149,6 +151,8 @@ export function NewTaskDraftScreen(props: { }; /** Queued outbox message id when editing an existing pending task. */ readonly pendingTaskId?: string; + /** Existing new-task draft key to resume (a Draft row in the thread list). */ + readonly draftId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string; }) { @@ -309,6 +313,14 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. + // T3 owns /usage-limits only where Limits has data for the selected provider. + const offersUsageLimits = + flow.selectedProviderStatus !== null && + hasProviderUsageLimits( + flow.selectedProviderStatus.driver, + selectedEnvironmentServerConfig?.providers ?? [], + selectedEnvironmentServerConfig?.usageLimitSources ?? [], + ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -320,6 +332,7 @@ export function NewTaskDraftScreen(props: { selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, + offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, @@ -410,7 +423,44 @@ export function NewTaskDraftScreen(props: { }; }, []); - const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask } = flow; + const { beginEditingPendingTask, cancelEditingPendingTask, editingPendingTask, openDraft } = flow; + // A Draft row opens its own draft; a fresh New Task never reuses one. + // Drafts hydrate from disk and projects arrive with the shell snapshot, so + // on a cold launch the draft or its project can be missing for a moment; + // wait for hydration and retry while projects load. Attempt each id once + // after that so a draft discarded mid-session does not keep bouncing to + // the picker. + const attemptedDraftIdRef = useRef(null); + useEffect(() => { + if (!props.draftId || props.pendingTaskId) { + return; + } + const draftId = props.draftId; + if (attemptedDraftIdRef.current === draftId) { + return; + } + let cancelled = false; + void waitForComposerDraftsLoaded().then(() => { + if (cancelled || attemptedDraftIdRef.current === draftId) { + return; + } + if (openDraft(draftId)) { + attemptedDraftIdRef.current = draftId; + return; + } + if (getComposerDraftSnapshot(draftId).project !== undefined && projects.length === 0) { + // The draft exists; its project has not arrived yet. Retry on the + // next projects change instead of giving up. + return; + } + attemptedDraftIdRef.current = draftId; + navigation.dispatch(StackActions.replace("NewTask")); + }); + return () => { + cancelled = true; + }; + }, [navigation, openDraft, projects, props.draftId, props.pendingTaskId]); + const attemptedPendingTaskIdRef = useRef(null); useEffect(() => { if (!props.pendingTaskId || editingPendingTask?.messageId === props.pendingTaskId) { @@ -447,9 +497,10 @@ export function NewTaskDraftScreen(props: { const lastInitialProjectRefRef = useRef(props.initialProjectRef); useEffect(() => { - // Pending-task editing owns project selection (and must not fall through - // to the replace("NewTask") fallback while its hydration is in flight). - if (props.pendingTaskId) { + // Pending-task editing and draft resumption own project selection (and + // must not fall through to the replace("NewTask") fallback while their + // hydration is in flight). + if (props.pendingTaskId || props.draftId) { return; } if (lastInitialProjectRefRef.current !== props.initialProjectRef) { @@ -508,6 +559,7 @@ export function NewTaskDraftScreen(props: { props.initialProjectRef, props.incomingShareId, props.pendingTaskId, + props.draftId, navigation, selectedProject, selectedProjectKey, @@ -908,6 +960,20 @@ export function NewTaskDraftScreen(props: { ); return; } + // T3's own limits command is answered by the thread composer; a new task would + // send it to the agent. A provider's same-named command, or a prompt carrying + // attachments, goes through as usual. + if ( + offersUsageLimits && + isUsageLimitsCommand(initialMessageText) && + draft.attachments.length === 0 + ) { + Alert.alert( + "Usage limits", + "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index a94f321a4ad8..4be6ff611842 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -30,22 +30,20 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} {warning ? ( - - {warning} - + {warning} ) : null} {options.map((option) => ( @@ -53,10 +51,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { key={option.decision} className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${ option.decision === "accept" - ? "bg-blue-500" + ? "bg-primary" : option.decision === "decline" - ? "bg-adaptive-rose-100-500-a18" - : "bg-adaptive-neutral-200-800" + ? "bg-danger" + : "bg-subtle-strong" }`} disabled={props.respondingApprovalId === props.approval.requestId} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -64,10 +62,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index f821c5714950..8fe7fc186adb 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -161,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" + className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} @@ -216,7 +216,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } layout={CARD_LAYOUT_TRANSITION} - className="overflow-hidden gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4" + className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" style={ EXPANDED_CARD_IS_OVERLAY ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] @@ -230,14 +230,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className="flex-row items-start gap-2" > - + User input needed - - Fill in the pending answers - + Fill in the pending answers - + - + {question.header} - + {question.question} @@ -276,9 +274,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { key={optionValue} className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", - selected - ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" - : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", + selected ? "border-primary bg-primary/10" : "border-border bg-input", )} onPress={() => props.onSelectOption( @@ -292,15 +288,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {option.label} {description ? ( - + {description} ) : null} @@ -318,7 +312,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" + className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> ) : null} @@ -328,14 +322,21 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { void props.onSubmit()} > - Submit answers + + Submit answers + ) : null; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..d61d7b92d0fb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,7 +7,13 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + UsageLimitsReport, } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -20,7 +26,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -124,6 +130,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ + readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + selectedProviderStatus !== null && + hasProviderUsageLimits( + selectedProviderStatus.driver, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + ); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + onShowUsageLimits(report); + if (!report) { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return report !== null; + }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, + offersUsageLimits: usageLimitsOffered, + // With attachments aboard the pick just inserts the text, so it sends as a prompt. + onUsageLimits: + usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage } = props; - const handleSend = useCallback(async () => { + // Typed out in full rather than picked from the menu. Attachments mean the + // user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + isUsageLimitsCommand(props.draftMessage) && + props.draftAttachments.length === 0 + ) { + if (openUsageLimits()) onChangeDraftMessage(""); + return; + } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + props.draftMessage, + props.draftAttachments.length, + onChangeDraftMessage, + openUsageLimits, + usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..3392b534b634 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -3,7 +3,10 @@ import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, } from "@t3tools/client-runtime/codex-artifact-templates"; -import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; +import type { + CodexFeedbackSubmission, + EnvironmentThreadStatus, +} from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -19,6 +22,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + UsageLimitsReport, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -57,6 +61,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; @@ -70,6 +75,8 @@ import type { ThreadFeedEntry, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerFeedback } from "./ComposerFeedback"; +import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -98,6 +105,8 @@ export interface ThreadDetailScreenProps { readonly screenTone: StatusTone; readonly connectionError: string | null; readonly environmentLabel: string | null; + readonly feedbackSubmissions: ReadonlyArray; + readonly onDismissFeedback: (id: MessageId) => void; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; readonly isCompacting: boolean; @@ -359,6 +368,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = [ + selectedThreadKey, + props.selectedThread.modelSelection.instanceId, + props.selectedThread.latestTurn?.turnId ?? "", + props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the key changes so it cannot resurface stale. + if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { + setUsageLimitsPanel(null); + } + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey + ? collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + usageLimitsPanel.now, + ) + : null, + [ + props.selectedThread.modelSelection.instanceId, + props.serverConfig, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const showUsageLimits = useCallback( + (report: UsageLimitsReport | null) => + setUsageLimitsPanel( + report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, + ), + [selectedThreadKey, usageLimitsKey], + ); + const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); + // A send may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -623,6 +694,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); + setSubmittedMessageId(messageId); setAnchorMessageId( resolveThreadFeedSubmissionAnchor({ @@ -637,6 +711,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [ anchorMessageId, + clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, props.selectedThreadQueueCount, @@ -778,6 +853,26 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {props.feedbackSubmissions.map((submission) => ( + props.onDismissFeedback(submission.id)} + /> + ))} + {usageLimitsReport && activeUserInputRequestId === null ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( void; }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); + const linkIcon = resolveMarkdownLinkIcon(props.host); + const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); return ( - {faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( + {linkIcon ? ( + + ) : faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( ; + } + + if (entry.type === "agent-spawn") { + return ( + props.onToggleWorkGroup(entry.id, entry.id)} + onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())} + /> + ); + } + if (entry.type === "work-toggle") { return ( {message.text.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1516,14 +1557,16 @@ function renderFeedEntry( {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} > {renderedText.trim().length > 0 ? ( - + + + ) : null} {attachments.map((attachment) => { return isImageAttachment(attachment) ? ( @@ -1579,6 +1622,7 @@ function renderFeedEntry( rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} + edgeFadeColor={props.screenColor} themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} @@ -1930,6 +1974,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const fileShareSourceIdentifier = useId(); + const shareFileChip = useFileChipShare( + props.environmentId, + props.threadId, + fileShareSourceIdentifier, + ); useEffect(() => { setExpandedVideo(null); setExpandedFile(null); @@ -1942,6 +1992,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }); const contentWidth = Math.max(0, viewportWidth - contentHorizontalPadding * 2); const userBubbleMaxWidth = contentWidth * 0.85; + const markdownContentWidth = Math.max(0, contentWidth - ASSISTANT_ROW_HORIZONTAL_PADDING * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); const topContentInset = props.contentTopInset ?? insets.top + IOS_NAV_BAR_HEIGHT; @@ -1967,6 +2018,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; + const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { @@ -2080,10 +2132,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "open-file": onMarkdownLinkPress(href); return; + case "save": + shareFileChip(target); + return; } }, }), - [onMarkdownLinkPress, props.workspaceRoot], + [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], ); const renderMarkdownImage = useCallback( (image) => { @@ -2570,6 +2625,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "turn-fold": return TURN_FOLD_HEIGHT; case "work-toggle": + case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": if (isContextCompactionActivityGroup(entry)) { @@ -2614,12 +2670,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { renderMarkdownImage, renderViewedImage, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, + markdownContentWidth, skills: props.skills, onUseArtifactTemplate: props.onUseArtifactTemplate, })} @@ -2635,12 +2693,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, themeAppearance, userBubbleMaxWidth, + markdownContentWidth, onCopyWorkRow, markdownLinkHandlers, onPressPreview, @@ -2669,7 +2729,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return ( - <> + ) : null} + setExpandedVideo(null)} /> + setExpandedFile(null)} /> - - setExpandedVideo(null)} /> - setExpandedFile(null)} /> - + ); }); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index c506bf1875eb..a67225f1a336 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,5 +1,5 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { useEffect, useId, useState } from "react"; +import { createContext, useContext, useEffect, useId, useState } from "react"; import { ActivityIndicator, Image, @@ -15,32 +15,56 @@ import { MediaActionsMenu } from "../../components/MediaActionsMenu"; import { PresentationSource } from "../../components/NativePresentation"; import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions"; import { useAssetUrlState } from "../../state/assets"; -import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { + MARKDOWN_IMAGE_MAX_WIDTH, + type MarkdownImageDisplaySize, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +/** + * Width the feed lays markdown out in. The feed already knows this from its + * viewport, so an image can size its frame on the first render instead of + * waiting for its own onLayout, which would change the row's height once + * more after the list has positioned the rows below it. It is an upper + * bound: a list item or blockquote indents its column, and the measured + * width takes over once it is known. + */ +export const MarkdownImageAvailableWidthContext = createContext(0); export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; readonly unavailable: boolean; readonly alt: string | null; + /** Pixel size from the server, when it could read the header; the frame is final from the first render. */ + readonly knownSize?: { readonly width: number; readonly height: number } | undefined; readonly actionsSource?: MediaActionsSource; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const sourceIdentifier = useId(); const mediaActions = useMediaActions(props.actionsSource); - const [availableWidth, setAvailableWidth] = useState(0); - const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); + const contextWidth = useContext(MarkdownImageAvailableWidthContext); + const [measuredWidth, setMeasuredWidth] = useState(0); + const availableWidth = + measuredWidth > 0 && contextWidth > 0 + ? Math.min(contextWidth, measuredWidth) + : contextWidth || measuredWidth; + const [decodedSize, setDecodedSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); useEffect(() => { - setSourceSize(null); + setDecodedSize(null); }, [props.sourceKey]); useEffect(() => { setFailedUri(null); }, [props.uri]); - const displaySize = - sourceSize === null + // The decoded size is what the platform actually drew, so it wins over the + // server's header hint once it exists. + const sourceSize = decodedSize ?? props.knownSize ?? null; + const displaySize: MarkdownImageDisplaySize | null = + sourceSize === null || availableWidth <= 0 ? null : resolveMarkdownImageDisplaySize({ sourceWidth: sourceSize.width, @@ -54,7 +78,7 @@ export function ThreadMarkdownImageView(props: { return ( setAvailableWidth(event.nativeEvent.layout.width)} + onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)} style={{ alignSelf: "stretch", gap: 6 }} > {props.uri === null || failed ? ( @@ -97,14 +121,12 @@ export function ThreadMarkdownImageView(props: { > setFailedUri(props.uri)} /> @@ -173,6 +195,7 @@ export function ThreadMarkdownImage(props: { : `workspace:${props.resource.path}` } unavailable={assetUrl._tag === "Failure"} + knownSize={assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined} alt={props.alt} actionsSource={props.actionsSource} onPressPreview={props.onPressPreview} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 07357a1b7524..4a49a34fc0ab 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -78,6 +79,7 @@ import { } from "./thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -298,7 +300,7 @@ function ThreadNavigationSidebarPane( ? pendingTasks : pendingTasks.filter((pendingTask) => selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), ), ), [threadListV2Enabled, pendingTasks, selectedProjectRefs], @@ -353,13 +355,6 @@ function ThreadNavigationSidebarPane( }), [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], ); - const projectCwdByKey = useMemo(() => { - const map = new Map(); - for (const project of projects) { - map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); - } - return map; - }, [projects]); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -444,6 +439,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -463,19 +467,40 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); - // Canonical arranged pinned order for Move up/down flags — computed from - // all shells so search/scope filtering never disables a valid move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -488,6 +513,7 @@ function ThreadNavigationSidebarPane( nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ + pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, @@ -502,6 +528,7 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -542,10 +569,10 @@ function ThreadNavigationSidebarPane( const v2PendingTasks = pendingTasks.filter( (pendingTask) => (options.selectedEnvironmentId === null || - pendingTask.message.environmentId === options.selectedEnvironmentId) && + pendingTask.environmentId === options.selectedEnvironmentId) && (selectedProjectRefs === null || selectedProjectRefs.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), @@ -742,7 +769,6 @@ function ThreadNavigationSidebarPane( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", projectByKey, - projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, @@ -752,7 +778,6 @@ function ThreadNavigationSidebarPane( [ props.selectedThreadKey, projectByKey, - projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, @@ -827,8 +852,8 @@ function ThreadNavigationSidebarPane( switch (item.type) { case "v2-pending": { const pendingScopeKey = scopedProjectKey( - item.pendingTask.message.environmentId, - item.pendingTask.creation.projectId, + item.pendingTask.environmentId, + item.pendingTask.projectId, ); return ( 1 - ? (savedConnectionsById[item.pendingTask.message.environmentId] - ?.environmentLabel ?? null) + ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null) : null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} pane="sidebar" showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} @@ -853,6 +875,10 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; + const movePlanner = item.item.pinned + ? threadMovePlanners.pinned + : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( 0 + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) } - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} - projectCwd={projectCwdByKey.get(scopeKey) ?? null} + onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} @@ -979,12 +1003,9 @@ function ThreadNavigationSidebarPane( variant="sidebar" pendingTask={item.pendingTask} environmentLabel={ - savedConnectionsById[item.pendingTask.message.environmentId]?.environmentLabel ?? - null + savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null } - environmentMachine={machineByEnvironmentId.get( - item.pendingTask.message.environmentId, - )} + environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} isLast={item.isLast} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} @@ -1000,10 +1021,6 @@ function ThreadNavigationSidebarPane( savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? - null - } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -1041,20 +1058,21 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, - arrangedPinnedKeys, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, machineByEnvironmentId, - movePinnedThread, + moveThread, openPendingTask, pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, projectByKey, - projectCwdByKey, projectTitleByProjectKey, regenerateThreadTitle, props.onNewThreadInProject, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index df9486e8556a..a59fa1a4450c 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,12 +7,21 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ThreadId, + type ProjectScript, +} from "@t3tools/contracts"; import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -627,7 +636,12 @@ function ThreadRouteContent( gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, @@ -773,6 +787,8 @@ function ThreadRouteContent( screenTone={connectionTone(routeConnectionState)} connectionError={routeConnectionError} environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} + feedbackSubmissions={composer.feedbackSubmissions} + onDismissFeedback={composer.dismissFeedback} selectedThreadFeed={composer.selectedThreadFeed} activeWorkStartedAt={composer.activeWorkStartedAt} isCompacting={composer.isCompacting} @@ -819,6 +835,7 @@ function ThreadRouteContent( <> {activeInspectorRenderer ? : null} { it("resolves a workspace-relative link to both paths", () => { @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { ]); }); }); + +describe("file chip downloads", () => { + const threadId = ThreadId.make("thread-1"); + + it.each([ + [ + "/tmp/maria-counter/maria-counter-final.mp4", + "/tmp/maria-counter/maria-counter-final.mp4", + "video/mp4", + ], + ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], + ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], + ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ])("offers a host download for %s", (href, path, mimeType) => { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipMenu(target).actions).toContainEqual({ + id: "save", + title: "Save or share", + }); + expect(fileChipShareSource(target, threadId)).toEqual({ + name: path.split("/").at(-1), + mimeType, + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("retains the thread context for a relative file without a known workspace root", () => { + expect( + fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId), + ).toMatchObject({ + resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" }, + }); + }); + + it("does not offer downloads the host asset endpoint cannot serve", () => { + for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipShareSource(target, threadId)).toBeNull(); + expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); + } + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 3630a62b3551..9f82e089444d 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,5 +1,8 @@ +import { fileBasename } from "@t3tools/client-runtime/markdown-links"; +import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, @@ -7,7 +10,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; export interface FileChipTarget { /** The host path, when the link is absolute or the workspace root is known. */ @@ -36,7 +39,28 @@ export function resolveFileChipTarget( }; } -/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +function fileChipMetadata(target: FileChipTarget) { + const path = target.fullPath ?? target.relativePath; + if (!path) return null; + const name = fileBasename(path); + const dot = name.lastIndexOf("."); + const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot)); + return mimeType ? { path, name, mimeType } : null; +} + +/** Use literal resolved paths so encoded filename characters are not decoded twice. */ +export function fileChipShareSource(target: FileChipTarget, threadId: ThreadId) { + const metadata = fileChipMetadata(target); + return metadata + ? { + name: metadata.name, + mimeType: metadata.mimeType, + resource: { _tag: "media-file" as const, threadId, path: metadata.path }, + } + : null; +} + +/** Saving is available for the media and documents the host asset endpoint can serve. */ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { return { title: target.fullPath ?? target.relativePath ?? "", @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), { id: "open-file", title: "Open in file viewer" }, + ...(fileChipMetadata(target) + ? [ + { + id: "save", + title: "Save or share", + }, + ] + : []), ], }; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index cac2686ab218..a429044fccdd 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -18,8 +18,12 @@ import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -const CONTROL_HEIGHT = 44; -const CONTROL_COMPOSER_GAP = 8; +const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem +// The collapsed composer capsule starts 6 below its overlay's top edge, so +// the pill sits at (gap - 6) above the overlay to leave the same gap to the +// capsule as the feed's end inset leaves between it and the last row. +const CONTROL_GAP = 8; +const COMPOSER_CAPSULE_INSET = 6; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); @@ -40,7 +44,8 @@ const UniwindGlassContainer = withUniwind(GlassContainer, { }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; +export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; /** * What the floating pill says. Syncing and working share one element so the @@ -81,7 +86,7 @@ export function FloatingWorkingControl(props: { diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..e76672de20f1 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6d87f284ebda..5df507ea671b 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -43,11 +43,14 @@ import { useEnvironmentQuery } from "../../state/query"; import { appendComposerDraftAttachments, clearComposerDraft, - copyComposerDraftContentIfEmpty, + composerDraftsAtom, + createNewTaskDraft, getComposerDraftSnapshot, isComposerDraftEmpty, + isNewTaskDraftKey, removeComposerDraftAttachment, replaceComposerDraftAttachments, + retargetNewTaskDraft, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, @@ -92,6 +95,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; type WorkspaceMode = "local" | "worktree"; @@ -168,6 +172,12 @@ type NewTaskFlowContextValue = { readonly filteredBranches: ReadonlyArray; readonly reset: () => void; readonly setProject: (project: EnvironmentProject) => void; + /** + * Binds the composer to an existing new-task draft (a row in the thread + * list). Returns false when the draft is gone, so the caller can fall back + * to a fresh one. + */ + readonly openDraft: (draftKey: string) => boolean; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: ( key: string | null, @@ -231,6 +241,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ? selectedEnvironmentIdOverride : (projects[0]?.environmentId ?? null); const [selectedProjectKey, setSelectedProjectKey] = useState(null); + // The new-task draft the composer is bound to. Null until a project is + // chosen; each New Task entry mints its own, so a project can hold several. + const [activeDraftKey, setActiveDraftKey] = useState(null); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); @@ -246,6 +259,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); + setActiveDraftKey(null); setSubmitting(false); setBranchQuery(""); setExpandedProvider(null); @@ -366,12 +380,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject?.environmentId ?? null, ); // While a queued pending task is being edited its draft lives under a key - // scoped to the queued message, so per-project new-task drafts stay intact. + // scoped to the queued message, so new-task drafts stay intact. const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject - ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` + ? activeDraftKey : null; + // selectedProject can resolve without setProject ever running (the + // environment's first project is the fallback, and the draft screen skips + // setProject when the route's project already matches it). The composer + // still needs a draft to write into, so bind one the moment a project is + // in view and nothing else owns the key. + useEffect(() => { + if (activeDraftKey !== null || editingPendingTask !== null || selectedProject === null) { + return; + } + setActiveDraftKey( + createNewTaskDraft({ + environmentId: selectedProject.environmentId, + projectId: selectedProject.id, + }), + ); + }, [activeDraftKey, editingPendingTask, selectedProject]); const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; @@ -426,7 +456,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProject?.defaultModelSelection ?? + selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? + null, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( @@ -622,51 +654,68 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback( + // The composer's draft follows the project it will be sent to: switching + // mid-compose keeps the same draft and moves it, so typed text follows the + // user. A pending-task edit owns its own key and is untouched here. + const carryDraftContentTo = useCallback( (project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - const nextDraftKey = `new-task:${nextProjectKey}`; - if ( - selectedProjectDraftKey?.startsWith("new-task:") && - selectedProjectDraftKey !== nextDraftKey - ) { - void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); + const target = { environmentId: project.environmentId, projectId: project.id }; + if (activeDraftKey !== null && isNewTaskDraftKey(activeDraftKey)) { + retargetNewTaskDraft(activeDraftKey, target); + } else if (!editingPendingTaskRef.current) { + setActiveDraftKey(createNewTaskDraft(target)); } + }, + [activeDraftKey], + ); + + const setProject = useCallback( + (project: EnvironmentProject) => { + carryDraftContentTo(project); setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); + setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); }, - [selectedProjectDraftKey], + [carryDraftContentTo], + ); + + const openDraft = useCallback( + (draftKey: string): boolean => { + const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; + const stamp = draft?.project; + if (!isNewTaskDraftKey(draftKey) || !stamp) { + return false; + } + // The stamped project must be loaded: selectedProject falls back to + // the environment's first project otherwise, and the draft would be + // sent somewhere the user never chose. + const projectLoaded = projects.some( + (project) => + project.environmentId === stamp.environmentId && project.id === stamp.projectId, + ); + if (!projectLoaded) { + return false; + } + setActiveDraftKey(draftKey); + setSelectedEnvironmentId(stamp.environmentId); + setSelectedProjectKey(scopedProjectKey(stamp.environmentId, stamp.projectId)); + return true; + }, + [projects], ); const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { - const projectsOnTarget = projects.filter( - (project) => project.environmentId === environmentId, + const match = resolveEnvironmentProjectMatch( + projects.filter((project) => project.environmentId === environmentId), + selectedProject, ); - const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; - // Prefer the repository identity; projects without one (e.g. not yet - // indexed) fall back to workspace basename, then title, so switching - // computers still follows the same repo instead of resetting to - // whatever project is first on the target machine. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const match = - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find((project) => project.title === selectedProject.title) - : undefined); + if (match) { + carryDraftContentTo(match); + } setSelectedEnvironmentId(environmentId); setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); }, - [projects, selectedProject], + [projects, selectedProject, carryDraftContentTo], ); const setWorkspaceMode = useCallback( @@ -1089,6 +1138,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { filteredBranches, reset, setProject, + openDraft, selectEnvironment, setSelectedModelKey, setWorkspaceMode, @@ -1152,6 +1202,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectKey, selectedWorktreePath, setProject, + openDraft, selectBranch, selectEnvironment, setInteractionMode, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 7068a95d558a..ca59a2b9dddc 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -4,18 +4,35 @@ import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { HomeProjectScope } from "../home/homeThreadList"; import { - getOnlySelectableProject, getProjectScopeSelectionTarget, resolveDraftProjectSelection, + resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; -function makeProject(id: string, environmentId = "environment"): EnvironmentProject { +function makeProject( + id: string, + environmentId = "environment", + options: { + readonly title?: string; + readonly workspaceRoot?: string; + readonly repositoryKey?: string; + } = {}, +): EnvironmentProject { return { environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), - title: id, - workspaceRoot: `/work/${id}`, - repositoryIdentity: null, + title: options.title ?? id, + workspaceRoot: options.workspaceRoot ?? `/work/${id}`, + repositoryIdentity: options.repositoryKey + ? { + canonicalKey: options.repositoryKey, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://${options.repositoryKey}.git`, + }, + } + : null, defaultModelSelection: null, scripts: [], createdAt: "2026-07-01T00:00:00.000Z", @@ -36,18 +53,6 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop }; } -describe("getOnlySelectableProject", () => { - it("auto-selects when there is exactly one physical project", () => { - const project = makeProject("t3code"); - expect(getOnlySelectableProject([makeScope([project])])).toBe(project); - }); - - it("selects the representative when one logical project has multiple workspaces", () => { - const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; - expect(getOnlySelectableProject([makeScope(projects)])).toBe(projects[0]); - }); -}); - describe("getProjectScopeSelectionTarget", () => { it("keeps the current environment when it hosts the selected logical project", () => { const projects = [makeProject("t3code-mac", "mac"), makeProject("t3code-server", "server")]; @@ -64,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("resolveEnvironmentProjectMatch", () => { + it("follows the same repository onto the target machine", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [ + makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }), + makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + }); + + it("falls back to workspace basename, then title, for unindexed projects", () => { + const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); + const byBasename = [ + makeProject("other", "server"), + makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + + const byTitle = [ + makeProject("other", "server"), + makeProject("srv", "server", { title: "t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + }); + + it("does not treat a known different repository as a basename or title match", () => { + const selected = makeProject("t3code", "mac", { + repositoryKey: "github.com/t3tools/t3code", + workspaceRoot: "/Users/me/t3code", + }); + const fork = makeProject("fork", "server", { + repositoryKey: "github.com/someone/t3code", + title: "t3code", + workspaceRoot: "/home/me/t3code", + }); + const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); + expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); + // Without any weaker match the fork is still the first-project fallback. + expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + }); + + it("falls back to the first project on the target so the draft has a key to carry over to", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); + }); +}); + describe("resolveDraftProjectSelection", () => { it("preserves an explicit project selection", () => { const project = makeProject("t3code"); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 7be899d62a1a..65dd9916f2f6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -19,13 +19,60 @@ export function getProjectScopeSelectionTarget( ); } -export function getOnlySelectableProject( +function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; return onlyScope?.representative ?? null; } +/** + * Picks the project on a target environment that corresponds to the project + * currently selected in the new-task flow, so switching computers follows the + * same repo. Repository identity is preferred; projects without one (e.g. not + * yet indexed) fall back to workspace basename, then title. When nothing + * matches, the first project on the target stands in — the same fallback the + * render path applies when no key is selected — so the draft always has a + * concrete key to carry over to. + */ +export function resolveEnvironmentProjectMatch( + projectsOnTarget: ReadonlyArray, + selectedProject: EnvironmentProject | null, +): EnvironmentProject | null { + const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + // `|| null` (not `??`): a pending-task placeholder project can have an empty + // workspaceRoot, and an "" basename would match nothing meaningful. + const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + // The weaker signals only apply where identity is unknown on at least one + // side; two known, different repositories never match on a shared basename + // or title (mirrors the environment list filter in the new-task flow). + const isKnownMismatch = (project: EnvironmentProject) => { + const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; + }; + return ( + (repositoryKey !== null + ? projectsOnTarget.find( + (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + ) + : undefined) ?? + (workspaceBasename !== null + ? projectsOnTarget.find( + (project) => + !isKnownMismatch(project) && + project.workspaceRoot.split("/").at(-1) === workspaceBasename, + ) + : undefined) ?? + (selectedProject !== null + ? projectsOnTarget.find( + (project) => !isKnownMismatch(project) && project.title === selectedProject.title, + ) + : undefined) ?? + projectsOnTarget[0] ?? + null + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fcba4626be2d..e50d8b8151e6 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -267,10 +267,17 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, +]; + /** - * A queued new task waiting in the outbox for its environment to reconnect. - * Tapping reopens the new-task composer with everything prefilled; the row - * disappears once the task is delivered and the real thread arrives. + * Unsent work: a task queued in the outbox for its environment to reconnect, + * or a draft still sitting in the project's new-task composer. Tapping + * reopens the composer with everything prefilled; the row disappears once + * the work is sent and the real thread arrives. The two kinds differ in what + * happens next, so the pill and icon say which one this is: a queued task + * sends itself, a draft waits for the user. */ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { readonly variant: ThreadListVariant; @@ -284,10 +291,15 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const compact = props.variant === "compact"; const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const timestamp = relativeTime(pendingTask.message.createdAt); - const subtitleParts = [props.environmentLabel, pendingTask.creation.branch].filter( - (part): part is string => Boolean(part), - ); + const isDraft = pendingTask.kind === "draft"; + const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); + // The pill only has room for one word, so what happens next goes in the + // subtitle: a queued task sends itself, a draft waits for the user. + const subtitleParts = [ + isDraft ? null : "Sends on reconnect", + props.environmentLabel, + pendingTask.branch, + ].filter((part): part is string => Boolean(part)); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -296,9 +308,13 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { [onDeletePendingTask, pendingTask], ); - const statusPill = ( - - Pending + const statusPill = isDraft ? ( + + Draft + + ) : ( + + Pending ); @@ -306,7 +322,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { subtitleParts.length > 0 ? ( ) : null; + const accessibilityHint = isDraft + ? "Opens the draft in the new task composer" + : "Sends when the environment reconnects. Opens the task for editing"; + const rowContent = compact ? ( {statusPill} - {timestamp} + {timestamp !== null ? ( + {timestamp} + ) : null} ) : ( {statusPill} - - {timestamp} - + {timestamp !== null ? ( + + {timestamp} + + ) : null} {subtitleRow} @@ -395,7 +419,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { return ( @@ -416,7 +440,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; - readonly projectCwd: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly isLast: boolean; @@ -453,7 +476,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; const status = resolveThreadStatus(thread); - const pr = useThreadPr(thread, props.projectCwd); + const pr = useThreadPr(thread); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 97c13de56aab..1cd723126c39 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,7 +23,6 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, @@ -53,10 +52,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-adaptive-red-700-300" }, + approval: { label: "Approval", className: "text-warning-foreground" }, + input: { label: "Input", className: "text-foreground-secondary" }, + working: { label: "Working", className: "text-foreground-secondary" }, + failed: { label: "Failed", className: "text-danger-foreground" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -107,9 +106,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); -const SNOOZE_ACCENT_LIGHT = "#2563eb"; -const SNOOZE_ACCENT_DARK = "#60a5fa"; - export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; @@ -117,7 +113,6 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { - const { themeAppearance: colorScheme } = useAppearancePreferences(); return ( ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + @@ -191,12 +186,16 @@ const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; +const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, +]; + /** - * A queued new task, in the same idiom as an active v2 row: it is work the - * user wrote, so it reads like the threads it will become. "Queued" takes - * the status slot — the state is the one thing that differs — and stays - * uncolored because nothing is asked of the user; the environment is simply - * not reachable yet. + * Unsent work, in the same idiom as an active v2 row: it is work the user + * wrote, so it reads like the thread it will become. The status slot says + * what happens next, not where the item sits: "Sends on reconnect" stays + * uncolored because nothing is asked of the user; "Draft" takes the amber the + * web sidebar uses for drafts, because this one waits on the user. */ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { readonly pendingTask: PendingNewTask; @@ -206,7 +205,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props /** Drawn beside the label; ignored while the label is null. */ readonly environmentMachine?: EnvironmentMachineKind; readonly pane?: "screen" | "sidebar"; - /** Draws the "Pending" divider above the first queued row. */ + /** Draws the "Unsent" divider above the first draft or queued row. */ readonly showPendingDivider: boolean; /** Keeps row hairlines inside a section; section headers draw their own rule. */ readonly showTrailingDivider?: boolean; @@ -215,9 +214,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props }) { const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; const sidebarPane = props.pane === "sidebar"; - const projectTitle = - props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; - const branch = pendingTask.creation.branch; + const isDraft = pendingTask.kind === "draft"; + const projectTitle = props.projectTitle ?? props.project?.title ?? pendingTask.projectTitle ?? ""; + const branch = pendingTask.branch; const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -231,7 +230,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props {props.project ? ( {projectTitle} - Queued + {isDraft ? ( + + + Draft + + ) : ( + Sends on reconnect + )} {/* One line, unlike the two an active row allows: a queued title is derived from the whole prompt rather than written as a title, so the @@ -277,15 +288,19 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props return ( <> {props.showPendingDivider ? ( - + ) : null} void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; - readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; @@ -372,17 +387,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; - /** False on servers that predate thread.pin.reorder. Gates the pinned - Move up / Move down menu items. */ - readonly pinReorderSupported?: boolean; - readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; - /** Position flags for the pinned block so the menu disables the move that + /** Server supports reordering this card's section. */ + readonly reorderSupported?: boolean; + readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ - readonly canMovePinnedUp?: boolean; - readonly canMovePinnedDown?: boolean; + readonly canMoveUp?: boolean; + readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -403,12 +416,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, - onMovePinnedThread, + onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; - const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const pr = useThreadPr(thread); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; @@ -442,14 +455,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); - const handleMovePinnedUp = useCallback( - () => onMovePinnedThread?.(thread, "up"), - [onMovePinnedThread, thread], - ); - const handleMovePinnedDown = useCallback( - () => onMovePinnedThread?.(thread, "down"), - [onMovePinnedThread, thread], - ); + const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); + const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Un-settling a @@ -488,38 +495,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // Pinned cards keep the full lifecycle menu; only the pin item flips to // Unpin. (Settling a pinned thread clears the pin server-side; snoozing // hides the card until wake with the pin intact.) - const pinMenuItem = useMemo( - () => - props.pinningSupported + const arrangementMenuItems = useMemo( + () => [ + ...(variant === "card" && props.reorderSupported === true + ? [ + { + id: "move-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMoveUp !== true }, + } satisfies MenuAction, + { + id: "move-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMoveDown !== true }, + } satisfies MenuAction, + ] + : []), + ...(props.pinningSupported ? [ - ...(pinnedRow && props.pinReorderSupported === true - ? [ - { - id: "move-pin-up", - title: "Move up", - image: "arrow.up", - attributes: { disabled: props.canMovePinnedUp !== true }, - } satisfies MenuAction, - { - id: "move-pin-down", - title: "Move down", - image: "arrow.down", - attributes: { disabled: props.canMovePinnedDown !== true }, - } satisfies MenuAction, - ] - : []), thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] - : [], + : []), + ], [ - pinnedRow, - props.canMovePinnedDown, - props.canMovePinnedUp, - props.pinReorderSupported, + props.canMoveDown, + props.canMoveUp, + props.reorderSupported, props.pinningSupported, thread.pinnedAt, + variant, ], ); const titleRegenerationMenuItems = useMemo( @@ -539,37 +547,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { image: "clock", subactions: snoozePresetActions, }, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [pinMenuItem, titleRegenerationMenuItems], + [arrangementMenuItems, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, - ...(thread.pinnedAt != null ? pinMenuItem : []), + ...(thread.pinnedAt != null ? arrangementMenuItems : []), ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], + [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( - () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + LEGACY_MENU_ACTIONS[0]!, + ...arrangementMenuItems, + ...titleRegenerationMenuItems, + LEGACY_MENU_ACTIONS[1]!, + ], + [arrangementMenuItems, titleRegenerationMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -578,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); - if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); - if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); + if (nativeEvent.event === "move-up") handleMoveUp(); + if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); @@ -598,8 +611,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, - handleMovePinnedDown, - handleMovePinnedUp, + handleMoveDown, + handleMoveUp, handlePin, handleSettle, handleSnooze, @@ -640,6 +653,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { accessibilityLabel: `Settle ${thread.title}`, icon: "checkmark" as const, label: "Settle", + dismissOnPress: true as const, onPress: handleSettle, }; }, [ @@ -737,7 +751,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -919,7 +933,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-adaptive-blue-600-400" + ? "text-foreground-secondary" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} @@ -950,7 +964,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}`} + resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}`} simultaneousWithExternalGesture={props.simultaneousSwipeGesture} threadTitle={thread.title} > diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 48aaf80249d5..9c478f3c4504 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,7 +65,7 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-adaptive-blue-500-400" + ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", )} > diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a2e19b9a8c4f..e812e77ef088 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { + type AgentSpawnSummary, + type ThreadFeedActivity, + workEntryRowLabel, +} from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { }) { return ( - - {props.showIcon && props.toolIcon && props.environmentId ? ( - - ) : props.showIcon ? ( - - ) : null} - + {props.showIcon ? ( + + {props.toolIcon && props.environmentId ? ( + + ) : ( + + )} + + ) : null} { const subscription = AppState.addEventListener("change", (state) => { @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > 0 ? cleaned : null; -} - function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": @@ -400,6 +400,8 @@ interface ThreadWorkLogProps { readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; + /** Feed background, painted as the scroll-edge fade over a long group. */ + readonly edgeFadeColor: string; readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; @@ -445,6 +447,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { {props.activities[0]?.groupedToolDetail ? ( ; + readonly edgeFadeColor: string; readonly expandedRows: Readonly>; readonly groupId: string; readonly rowSizing: ReturnType; @@ -500,21 +504,17 @@ function ThreadWorkGroupList(props: { const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); - const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; - const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; - // Opaque covers remove each edge fade at the scroll boundary. Scroll offset - // stays on the UI thread; only content-size changes update React state. - const topCoverStyle = useAnimatedStyle(() => ({ - opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), + // Each edge fades only while content continues past it. Scroll offset stays + // on the UI thread; only content-size changes update React state. + const topFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), })); - const bottomCoverStyle = useAnimatedStyle(() => ({ - opacity: - 1 - - Math.min( - 1, - Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, - ), + const bottomFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, + ), })); const rememberPosition = useCallback(() => { if (!loadedRef.current) return; @@ -540,7 +540,7 @@ function ThreadWorkGroupList(props: { } }, []); const onContentSizeChange = useCallback( - (_width: number, nextHeight: number) => { + (nextHeight: number) => { const previous = previousContent.current; const detailsChanged = previous.expandedRows !== props.expandedRows; const followAppend = @@ -580,6 +580,24 @@ function ThreadWorkGroupList(props: { }, [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], ); + // The native ScrollView reports its content size a frame or more after + // LegendList has laid the rows out, so a detail toggle rendered the group + // at its old height while the rows below already moved. Read the size + // LegendList computes on the JS thread instead; it settles in the same + // commit as the row measurement that changed it. + const onContentSizeChangeRef = useRef(onContentSizeChange); + useLayoutEffect(() => { + onContentSizeChangeRef.current = onContentSizeChange; + }, [onContentSizeChange]); + const subscribeToContentSize = useCallback((list: LegendListRef | null) => { + listRef.current = list; + if (!list) return; + const unsubscribe = list.getState().listen("totalSize", () => { + onContentSizeChangeRef.current(list.getState().contentLength); + }); + onContentSizeChangeRef.current(list.getState().contentLength); + return unsubscribe; + }, []); const getFixedItemSize = useCallback( (row: ThreadFeedActivity, index: number) => props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined @@ -597,34 +615,9 @@ function ThreadWorkGroupList(props: { ); return ( - - - - - - - - - - - - - - - - } - > + { loadedRef.current = true; @@ -669,12 +661,47 @@ function ThreadWorkGroupList(props: { scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" - // MaskedView bridges through a native host whose absolute-fill bounds - // can lag behind a resize. Keep the list's viewport at the group's - // current height when expanding details or appending calls. - style={[StyleSheet.absoluteFill, { height }]} + style={{ height }} /> - + + + + + + + + ); +} + +/** A screen-colored gradient painted over the list edge that still has content past it. */ +function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { + const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; + return ( + + + + + + + + + ); } @@ -685,7 +712,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, - "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" + | "activities" + | "copiedRowId" + | "edgeFadeColor" + | "expandedRows" + | "rowSizing" + | "scrollPositions" > & { readonly row: ThreadFeedActivity; readonly copied: boolean; @@ -697,8 +729,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = - toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; + const previewText = workEntryRowLabel(row.workEntry); const displayText = !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; @@ -922,6 +953,162 @@ export function ThreadWorkGroupToggle(props: { ); } +const AGENT_SPAWN_TONE_DOT_CLASS = { + working: "bg-adaptive-sky-600-400", + completed: "bg-adaptive-emerald-600-400", + failed: "bg-adaptive-rose-600-400", + stopped: "bg-foreground-muted", +} as const satisfies Record; + +/** + * A batch of spawned subagents. The status line updates in place as members + * report progress; expanding lists each member. Text nodes carry keys tied to + * the row identity only, so a progress tick re-renders the labels without + * remounting the card (see the batch key in appendActivityGroupRows). + */ +export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { + readonly summary: AgentSpawnSummary; + readonly expanded: boolean; + readonly iconSubtleColor: ColorValue; + readonly rowSizing: ReturnType; + readonly onToggle: () => void; + readonly onCopy: () => void; +}) { + const { summary, expanded } = props; + const working = summary.tone === "working"; + const memberCount = summary.members.length; + const canExpand = memberCount > 0; + return ( + + { + if (!canExpand) return; + void Haptics.selectionAsync(); + props.onToggle(); + }} + onLongPress={props.onCopy} + className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" + > + + + + + + + {summary.title} + + + + {working ? ( + + ) : ( + + {summary.status} + + )} + + + {canExpand ? ( + + ) : null} + + {expanded && canExpand ? ( + + {summary.members.map((member) => ( + + + + + {member.title} + + {member.status} + + {member.detail ? ( + + {member.detail} + + ) : null} + + ))} + + ) : null} + + + ); +}); + +export function ThreadThinkingRow(props: { + readonly rowSizing: ReturnType; + readonly iconSubtleColor: ColorValue; +}) { + return ( + + + + ); +} + function ToolActivityIconView(props: { readonly environmentId: EnvironmentId; readonly icon?: ToolActivityIcon; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 33ae27cc0638..642ee7356cac 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,3 +1,10 @@ +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import { + createPendingThreadOrder, + createThreadMovePlanner, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; @@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + getThreadListV2OrderedSection, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => { }); describe("sortThreadsForListV2", () => { + it("honors a saved active order and leaves new threads above it", () => { + const sorted = sortThreadsForListV2([ + { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" }, + { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" }, + { id: "new", createdAt: "2026-06-01T13:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); + }); + it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, @@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => { }); }); +describe("getThreadListV2OrderedSection", () => { + it("uses each saved order and excludes settled, snoozed, and archived rows", () => { + const threads = [ + makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }), + makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }), + makeThread({ id: ThreadId.make("active-new"), title: "New" }), + makeThread({ + id: ThreadId.make("pinned-later"), + title: "Pinned later", + pinnedAt: NOW, + pinOrderKey: "t", + activeOrderKey: "f", + }), + makeThread({ + id: ThreadId.make("pinned-first"), + title: "Pinned first", + pinnedAt: NOW, + pinOrderKey: "f", + activeOrderKey: "t", + }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + makeThread({ + id: ThreadId.make("pinned-snoozed"), + title: "Pinned snoozed", + pinnedAt: NOW, + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + ]; + expect( + getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["active-new", "active-first", "active-later"]); + expect( + getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["pinned-first", "pinned-later"]); + }); +}); + describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ @@ -800,7 +866,22 @@ describe("buildThreadListV2Items settled paging", () => { }); function makePendingTask(id: string): PendingNewTask { + const creation = { + projectId: ProjectId.make("project-1"), + workspaceMode: "worktree" as const, + branch: null, + worktreePath: null, + }; return { + kind: "pending", + key: `pending-task:${id}`, + environmentId, + projectId: creation.projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: null, + title: id, + createdAt: NOW, message: { environmentId, threadId: ThreadId.make(`thread-${id}`), @@ -809,20 +890,9 @@ function makePendingTask(id: string): PendingNewTask { text: id, attachments: [], createdAt: NOW, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, - }, - }, - creation: { - projectId: ProjectId.make("project-1"), - workspaceMode: "worktree", - branch: null, - worktreePath: null, + creation, }, - title: id, + creation, }; } @@ -937,3 +1007,230 @@ describe("buildThreadListV2ListItems", () => { ]); }); }); + +describe("pending mobile thread moves", () => { + function fixture(section: "active" | "pinned" = "active") { + const rows = ["a", "b", "c"].map((id, index) => + makeThread({ + id: ThreadId.make(id), + title: id === "a" ? "hidden" : "match", + createdAt: `2026-06-01T0${3 - index}:00:00.000Z`, + pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null, + }), + ); + const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW }); + const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`); + const movedId = orderedIds[2]!; + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map(orderedIds.map((id) => [id, null])), + movedId, + direction: "up", + })!; + const pending = createPendingThreadOrder({ + section, + ordered, + movedId, + direction: "up", + assignments, + }); + const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) => + current.map((row) => + `${row.environmentId}:${row.id}` === assignment.id + ? { + ...row, + [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey, + } + : row, + ); + return { rows, assignments, pending, update }; + } + + function layout( + rows: EnvironmentThreadShell[], + pendingOrder: PendingThreadOrder | null, + searchQuery = "", + ) { + return buildThreadListV2Items({ + threads: rows, + pendingOrder, + environmentId: null, + searchQuery, + now: NOW, + }).items.map((item) => item.thread.id); + } + + it.each(["active", "pinned"] as const)( + "holds %s order through every intermediate key upsert", + (section) => { + const { rows, assignments, pending, update } = fixture(section); + let current = rows; + let hold: PendingThreadOrder | null = pending; + const desired = pending.orderedIds.map((id) => id.split(":")[1]); + expect(layout(current, hold)).toEqual(desired); + for (const assignment of assignments) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder( + hold!, + getThreadListV2OrderedSection({ threads: current, section, now: NOW }), + ); + expect(hold).not.toBeNull(); + expect(layout(current, hold)).toEqual(desired); + } + expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull(); + expect(layout(current, null)).toEqual(desired); + }, + ); + + it("keeps the action guard pending when receipts precede canonical shells", () => { + const { rows, assignments, pending, update } = fixture(); + let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true }; + let current = rows; + expect(reconcilePendingThreadOrder(hold, current)).toBe(hold); + for (const [index, assignment] of assignments.entries()) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder(hold!, current); + expect(hold === null).toBe(index === assignments.length - 1); + expect(layout(current, hold)).toEqual(["a", "c", "b"]); + } + }); + + it("keeps search results in the full pending section order", () => { + const { rows, assignments, pending, update } = fixture(); + const current = update(update(rows, assignments[0]!), assignments[1]!); + expect(layout(current, pending, "match")).toEqual(["c", "b"]); + }); + + it("releases for real section membership and foreign key changes", () => { + const { rows, pending } = fixture(); + expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull(); + const newRow = makeThread({ id: ThreadId.make("new"), title: "new" }); + expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)), + ), + ).toBeNull(); + const settled = rows.map((row, index) => + index === 0 ? { ...row, settledOverride: "settled" as const } : row, + ); + expect(layout(settled, pending)).toEqual(layout(settled, null)); + }); + + it("does not hide a concurrent return to a previously confirmed key", () => { + const { rows, assignments, pending, update } = fixture(); + const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!; + expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull(); + }); + + it("preserves the hold for activity but releases for a reopened sort anchor", () => { + const { rows, pending } = fixture(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row) => ({ ...row, updatedAt: NOW })), + ), + ).toBe(pending); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)), + ), + ).toBeNull(); + }); +}); + +describe("mobile move availability", () => { + const oldEnvironment = EnvironmentId.make("older-server"); + function rows(section: "active" | "pinned", keys: readonly (string | null)[]) { + return keys.map((key, index) => + makeThread({ + id: ThreadId.make(`move-${index}`), + title: `Move ${index}`, + environmentId: index === 1 ? oldEnvironment : environmentId, + activeOrderKey: section === "active" ? key : null, + pinOrderKey: section === "pinned" ? key : null, + pinnedAt: section === "pinned" ? NOW : null, + }), + ); + } + + it.each(["active", "pinned"] as const)( + "keeps unsupported keyed %s neighbors as usable anchors", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-0`, "down"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-0`); + expect(assignments![0]!.orderKey > "dd").toBe(true); + expect(assignments![0]!.orderKey < "ff").toBe(true); + expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull(); + expect(plan(`${environmentId}:move-0`, "up")).toBeNull(); + }, + ); + + it.each(["active", "pinned"] as const)( + "disables %s moves requiring unsupported keyless materialization", + (section) => { + const ordered = rows(section, [null, null, null]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + expect(plan(`${environmentId}:move-0`, "down")).toBeNull(); + expect(plan(`${environmentId}:move-2`, "up")).toBeNull(); + const supported = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]), + }); + expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3); + }, + ); + + it.each(["active", "pinned"] as const)( + "reserves snoozed %s keys when moving visible rows", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) }; + const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]! + .orderKey; + const hidden = { + ...ordered[0]!, + id: ThreadId.make("snoozed"), + snoozedAt: NOW, + snoozedUntil: "2099-01-01T00:00:00.000Z", + pinOrderKey: section === "pinned" ? collision : null, + activeOrderKey: section === "active" ? collision : null, + }; + const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })( + `${environmentId}:move-0`, + "down", + ); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.orderKey).not.toBe(collision); + expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true); + }, + ); + + it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => { + const ordered = rows("active", [null, null, "bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section: "active", + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-4`, "up"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-4`); + expect(assignments![0]!.orderKey > "bb").toBe(true); + expect(assignments![0]!.orderKey < "dd").toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2b44851f9309..a4a977c9df67 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { - activeThreadAnchorTimestampMs, + sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; @@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + applyPendingThreadOrder, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; + export { snoozeWakeLabel }; /** @@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** - * v2 sort: static order, newest anchor on top. Activity NEVER reorders the - * list — a row holds its position between lifecycle transitions. The anchor - * is creation time until an un-settle re-anchors it (see - * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the - * top instead of sinking back to its creation-order slot. Mirrors web's - * sortThreadsForSidebar. - */ +/** The active order shared by web and native: new/reopened rows, then the + saved arrangement. Activity does not move a thread. */ export function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { - // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 - // change-by-copy array methods. - return [...threads].sort( - (left, right) => - activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || - left.id.localeCompare(right.id), - ); + return sortActiveThreadsByOrderKey(threads); +} + +/** Canonical card section for Move up/down, independent of search or scope. */ +export function getThreadListV2OrderedSection(input: { + readonly threads: readonly EnvironmentThreadShell[]; + readonly section: "pinned" | "active"; + readonly pendingOrder?: PendingThreadOrder | null; + readonly now: string; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly snoozeEnvironmentIds?: ReadonlySet; +}): EnvironmentThreadShell[] { + const threads = input.threads.filter((thread) => { + if (thread.archivedAt !== null) return false; + if ( + (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && + thread.settledOverride === "settled" + ) { + return false; + } + if ( + (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) && + effectiveSnoozed(thread, { now: input.now }) + ) { + return false; + } + return (thread.pinnedAt != null) === (input.section === "pinned"); + }); + const ordered = + input.section === "pinned" + ? sortPinnedThreadsByOrderKey(threads) + : sortActiveThreadsByOrderKey(threads); + const pending = + input.pendingOrder?.section === input.section + ? reconcilePendingThreadOrder(input.pendingOrder, ordered) + : null; + return applyPendingThreadOrder(ordered, input.section, pending); } export interface ThreadListV2Item { @@ -266,7 +298,7 @@ export function buildThreadListV2ListItems(input: { })); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", - key: `v2-pending:${pendingTask.message.messageId}`, + key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, })); @@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: { } /** - * Partitions visible threads into the active card block (creation order) and + * Partitions visible threads into the active card block (saved order) and * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { + readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ @@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: { readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { const now = input.now; + const pending = + input.pendingOrder == null + ? null + : reconcilePendingThreadOrder( + input.pendingOrder, + getThreadListV2OrderedSection({ + ...input, + section: input.pendingOrder.section, + pendingOrder: null, + }), + ); const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: { } } - const orderedActive = sortThreadsForListV2(active); + const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), @@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortPinnedThreadsByOrderKey(pinned)) { + for (const thread of applyPendingThreadOrder( + sortPinnedThreadsByOrderKey(pinned), + "pinned", + pending, + )) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts new file mode 100644 index 000000000000..2b722e69428b --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -0,0 +1,120 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +type OrderRow = Pick< + EnvironmentThreadShell, + | "id" + | "environmentId" + | "pinOrderKey" + | "activeOrderKey" + | "createdAt" + | "unsettledAt" + | "pinnedAt" +>; + +export interface PendingThreadOrder { + readonly section: "pinned" | "active"; + readonly orderedIds: readonly string[]; + readonly before: ReadonlyMap; + readonly assignments: ReadonlyMap; + readonly confirmed: ReadonlySet; + readonly commandsComplete: boolean; +} + +function rowId(row: OrderRow): string { + return `${row.environmentId}:${row.id}`; +} + +function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) { + return { + key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null, + anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt), + }; +} + +/** Keep every visible row as an anchor, but only offer plans whose key writes + * are supported. Menu availability and execution use this same planner. */ +export function createThreadMovePlanner(input: { + readonly ordered: readonly OrderRow[]; + readonly allThreads?: readonly OrderRow[]; + readonly section: PendingThreadOrder["section"]; + readonly reorderableEnvironmentIds: ReadonlySet; +}) { + const orderedIds = input.ordered.map(rowId); + const keysById = new Map( + (input.allThreads ?? input.ordered).map((row) => [ + rowId(row), + rowOrder(row, input.section).key, + ]), + ); + const writableIds = new Set( + input.ordered + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + return (movedId: string, direction: "up" | "down") => { + if (!writableIds.has(movedId)) return null; + const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction }); + return assignments === null || + assignments.length === 0 || + assignments.some((assignment) => !writableIds.has(assignment.id)) + ? null + : assignments; + }; +} + +export function createPendingThreadOrder(input: { + readonly section: PendingThreadOrder["section"]; + readonly ordered: readonly OrderRow[]; + readonly movedId: string; + readonly direction: "up" | "down"; + readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; +}): PendingThreadOrder { + const orderedIds = input.ordered.map(rowId); + const from = orderedIds.indexOf(input.movedId); + orderedIds.splice(from, 1); + orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId); + return { + section: input.section, + orderedIds, + before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])), + assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])), + confirmed: new Set(), + commandsComplete: false, + }; +} + +/** Receipts and shell updates arrive independently. Only our own key writes + * may pass through the hold; membership and other arrangement changes win. */ +export function reconcilePendingThreadOrder( + pending: PendingThreadOrder, + ordered: readonly OrderRow[], +): PendingThreadOrder | null { + if (ordered.length !== pending.before.size) return null; + const confirmed = new Set(pending.confirmed); + for (const row of ordered) { + const id = rowId(row); + const before = pending.before.get(id); + const current = rowOrder(row, pending.section); + if (before === undefined || current.anchor !== before.anchor) return null; + const assigned = pending.assignments.get(id); + if (assigned !== undefined && current.key === assigned) confirmed.add(id); + else if (current.key !== before.key || confirmed.has(id)) return null; + } + if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null; + return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed }; +} + +/** Apply the full section's pending order after search/environment filtering. */ +export function applyPendingThreadOrder( + rows: readonly T[], + section: PendingThreadOrder["section"], + pending: PendingThreadOrder | null | undefined, +): T[] { + if (pending == null || pending.section !== section) return [...rows]; + const rank = new Map(pending.orderedIds.map((id, index) => [id, index])); + return [...rows].sort( + (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), + ); +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 59cf108a01dd..bfc72d2ac9b8 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -42,8 +42,8 @@ export function resolveThreadStatus( return { kind: "pending-approval", label: "Needs Approval", - pillClassName: "bg-adaptive-amber-500-a12-a16", - textClassName: "text-adaptive-amber-700-300", + pillClassName: "bg-warning", + textClassName: "text-warning-foreground", iconColor: "#ff9f0a", iconBackground: "rgba(255,159,10,0.22)", pulse: false, @@ -54,8 +54,8 @@ export function resolveThreadStatus( return { kind: "awaiting-input", label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#5e5ce6", iconBackground: "rgba(94,92,230,0.22)", pulse: false, @@ -66,8 +66,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -78,8 +78,8 @@ export function resolveThreadStatus( return { kind: "connecting", label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -90,8 +90,8 @@ export function resolveThreadStatus( return { kind: "error", label: "Error", - pillClassName: "bg-adaptive-rose-500-a12-a16", - textClassName: "text-adaptive-rose-700-300", + pillClassName: "bg-danger", + textClassName: "text-danger-foreground", iconColor: "#ff453a", iconBackground: "rgba(255,69,58,0.22)", pulse: false, @@ -106,8 +106,8 @@ export function resolveThreadStatus( return { kind: "plan-ready", label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#bf5af2", iconBackground: "rgba(191,90,242,0.22)", pulse: false, diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 5ae248b6cde8..4c92325f5fbd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -13,16 +13,9 @@ vi.mock("../../state/use-atom-command", () => ({ import { buildComposerSlashCommandItems, - composerSelectionAtEnd, resolveComposerCommandSelection, } from "./use-composer-command-menu"; -describe("composerSelectionAtEnd", () => { - it("resets a changed draft owner to the new draft end", () => { - expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); - }); -}); - describe("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 5b5b444cca74..23c56d28f4c4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,4 +1,5 @@ import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { detectComposerTrigger, replaceTextRange, @@ -27,7 +28,7 @@ import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { +function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } @@ -36,6 +37,8 @@ export function buildComposerSlashCommandItems(input: { readonly atMessageStart: boolean; readonly hasThread: boolean; readonly hasCompactableConversation?: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly allowInteractionMode: boolean; readonly selectedProviderStatus: Pick< ServerProvider, @@ -78,6 +81,11 @@ export function buildComposerSlashCommandItems(input: { for (const command of input.selectedProviderStatus?.slashCommands ?? []) { if (!command.name.toLowerCase().includes(query)) continue; if (command.name === "compact" && !input.hasCompactableConversation) continue; + // T3's own limits command is answered by the thread composer; New Task has + // nowhere to show it. A provider's same-named command is left alone. + if (command.name === USAGE_LIMITS_COMMAND.name && input.offersUsageLimits && !input.hasThread) { + continue; + } if ( !input.hasThread && input.selectedProviderStatus?.driver === "codex" && @@ -143,9 +151,11 @@ export function useComposerCommandMenu({ selectedProviderStatus, hasThread, hasCompactableConversation, + offersUsageLimits = false, enabled = true, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -154,9 +164,13 @@ export function useComposerCommandMenu({ readonly selectedProviderStatus: ServerProvider | null; readonly hasThread: boolean; readonly hasCompactableConversation: boolean; + /** Whether T3 itself offers /usage-limits for the selected provider. */ + readonly offersUsageLimits?: boolean; readonly enabled?: boolean; readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; + /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ + readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ atMessageStart: trigger.rangeStart === 0, hasThread, hasCompactableConversation, + offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ selectedProviderStatus, skills, trigger, + offersUsageLimits, ]); const onSelect = useCallback( (item: ComposerCommandItem) => { if (!trigger) return; + if ( + item.type === "provider-slash-command" && + item.command.name === USAGE_LIMITS_COMMAND.name && + onUsageLimits + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onUsageLimits(); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ draftMessage, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/mobile/src/features/threads/useFileChipShare.ts b/apps/mobile/src/features/threads/useFileChipShare.ts new file mode 100644 index 000000000000..587aa8ad9572 --- /dev/null +++ b/apps/mobile/src/features/threads/useFileChipShare.ts @@ -0,0 +1,70 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { Alert } from "react-native"; + +import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu"; + +/** Fetches host files through the selected environment before opening the native save/share sheet. */ +export function useFileChipShare( + environmentId: EnvironmentId, + threadId: ThreadId, + sourceIdentifier: string, +) { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + const connectionRef = useRef(httpBaseUrl); + useLayoutEffect(() => { + connectionRef.current = httpBaseUrl; + }, [httpBaseUrl]); + const requestRef = useRef(null); + useEffect(() => () => requestRef.current?.abort(), []); + + const share = useCallback( + (target: FileChipTarget) => { + const source = fileChipShareSource(target, threadId); + if (!source || requestRef.current) return; + const request = new AbortController(); + requestRef.current = request; + const httpBaseUrl = connectionRef.current; + void (async () => { + if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); + const result = await createUrl({ environmentId, input: { resource: source.resource } }); + if (request.signal.aborted) return; + const url = + result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; + if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); + await downloadAndShareAttachment({ + url, + attachment: source, + signal: request.signal, + sourceIdentifier, + }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (requestRef.current === request) { + requestRef.current = null; + } + }); + }, + [createUrl, environmentId, sourceIdentifier, threadId], + ); + return share; +} diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..61828ad90ee0 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -0,0 +1,374 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + remainingPercent, + type LimitAccount, + type LimitPoolWindow, +} from "@t3tools/shared/usageLimits"; +import { useId, useState } from "react"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { environmentPresentations } from "../../state/presentation"; +import { ResetCredits } from "./UsageLimitsSection"; +import { useProviderColors } from "./usageProviders"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; +const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; + +function accountName(account: LimitAccount) { + if (account.displayName) return account.displayName; + if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); + const [local = "", domain = ""] = account.email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; +} + +/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */ +function AccountSegment({ + remaining, + color, + pending, +}: { + readonly remaining: number; + readonly color: string; + readonly pending: boolean; +}) { + const patternId = useId().replace(/:/g, ""); + return ( + + + + + + + {pending ? ( + + ) : null} + + + ); +} + +function PoolWindowCard({ + pool, + color, + now, + environmentIds, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; + readonly environmentIds: readonly string[] | null; +}) { + const navigation = useNavigation(); + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + const openAccount = (account: LimitAccount) => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsUsageAccount", + params: { + accountKey: account.key, + windowId: pool.id, + windowKind: pool.kind, + environmentIds, + now, + }, + }, + }); + return ( + + + + {pool.label} + + + {pool.remainingPercent}% + + left + + + {pool.pace ? ( + {PACE_LABEL[pool.pace]} + ) : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} + + {pool.members.map(({ account, window }, index) => ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ))} + + + {pool.members.map(({ account, window }, index) => { + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + const resetsIn = formatResetsIn(window, now); + return ( + openAccount(account)} + className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" + > + + + {index + 1} + + + + {accountName(account)} + + + {remainingPercent(window)}% + + + {resetsIn ? ( + + {resetsIn.replace("resets in ", "↻ ")} + + ) : null} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + ) : null} + + + ); + })} + + + ); +} + +export function UsageLimitsSection({ + now, + failedLabels, + selectedEnvironmentIds, +}: { + readonly now: number; + readonly failedLabels: readonly string[]; + readonly selectedEnvironmentIds: ReadonlySet | null; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const selected = + selectedEnvironmentIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); + const pools = collectLimitPools(collectLimitAccounts(selected), now); + const notices = collectLimitNotices(selected); + const colors = useProviderColors(); + return ( + + {failedLabels.length ? ( + + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + ) : null} + {pools.length === 0 ? ( + + {selected.size === 0 + ? "Select an environment to see limits." + : "No provider on the selected environments reports subscription limits."} + + ) : null} + {pools.map((pool) => ( + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {pool.windows.map((window) => ( + + ))} + + ))} + {notices.map((notice) => ( + + {notice} + + ))} + + ); +} + +type AccountScreenProps = StaticScreenProps<{ + accountKey: string; + windowId: string; + windowKind: LimitPoolWindow["kind"]; + environmentIds: readonly string[] | null; + now: number; +}>; + +/** Resolve the account again so live quota and credit updates reach the open detail screen. */ +export function UsageLimitAccountScreen({ route }: AccountScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const { accountKey, windowId, windowKind, environmentIds, now } = route.params; + const selectedIds = + environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id))); + const selected = + selectedIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedIds.has(id))); + const accounts = collectLimitAccounts(selected); + const account = accounts.find((candidate) => candidate.key === accountKey); + const pool = collectLimitPools(accounts, now) + .find((candidate) => candidate.driver === account?.driver) + ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); + const window = pool?.members.find((member) => member.account.key === accountKey)?.window; + const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); + const [revealed, setRevealed] = useState(false); + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + {!account || !window ? ( + + This account is no longer reporting limits on the selected environments. + + ) : ( + <> + + + + + {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} + + + {account.email ? ( + setRevealed((value) => !value)} + className="min-h-[44px] justify-center" + > + + {revealed ? account.email : "••••••@••••••"} + + + ) : null} + {account.plan ? ( + + {account.plan} + + ) : null} + + + {window.label} + + {remainingPercent(window)}% left + + {window.resetsAt ? ( + + Resets{" "} + {new Date(window.resetsAt).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + + Restores {reset.restoresPercent}% of the pool + + ) : null} + + + + {account.environments.length ? "Signed in" : "Source"} + + {account.environments.length ? ( + account.environments.map((environment) => ( + + {environment.label} + + )) + ) : ( + {account.sourceLabel} + )} + + {account.redeem && account.limits.resetCredits ? ( + + Reset credits + + + ) : null} + + )} + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..a923e903ef18 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -6,18 +6,15 @@ import type { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, UsageProviderKind, } from "@t3tools/contracts"; import { - collectLimitSources, - collectLimitsGroups, elapsedShare, formatDuration, formatResetsIn, limitsNotice, paceOf, - providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -27,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { SettingsSection } from "../settings/components/SettingsSection"; import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; type Driver = ServerProvider["driver"]; @@ -44,9 +39,10 @@ function useBarColor(driver: Driver): string | null { } /** - * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. Pace sits under the - * left edge, the countdown under the right, so a row reads in one glance. + * One window as a bar spanning its whole duration: the fill is quota left, + * the hairline is how much of the window is left, so even spending keeps the + * fill on the line. Pace sits under the left edge, the countdown under the + * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -54,37 +50,40 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - {used}% + + {remaining}% left + = 90 - ? "h-full rounded-full bg-destructive" - : used >= 70 - ? "h-full rounded-full bg-warning" + remaining <= 10 + ? "h-full rounded-full bg-red-500" + : remaining <= 30 + ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ - { flex: used }, - used < 70 && props.color ? { backgroundColor: props.color } : null, + { flex: remaining }, + remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null} @@ -99,7 +98,7 @@ function WindowRow(props: { } /** One account: icon, name and plan on a single line, then its windows. */ -function AccountLimits(props: { +export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; @@ -107,14 +106,23 @@ function AccountLimits(props: { readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; + /** Tighter padding for the composer card. */ + readonly dense?: boolean; + /** Sits at the end of the heading row, such as a close control. */ + readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { - const { limits, now } = props; + const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const padding = dense ? "px-4 py-3" : "p-4"; return ( - + @@ -130,6 +138,7 @@ function AccountLimits(props: { ) : null} + {props.trailing} {notice ? ( {notice} @@ -157,19 +166,21 @@ const OUTCOME_TEXT: Record = { * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ -function ResetCredits(props: { +export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly credits: ServerProviderResetCredits; readonly now: number; + /** A smaller pill for the composer card. */ + readonly dense?: boolean; }) { - const { environmentId, instanceId, credits, now } = props; + const { environmentId, instanceId, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; + if (dense && credits.availableCount === 0 && status === null) return null; const expiresIn = credits.nextExpiresAt ? formatDuration(Date.parse(credits.nextExpiresAt) - now) @@ -217,10 +228,20 @@ function ResetCredits(props: { accessibilityState={{ disabled: busy }} disabled={busy} onPress={confirm} - className="rounded-full bg-subtle-strong px-3 py-1.5" + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5" + } > - - {busy ? "Using credit…" : "Use a reset credit"} + + {busy ? "Using…" : "Use reset"} ) : null} @@ -229,57 +250,6 @@ function ResetCredits(props: { ); } -function ProviderLimits(props: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; - readonly first: boolean; -}) { - const { provider, environmentId, now } = props; - const credits = provider.usageLimits?.resetCredits; - return ( - DRIVER_LABEL[driver])} - detail={provider.auth.label} - limits={provider.usageLimits} - now={now} - first={props.first} - footer={ - credits ? ( - - ) : undefined - } - /> - ); -} - -/** Emails stay off the phone screen; the plan and driver identify the row. */ -function SourceAccountLimits(props: { - readonly account: UsageLimitSourceAccount; - readonly now: number; - readonly first: boolean; -}) { - const { account } = props; - return ( - - ); -} - /** * Re-probes every provider (and usage-limit source) on each connected * environment; the fresh snapshots then arrive over the config stream. @@ -288,107 +258,47 @@ function SourceAccountLimits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits() { +export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); - const [failedLabels, setFailedLabels] = useState([]); + const [failedEnvironments, setFailedEnvironments] = useState< + readonly { environmentId: EnvironmentId; label: string }[] + >([]); // Always toggles `refreshing`, even with nothing to probe: Android's // RefreshControl keeps its spinner up until it sees true then false. const refresh = async () => { const connected = [...presentations].filter( - ([, presentation]) => presentation.connection.phase === "connected", + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); setRefreshing(true); try { const results = await Promise.all( connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), ); - setFailedLabels( + setFailedEnvironments( connected .filter((_, index) => results[index]?._tag === "Failure") - .map(([, presentation]) => presentation.entry.target.label), + .map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + })), ); } finally { setNow(Date.now()); setRefreshing(false); } }; + const failedLabels = failedEnvironments + .filter( + ({ environmentId }) => + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), + ) + .map(({ label }) => label); return { now, refreshing, failedLabels, refresh }; } - -/** - * Subscription quota windows from every connected environment's providers, - * read from the config each environment already streams. - */ -export function UsageLimitsSection(props: { - readonly now: number; - readonly failedLabels: readonly string[]; -}) { - const { now } = props; - const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const groups = collectLimitsGroups(presentations); - const sources = collectLimitSources(presentations); - - if (groups.length === 0 && sources.length === 0) { - return ( - - No provider on a connected environment reports subscription limits. - - ); - } - - return ( - <> - {props.failedLabels.length > 0 ? ( - - - {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - - ) : null} - {groups.map((group) => ( - - {group.providers.map((provider, index) => ( - - ))} - - ))} - {sources.map((source) => ( - - {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account, index) => ( - - )) - )} - - ))} - - ); -} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..3e5cd0fc9e3d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,10 @@ +import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; -import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; +import { + isCompatibleUsageContractVersion, + type DailyTotals, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import { enumerateDays, enumerateHourStarts, @@ -11,8 +16,9 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; -import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; +import { useRefreshLimits } from "./UsageLimitsSection"; +import { UsageLimitsSection } from "./UsageLimitsPooled"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; @@ -64,8 +74,13 @@ export function UsageRouteScreen() { const [metric, setMetric] = useState("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, isPending, isPartial, refresh } = useUsage(window); - const limits = useRefreshLimits(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = + useState | null>(null); + const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( + window, + selectedEnvironmentIds, + ); + const limits = useRefreshLimits(selectedEnvironmentIds); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -91,10 +106,8 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever — neither may pin the spinner on. - const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const [refreshingUsage, setRefreshingUsage] = useState(false); + const refreshingRef = useRef(false); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ @@ -103,31 +116,122 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setRefreshingUsage(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setRefreshingUsage(false); + }); }; + const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; + const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); + const filterAccessibilityLabel = hasLoadingEnvironments + ? "Filter usage environments, some environments are loading" + : "Filter usage environments"; + const filterIcon = + selectedEnvironmentIds === null + ? "line.3.horizontal.decrease" + : "line.3.horizontal.decrease.circle.fill"; + const environmentActions = useMemo( + () => [ + { + id: "all", + title: "All environments", + subtitle: undefined, + state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), + }, + ...environments.map((environment) => ({ + id: environment.environmentId, + title: environment.label, + subtitle: usageEnvironmentStatus(environment), + state: + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) + ? ("on" as const) + : ("off" as const), + })), + ], + [environments, selectedEnvironmentIds], + ); + const selectEnvironment = useCallback( + (value: string) => { + if (value === "all") { + setSelectedEnvironmentIds(null); + return; + } + const id = EnvironmentId.make(value); + setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); + }, + [environments], + ); + const environmentFilter = useMemo( + () => + showEnvironmentFilter ? ( + selectEnvironment(nativeEvent.event)} + > + + + {hasLoadingEnvironments ? ( + + ) : null} + + + ) : null, + [ + showEnvironmentFilter, + environmentActions, + selectEnvironment, + filterAccessibilityLabel, + filterIcon, + hasLoadingEnvironments, + ], + ); + + useLayoutEffect(() => { + if (Platform.OS === "ios") { + navigation.setOptions({ headerRight: () => environmentFilter }); + } + }, [navigation, environmentFilter]); + return ( {Platform.OS === "android" ? ( <> - navigation.goBack()} /> + navigation.goBack()} + trailing={environmentFilter} + /> ) : null} - {showingLimits ? ( - - ) : ( - <> - {/* Period and metric together: neither applies to Limits, and - both change every number below, so they share one bar. */} - - - - - + {showingLimits ? ( + - {isPending ? ( - - Scanning provider transcripts… - - ) : environments.length === 0 ? ( - - Connect an environment to see usage. - - ) : ( - <> - + {/* Period and metric together: neither applies to Limits, and + both change every number below, so they share one bar. */} + + - - - - - )} - - )} + + + {merged.duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {merged.duplicateSources.join(", ")} + + ) : null} + {isPending ? ( + + Scanning provider transcripts… + + ) : selectedEnvironments.length === 0 ? ( + + {environments.length === 0 + ? "Connect an environment to see usage." + : "Select an environment to see usage."} + + ) : ( + <> + + + + + + )} + + )} + ); @@ -218,25 +335,42 @@ function SegmentedControl(props: { const compact = props.size === "compact"; return ( + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", - active && "bg-subtle-strong", )} > environment.error !== null); - const stale = props.environments.filter((environment) => - props.merged.staleEnvironments.includes(environment.environmentId), - ); - const duplicateSources = props.merged.duplicateSources; +function isUsageLoading(environment: EnvironmentUsageStatus) { + return environment.isPending || (environment.summary === null && environment.error === null); +} + +function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { if ( - failed.length === 0 && - stale.length === 0 && - duplicateSources.length === 0 && - !props.isPartial + environment.summary && + !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { - return null; + return "Older server · excluded from usage totals"; } - - return ( - - {props.isPartial ? ( - - Some environments are still reporting. Totals are partial. - - ) : null} - {failed.map((environment) => ( - - {environment.label} could not report usage. - - ))} - {stale.map((environment) => ( - - {environment.label} runs an older server version and is excluded from totals. - - ))} - {duplicateSources.length > 0 ? ( - - Counted once across environments sharing a transcript directory:{" "} - {duplicateSources.join(", ")} - - ) : null} - - ); + if (!environment.isConnected) + return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; + if (environment.error) + return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; + if (isUsageLoading(environment)) + return environment.summary ? "Updating usage…" : "Loading usage…"; + return "Usage up to date"; } diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts new file mode 100644 index 000000000000..4914f9de0f8b --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; + +const a = EnvironmentId.make("a"); +const b = EnvironmentId.make("b"); +const c = EnvironmentId.make("c"); +const removed = EnvironmentId.make("removed"); +const environments = [a, b, c].map((environmentId) => ({ environmentId })); + +describe("usage environment selection", () => { + it("can exclude an environment from all, then select all again", () => { + const selected = toggleUsageEnvironment(null, environments, b); + expect(selected).toEqual(new Set([a, c])); + expect(toggleUsageEnvironment(selected, environments, b)).toBeNull(); + }); + + it("can deselect the last environment", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set()); + }); + + it("does not count removed IDs toward selecting all current environments", () => { + expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b])); + }); + + it("returns to all mode despite stale IDs when every current environment is selected", () => { + expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull(); + }); + + it("ignores a menu action for an environment that was removed", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a])); + }); + + it("includes newly connected environments only in all mode", () => { + const expanded = [...environments, { environmentId: removed }]; + expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed])); + expect(toggleUsageEnvironment(new Set([a, b, c]), expanded, a)).toEqual(new Set([b, c])); + }); +}); diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts new file mode 100644 index 000000000000..3f6e9fb3bae5 --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts @@ -0,0 +1,16 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** Null follows all environments, including ones connected after the menu opened. */ +export function toggleUsageEnvironment( + selected: ReadonlySet | null, + environments: readonly { readonly environmentId: EnvironmentId }[], + toggledId: EnvironmentId, +): ReadonlySet | null { + const ids = environments.map(({ environmentId }) => environmentId); + const next = new Set(ids.filter((id) => selected === null || selected.has(id))); + if (ids.includes(toggledId)) { + if (next.has(toggledId)) next.delete(toggledId); + else next.add(toggledId); + } + return ids.every((id) => next.has(id)) ? null : next; +} diff --git a/apps/mobile/src/fork/prism/PrismSettingsRow.tsx b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx new file mode 100644 index 000000000000..7ba2c3ac5250 --- /dev/null +++ b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx @@ -0,0 +1,93 @@ +/** + * The "Prism" section of the Settings root: one "Accounts" row into the Prism + * screen, shown only while a connected environment has the `prism` flag + * on. With the flag off everywhere this renders nothing, so Settings matches + * upstream row for row. + */ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { SettingsRow } from "../../features/settings/components/SettingsRow"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { useServerConfigs } from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { + type PrismOverview, + selectPrismEnvironments, + summarizePrismOverviews, +} from "./prismSettings.logic"; +import { usePrismApi } from "./usePrismApi"; + +export function PrismSettingsRow() { + const { environments } = useEnvironments(); + const configs = useServerConfigs(); + const targets = useMemo( + () => + selectPrismEnvironments( + environments, + (environmentId) => configs.get(environmentId)?.environment.capabilities, + ), + [configs, environments], + ); + const [overviews, setOverviews] = useState>( + () => new Map(), + ); + const handleOverview = useCallback((environmentId: EnvironmentId, overview: PrismOverview) => { + setOverviews((previous) => new Map(previous).set(environmentId, overview)); + }, []); + + if (targets.length === 0) return null; + + const value = summarizePrismOverviews( + targets.map((target) => overviews.get(target.environmentId) ?? { _tag: "loading" }), + ); + return ( + + + {targets.map((target) => ( + + ))} + + ); +} + +/** One status + list fetch per environment on mount; renders nothing. */ +function OverviewLoader(props: { + readonly environmentId: EnvironmentId; + readonly onOverview: (environmentId: EnvironmentId, overview: PrismOverview) => void; +}) { + const api = usePrismApi(props.environmentId); + const { environmentId, onOverview } = props; + + useEffect(() => { + if (!api) return; + let cancelled = false; + void Promise.all([api.status(), api.listAccounts()]).then(([status, accounts]) => { + if (cancelled) return; + onOverview( + environmentId, + status._tag === "ok" + ? { + _tag: "loaded", + state: status.value.state, + accountCount: accounts._tag === "ok" ? accounts.value.length : null, + } + : { _tag: "error" }, + ); + }); + return () => { + cancelled = true; + }; + }, [api, environmentId, onOverview]); + + return null; +} diff --git a/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx new file mode 100644 index 000000000000..2c29e427752d --- /dev/null +++ b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx @@ -0,0 +1,838 @@ +/** + * Settings → Prism: the CLIProxyAPI account pool of every connected + * environment with the `prism` flag on. Per environment: proxy status with + * a restart, the accounts with enable/remove, the "Add account" sign-in flow, + * and the routing strategy. Reachable by deep link even with the flag off + * everywhere; it then explains how to turn Prism on. + */ +import type { PrismAccount, PrismLoginProvider, PrismStatus } from "@q1code/core/prismApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { useIsFocused, useNavigation } from "@react-navigation/native"; +import { createNativeStackScreen } from "@react-navigation/native-stack"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + type ComponentProps, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { + Alert, + AppState, + type ColorValue, + Linking, + Platform, + Pressable, + RefreshControl, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { withUniwind } from "uniwind"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { CopyTextButton } from "../../components/CopyTextButton"; +import { StatusPill } from "../../components/StatusPill"; +import { ThemedSwitch } from "../../components/ThemedSwitch"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { useServerConfigs } from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { + PRISM_LOGIN_POLL_MS, + PRISM_LOGIN_PROVIDERS, + PRISM_OFF_HINT, + PRISM_RESTART_POLL_MS, + PRISM_ROUTING_OPTIONS, + PRISM_STATUS_POLL_MS, + PRISM_USAGE_SOURCE_LABEL, + type PrismUsageSourceState, + prismStateTone, + describePrismAccount, + describePrismError, + describePrismStatus, + IDLE_LOGIN_FLOW, + INITIAL_ACCOUNTS_STATE, + INITIAL_USAGE_SOURCE_STATE, + labelPrismLoginProvider, + nextRestartStep, + pendingPrismLoginSession, + reducePrismAccounts, + reducePrismLoginFlow, + reducePrismUsageSource, + selectPrismEnvironments, + shouldPollPrismStatus, +} from "./prismSettings.logic"; +import { type PrismApi, usePrismApi } from "./usePrismApi"; + +type Reloader = () => Promise; + +// CopyTextButton takes a native tint; map a className onto it like ControlPill does for its menu. +const ThemedCopyTextButton = withUniwind( + function TintedCopyTextButton({ + tintColor, + ...props + }: Omit, "tintColor"> & { + readonly tintColor?: ColorValue; + }) { + return ; + }, + { tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" } }, +); + +export function PrismSettingsScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { environments } = useEnvironments(); + const configs = useServerConfigs(); + const targets = useMemo( + () => + selectPrismEnvironments( + environments, + (environmentId) => configs.get(environmentId)?.environment.capabilities, + ), + [configs, environments], + ); + // Each panel registers its reload so pull-to-refresh can wait on all of them. + const reloaders = useRef(new Map()); + const [refreshing, setRefreshing] = useState(false); + const registerReloader = useCallback((environmentId: EnvironmentId, reload: Reloader | null) => { + if (reload) reloaders.current.set(environmentId, reload); + else reloaders.current.delete(environmentId); + }, []); + const refreshAll = useCallback(() => { + setRefreshing(true); + void Promise.all([...reloaders.current.values()].map((reload) => reload())).finally(() => + setRefreshing(false), + ); + }, []); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + } + > + {targets.length === 0 ? ( + + Prism is off + + {PRISM_OFF_HINT} + + + ) : ( + targets.map((target) => ( + 1 ? target.label : null} + registerReloader={registerReloader} + /> + )) + )} + + + ); +} + +/** Registered in `Stack.tsx` under `SettingsPrism`; one line there, everything else here. */ +export const prismSettingsStackScreen = createNativeStackScreen({ + screen: PrismSettingsScreen, + linking: "prism", + options: { + title: "Prism", + }, +}); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface RestartState { + readonly running: boolean; + readonly note: string | null; +} + +function EnvironmentPanel(props: { + readonly environmentId: EnvironmentId; + /** Shown above the sections when more than one environment is listed. */ + readonly label: string | null; + readonly registerReloader: (environmentId: EnvironmentId, reload: Reloader | null) => void; +}) { + const { environmentId, registerReloader } = props; + const api = usePrismApi(environmentId); + const mounted = useRef(true); + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); + + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [restart, setRestart] = useState({ running: false, note: null }); + const [accounts, dispatchAccounts] = useReducer(reducePrismAccounts, INITIAL_ACCOUNTS_STATE); + const [routing, setRouting] = useState(null); + const [routingError, setRoutingError] = useState(null); + const [routingBusy, setRoutingBusy] = useState(false); + const [login, dispatchLogin] = useReducer(reducePrismLoginFlow, IDLE_LOGIN_FLOW); + const [usageSource, dispatchUsageSource] = useReducer( + reducePrismUsageSource, + INITIAL_USAGE_SOURCE_STATE, + ); + + const loadStatus = useCallback(async () => { + if (!api) return; + const result = await api.status(); + if (!mounted.current) return; + if (result._tag === "ok") { + setStatus(result.value); + dispatchUsageSource({ type: "status", status: result.value }); + setStatusError(null); + } else { + setStatusError(describePrismError(result.error)); + } + }, [api]); + + const loadAccounts = useCallback(async () => { + if (!api) return; + const result = await api.listAccounts(); + if (!mounted.current) return; + dispatchAccounts( + result._tag === "ok" + ? { type: "loaded", accounts: result.value } + : { type: "loadFailed", error: describePrismError(result.error) }, + ); + }, [api]); + + const loadRouting = useCallback(async () => { + if (!api) return; + const result = await api.getRouting(); + if (!mounted.current) return; + if (result._tag === "ok") { + setRouting(result.value.strategy); + setRoutingError(null); + } else { + setRoutingError(describePrismError(result.error)); + } + }, [api]); + + const loadAll = useCallback(async () => { + await Promise.all([loadStatus(), loadAccounts(), loadRouting()]); + }, [loadAccounts, loadRouting, loadStatus]); + + useEffect(() => { + void loadAll(); + }, [loadAll]); + + useEffect(() => { + registerReloader(environmentId, loadAll); + return () => registerReloader(environmentId, null); + }, [environmentId, loadAll, registerReloader]); + + useStatusPolling(api !== null && !restart.running, loadStatus); + + const confirmRestart = () => { + Alert.alert( + "Restart the proxy?", + "Provider CLIs lose their connection until it is ready again.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Restart", style: "destructive", onPress: () => void runRestart() }, + ], + ); + }; + + const runRestart = async () => { + if (!api) return; + setRestart({ running: true, note: null }); + const result = await api.restart(); + if (!mounted.current) return; + if (result._tag === "error") { + setRestart({ running: false, note: describePrismError(result.error) }); + return; + } + setStatus(result.value); + const startedAt = Date.now(); + let step = nextRestartStep({ state: result.value.state, elapsedMs: 0 }); + while (step === "poll") { + await delay(PRISM_RESTART_POLL_MS); + if (!mounted.current) return; + const polled = await api.status(); + if (!mounted.current) return; + if (polled._tag === "ok") setStatus(polled.value); + step = nextRestartStep({ + state: polled._tag === "ok" ? polled.value.state : "starting", + elapsedMs: Date.now() - startedAt, + }); + } + setRestart({ + running: false, + note: step === "timeout" ? "Still starting after 30 s. Pull to refresh later." : null, + }); + if (step === "settled") void loadAccounts(); + }; + + const toggleUsageSource = (enabled: boolean) => { + if (!api) return; + dispatchUsageSource({ type: "toggle", enabled }); + void api.setUsageSource(enabled).then((result) => { + if (!mounted.current) return; + if (result._tag === "ok") { + setStatus(result.value); + dispatchUsageSource({ type: "saved", status: result.value }); + } else { + dispatchUsageSource({ type: "saveFailed", error: describePrismError(result.error) }); + } + }); + }; + + const toggleAccount = (account: PrismAccount, enabled: boolean) => { + if (!api) return; + dispatchAccounts({ type: "toggle", id: account.id, disabled: !enabled }); + void api.patchAccount(account.id, { disabled: !enabled }).then((result) => { + if (!mounted.current) return; + dispatchAccounts( + result._tag === "ok" + ? { type: "toggled", id: account.id, account: result.value } + : { type: "toggleFailed", id: account.id, error: describePrismError(result.error) }, + ); + }); + }; + + const confirmRemove = (account: PrismAccount) => { + Alert.alert( + `Remove ${account.email ?? account.label}?`, + "The auth file is deleted from the proxy and the removal syncs to the other environments.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + if (!api) return; + dispatchAccounts({ type: "remove", id: account.id }); + void api.deleteAccount(account.id).then((result) => { + if (!mounted.current) return; + dispatchAccounts( + result._tag === "ok" + ? { type: "removed", id: account.id } + : { + type: "removeFailed", + id: account.id, + error: describePrismError(result.error), + }, + ); + }); + }, + }, + ], + ); + }; + + const selectRouting = (strategy: PrismRoutingStrategy) => { + if (!api || routingBusy || strategy === routing) return; + const previous = routing; + setRouting(strategy); + setRoutingError(null); + setRoutingBusy(true); + void api.setRouting(strategy).then((result) => { + if (!mounted.current) return; + setRoutingBusy(false); + if (result._tag === "ok") { + setRouting(result.value.strategy); + } else { + setRouting(previous); + setRoutingError(describePrismError(result.error)); + } + }); + }; + + if (!api) { + return ( + + + Connecting… + + + ); + } + + return ( + + + void loadAccounts()} + /> + void loadAccounts()} + /> + + + ); +} + +function PanelFrame(props: { readonly label: string | null; readonly children: React.ReactNode }) { + return ( + + {props.label ? ( + + {props.label} + + ) : null} + {props.children} + + ); +} + +/** Status polls only while this screen is on top and the app is in the foreground. */ +function useStatusPolling(enabled: boolean, poll: () => Promise) { + const focused = useIsFocused(); + const [appState, setAppState] = useState(AppState.currentState ?? "active"); + useEffect(() => { + const subscription = AppState.addEventListener("change", setAppState); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (!enabled || !shouldPollPrismStatus({ focused, appState })) return; + const interval = setInterval(() => void poll(), PRISM_STATUS_POLL_MS); + return () => clearInterval(interval); + }, [appState, enabled, focused, poll]); +} + +function StatusSection(props: { + readonly status: PrismStatus | null; + readonly error: string | null; + readonly restart: RestartState; + readonly usageSource: PrismUsageSourceState; + readonly onRestart: () => void; + readonly onUsageSourceChange: (enabled: boolean) => void; +}) { + const { status } = props; + return ( + + {status === null ? ( + props.error ? ( + {props.error} + ) : ( + + ) + ) : ( + + + + + + + {describePrismStatus(status, relativeTime).map((line) => ( + + {line.label} + + {line.value} + + + ))} + {props.error ? {props.error} : null} + {props.restart.note ? {props.restart.note} : null} + + + )} + + ); +} + +/** Whether Prism publishes its pooled accounts to Usage → Limits; flips at once and rolls back on failure. */ +function UsageSourceRow(props: { + readonly state: PrismUsageSourceState; + readonly onChange: (enabled: boolean) => void; +}) { + const { state } = props; + const pending = state.rollback !== null; + return ( + + + {PRISM_USAGE_SOURCE_LABEL} + + + {state.error ? {state.error} : null} + + ); +} + +function AccountsSection(props: { + readonly state: ReturnType; + readonly onToggle: (account: PrismAccount, enabled: boolean) => void; + readonly onRemove: (account: PrismAccount) => void; + readonly onRetry: () => void; +}) { + const { state } = props; + return ( + + {state.error ? ( + + {state.error} + + + ) : null} + {state.accounts === null ? ( + state.error ? null : ( + + ) + ) : state.accounts.length === 0 ? ( + + No accounts yet. Add one below. + + ) : ( + state.accounts.map((account, index) => ( + props.onToggle(account, enabled)} + onRemove={() => props.onRemove(account)} + /> + )) + )} + {state.accounts !== null && state.accounts.length > 0 ? ( + + Long-press an account to remove it. + + ) : null} + + ); +} + +function AccountRow(props: { + readonly account: PrismAccount; + readonly first: boolean; + readonly pending: boolean; + readonly error: string | null; + readonly onToggle: (enabled: boolean) => void; + readonly onRemove: () => void; +}) { + const { account } = props; + return ( + + + + {account.email ?? account.label} + + + {describePrismAccount(account, relativeTime)} + + {props.error ? {props.error} : null} + + + + ); +} + +function AddAccountSection(props: { + readonly api: PrismApi; + readonly login: ReturnType; + readonly dispatch: (event: Parameters[1]) => void; + readonly onCompleted: () => void; +}) { + const { api, login, dispatch, onCompleted } = props; + const [redirectDraft, setRedirectDraft] = useState(""); + const pendingSession = pendingPrismLoginSession(login); + + useEffect(() => { + if (!pendingSession) return; + let cancelled = false; + const tick = async () => { + const result = await api.loginStatus(pendingSession); + if (cancelled || result._tag !== "ok") return; + dispatch({ type: "status", status: result.value }); + if (result.value.status === "completed") onCompleted(); + }; + const interval = setInterval(() => void tick(), PRISM_LOGIN_POLL_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [api, dispatch, onCompleted, pendingSession]); + + const start = (provider: PrismLoginProvider) => { + dispatch({ type: "start", provider }); + setRedirectDraft(""); + void api.startLogin(provider).then((result) => { + if (result._tag === "error") { + dispatch({ type: "startFailed", error: describePrismError(result.error) }); + return; + } + dispatch({ type: "started", started: result.value }); + void Linking.openURL(result.value.authUrl).catch(() => undefined); + }); + }; + + const submitRedirect = () => { + if (login._tag !== "pending") return; + const redirectUrl = redirectDraft.trim(); + if (redirectUrl.length === 0) return; + const { sessionId } = login; + dispatch({ type: "pasteRedirect" }); + void api.completeLogin(sessionId, redirectUrl).then((result) => { + if (result._tag === "ok") { + dispatch({ type: "status", status: result.value }); + if (result.value.status === "completed") onCompleted(); + } else { + dispatch({ type: "redirectFailed", sessionId, error: describePrismError(result.error) }); + } + }); + }; + + const cancel = () => { + if (login._tag === "pending") void api.cancelLogin(login.sessionId); + dispatch({ type: "cancel" }); + }; + + return ( + + + {login._tag === "idle" ? ( + <> + Sign in with a provider: + + {PRISM_LOGIN_PROVIDERS.map((provider) => ( + start(provider.value)} + /> + ))} + + + ) : login._tag === "starting" ? ( + + Starting {labelPrismLoginProvider(login.provider)} sign-in… + + ) : login._tag === "pending" ? ( + <> + + Finish the {labelPrismLoginProvider(login.provider)} sign-in in your browser. + + {login.userCode ? ( + + + {login.userCode} + + + + ) : null} + + void Linking.openURL(login.authUrl).catch(() => undefined)} + /> + + + + If the browser cannot reach the server, paste the URL it redirected to: + + + + + + {login.redirectError ? {login.redirectError} : null} + + ) : ( + <> + {login._tag === "completed" ? ( + + {labelPrismLoginProvider(login.provider)} account added + {login.accountId ? ` (${login.accountId})` : ""}. + + ) : login._tag === "failed" ? ( + {login.error} + ) : ( + Sign-in cancelled. + )} + dispatch({ type: "reset" })} /> + + )} + + + ); +} + +function RoutingSection(props: { + readonly strategy: PrismRoutingStrategy | null; + readonly error: string | null; + readonly busy: boolean; + readonly onSelect: (strategy: PrismRoutingStrategy) => void; +}) { + return ( + + + + {PRISM_ROUTING_OPTIONS.map((option) => ( + props.onSelect(option.value)} + /> + ))} + + {props.error ? {props.error} : null} + + + ); +} + +function Chip(props: { + readonly label: string; + readonly selected?: boolean; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} + +function PillButton(props: { + readonly label: string; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + return ( + + {props.label} + + ); +} + +function ErrorText(props: { readonly children: string }) { + return {props.children}; +} + +/** Static placeholder rows for the first load; no animation, so nothing repaints. */ +function SkeletonRows(props: { readonly count: number }) { + return ( + + {Array.from({ length: props.count }, (_, index) => ( + + + + + ))} + + ); +} diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.test.ts b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts new file mode 100644 index 000000000000..1034b94ff52d --- /dev/null +++ b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts @@ -0,0 +1,453 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { PrismAccount, PrismStatus } from "@q1code/core/prismApi"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + ADMIN_ACCESS_REQUIRED, + PRISM_RESTART_TIMEOUT_MS, + type PrismLoginFlowState, + describePrismAccount, + describePrismError, + describePrismStatus, + IDLE_LOGIN_FLOW, + INITIAL_ACCOUNTS_STATE, + INITIAL_USAGE_SOURCE_STATE, + isPrismUsageSourceOn, + nextRestartStep, + pendingPrismLoginSession, + reducePrismAccounts, + reducePrismLoginFlow, + reducePrismUsageSource, + selectPrismEnvironments, + shouldPollPrismStatus, + summarizePrismOverviews, +} from "./prismSettings.logic"; + +const environment = (id: string, phase: string) => ({ + environmentId: id as EnvironmentId, + label: `env ${id}`, + connection: { phase }, +}); + +describe("selectPrismEnvironments", () => { + it("keeps only connected environments whose flag is on", () => { + const flags: Record = { a: true, b: false, c: true, d: true }; + const selected = selectPrismEnvironments( + [ + environment("a", "connected"), + environment("b", "connected"), + environment("c", "connecting"), + environment("d", "connected"), + environment("e", "connected"), + ], + (id) => (flags[id] === undefined ? null : { forkFlags: { prism: flags[id] } }), + ); + expect(selected.map((entry) => entry.environmentId)).toEqual(["a", "d"]); + expect(selected[0]?.label).toBe("env a"); + }); + + it("reads the registry default (off) against upstream servers with no forkFlags", () => { + expect(selectPrismEnvironments([environment("a", "connected")], () => ({}))).toEqual([]); + }); +}); + +describe("describePrismStatus", () => { + const relative = (iso: string) => `rel(${iso})`; + const base: PrismStatus = { state: "ready", port: 8317, role: "standalone" }; + + it("treats a status without the newer fields as a sidecar on its port", () => { + expect(describePrismStatus(base, relative)).toEqual([ + { label: "Mode", value: "Sidecar" }, + { label: "Base URL", value: "port 8317" }, + { label: "Engine", value: "CLIProxyAPI (bundled)" }, + { label: "Sync", value: "standalone" }, + ]); + }); + + it("lists mode, base URL, engine, since, restarts, last error, and sync details", () => { + expect( + describePrismStatus( + { + ...base, + state: "failed", + mode: "external", + baseUrl: "http://proxy.local:9000", + version: "6.1.0", + since: "2026-09-02T10:00:00.000Z", + restarts: 3, + lastError: "connection refused", + role: "replica", + lastSyncAt: "2026-09-02T09:55:00.000Z", + lastSyncError: "401", + }, + relative, + ), + ).toEqual([ + { label: "Mode", value: "External" }, + { label: "Base URL", value: "http://proxy.local:9000" }, + { label: "Engine", value: "CLIProxyAPI v6.1.0 (external)" }, + { label: "Since", value: "rel(2026-09-02T10:00:00.000Z) ago" }, + { label: "Restarts", value: "3" }, + { label: "Last error", value: "connection refused" }, + { label: "Sync", value: "replica · synced rel(2026-09-02T09:55:00.000Z) ago · error: 401" }, + ]); + }); +}); + +describe("describePrismError", () => { + it("explains a 503 by its reason and state", () => { + expect( + describePrismError({ + _tag: "PrismUnavailableError", + reason: "sidecar-not-ready", + state: "starting", + } as never), + ).toContain("starting"); + expect( + describePrismError({ + _tag: "PrismUnavailableError", + reason: "flag-off", + state: "off", + } as never), + ).toContain("T3FORK_PRISM=1"); + }); + + it("phrases 401 and 403 as an administrative-access problem without leaking a token", () => { + const forbidden = describePrismError({ + _tag: "EnvironmentScopeRequiredError", + requiredScope: "access:write", + } as never); + expect(forbidden).toContain(ADMIN_ACCESS_REQUIRED); + expect(forbidden).toContain("access:write"); + expect(describePrismError({ _tag: "EnvironmentAuthInvalidError" } as never)).toContain( + ADMIN_ACCESS_REQUIRED, + ); + }); +}); + +describe("describePrismAccount", () => { + const relative = () => "5m"; + it("joins provider, weight, age, and counters when present", () => { + const account: PrismAccount = { + id: "codex-1.json", + provider: "codex", + label: "codex-1", + disabled: false, + weight: 2, + updatedAt: "2026-09-02T10:00:00.000Z", + usage: { success: 12, failed: 1 }, + }; + expect(describePrismAccount(account, relative)).toBe( + "Codex · weight 2 · 5m · 12 ok · 1 failed", + ); + expect( + describePrismAccount({ ...account, weight: undefined, usage: undefined }, relative), + ).toBe("Codex · 5m"); + }); +}); + +describe("reducePrismAccounts", () => { + const account = (id: string, disabled = false): PrismAccount => ({ + id, + provider: "claude", + label: id, + disabled, + updatedAt: "2026-09-02T10:00:00.000Z", + }); + const loaded = reducePrismAccounts(INITIAL_ACCOUNTS_STATE, { + type: "loaded", + accounts: [account("a.json"), account("b.json", true)], + }); + + it("applies a toggle optimistically and confirms it with the server's account", () => { + const toggled = reducePrismAccounts(loaded, { + type: "toggle", + id: "a.json", + disabled: true, + }); + expect(toggled.accounts?.[0]?.disabled).toBe(true); + expect(toggled.pending["a.json"]).toEqual({ _tag: "toggle", previousDisabled: false }); + + const confirmed = reducePrismAccounts(toggled, { + type: "toggled", + id: "a.json", + account: { ...account("a.json", true), weight: 4 }, + }); + expect(confirmed.accounts?.[0]).toMatchObject({ disabled: true, weight: 4 }); + expect(confirmed.pending).toEqual({}); + }); + + it("rolls a failed toggle back and keeps the error on that row only", () => { + const toggled = reducePrismAccounts(loaded, { + type: "toggle", + id: "b.json", + disabled: false, + }); + const failed = reducePrismAccounts(toggled, { + type: "toggleFailed", + id: "b.json", + error: "nope", + }); + expect(failed.accounts?.[1]?.disabled).toBe(true); + expect(failed.pending).toEqual({}); + expect(failed.rowErrors).toEqual({ "b.json": "nope" }); + + const retried = reducePrismAccounts(failed, { + type: "toggle", + id: "b.json", + disabled: false, + }); + expect(retried.rowErrors).toEqual({}); + }); + + it("ignores a second toggle while one is in flight and a stray confirmation", () => { + const toggled = reducePrismAccounts(loaded, { + type: "toggle", + id: "a.json", + disabled: true, + }); + expect(reducePrismAccounts(toggled, { type: "toggle", id: "a.json", disabled: false })).toBe( + toggled, + ); + expect(reducePrismAccounts(loaded, { type: "toggleFailed", id: "a.json", error: "late" })).toBe( + loaded, + ); + }); + + it("keeps the optimistic value when a list fetched mid-toggle still has the old one", () => { + const toggled = reducePrismAccounts(loaded, { + type: "toggle", + id: "a.json", + disabled: true, + }); + const reloaded = reducePrismAccounts(toggled, { + type: "loaded", + accounts: [account("a.json"), account("b.json", true)], + }); + expect(reloaded.accounts?.[0]?.disabled).toBe(true); + expect(reloaded.pending["a.json"]).toBeDefined(); + }); + + it("removes an account on success and reports a failed removal inline", () => { + const removing = reducePrismAccounts(loaded, { type: "remove", id: "a.json" }); + expect(removing.pending["a.json"]).toEqual({ _tag: "remove" }); + const removed = reducePrismAccounts(removing, { type: "removed", id: "a.json" }); + expect(removed.accounts?.map((entry) => entry.id)).toEqual(["b.json"]); + expect(removed.pending).toEqual({}); + + const failed = reducePrismAccounts(removing, { + type: "removeFailed", + id: "a.json", + error: "sidecar refused", + }); + expect(failed.accounts?.length).toBe(2); + expect(failed.rowErrors).toEqual({ "a.json": "sidecar refused" }); + }); + + it("keeps the last list when a reload fails, and clears the error on the next success", () => { + const failed = reducePrismAccounts(loaded, { type: "loadFailed", error: "offline" }); + expect(failed.accounts?.length).toBe(2); + expect(failed.error).toBe("offline"); + expect(reducePrismAccounts(failed, { type: "loaded", accounts: [] }).error).toBeNull(); + }); +}); + +describe("reducePrismLoginFlow", () => { + const started = { + sessionId: "session-1", + authUrl: "https://auth.example.test/start", + flow: "redirect" as const, + }; + const pending = (): PrismLoginFlowState => + reducePrismLoginFlow( + reducePrismLoginFlow(IDLE_LOGIN_FLOW, { type: "start", provider: "codex" }), + { type: "started", started }, + ); + + it("walks idle -> starting -> pending -> completed with the new account id", () => { + const starting = reducePrismLoginFlow(IDLE_LOGIN_FLOW, { + type: "start", + provider: "anthropic", + }); + expect(starting).toEqual({ _tag: "starting", provider: "anthropic" }); + const waiting = reducePrismLoginFlow(starting, { + type: "started", + started: { ...started, flow: "device", userCode: "ABCD-EFGH" }, + }); + expect(waiting).toMatchObject({ _tag: "pending", flow: "device", userCode: "ABCD-EFGH" }); + expect(pendingPrismLoginSession(waiting)).toBe("session-1"); + const done = reducePrismLoginFlow(waiting, { + type: "status", + status: { sessionId: "session-1", status: "completed", accountId: "claude-1.json" }, + }); + expect(done).toEqual({ _tag: "completed", provider: "anthropic", accountId: "claude-1.json" }); + expect(pendingPrismLoginSession(done)).toBeNull(); + }); + + it("ignores answers for another session and a second start while pending", () => { + const waiting = pending(); + expect( + reducePrismLoginFlow(waiting, { + type: "status", + status: { sessionId: "session-0", status: "completed" }, + }), + ).toBe(waiting); + expect(reducePrismLoginFlow(waiting, { type: "start", provider: "xai" })).toBe(waiting); + }); + + it("keeps polling through a pasted redirect and surfaces a rejected one", () => { + const submitting = reducePrismLoginFlow(pending(), { type: "pasteRedirect" }); + expect(submitting).toMatchObject({ _tag: "pending", submittingRedirect: true }); + expect(pendingPrismLoginSession(submitting)).toBe("session-1"); + const rejected = reducePrismLoginFlow(submitting, { + type: "redirectFailed", + sessionId: "session-1", + error: "bad state", + }); + expect(rejected).toMatchObject({ submittingRedirect: false, redirectError: "bad state" }); + expect(reducePrismLoginFlow(rejected, { type: "pasteRedirect" })).toMatchObject({ + submittingRedirect: true, + redirectError: null, + }); + }); + + it("cancels optimistically, reports failures, and resets to idle", () => { + expect(reducePrismLoginFlow(pending(), { type: "cancel" })).toEqual({ + _tag: "cancelled", + provider: "codex", + }); + expect( + reducePrismLoginFlow(pending(), { + type: "status", + status: { sessionId: "session-1", status: "failed" }, + }), + ).toMatchObject({ _tag: "failed", error: expect.any(String) }); + expect( + reducePrismLoginFlow( + reducePrismLoginFlow(IDLE_LOGIN_FLOW, { type: "start", provider: "kimi" }), + { type: "startFailed", error: "503" }, + ), + ).toEqual({ _tag: "failed", provider: "kimi", error: "503" }); + expect(reducePrismLoginFlow(pending(), { type: "reset" })).toBe(IDLE_LOGIN_FLOW); + }); +}); + +describe("reducePrismUsageSource", () => { + const status = (usageSource?: boolean): PrismStatus => ({ + state: "ready", + port: 8317, + role: "standalone", + ...(usageSource === undefined ? {} : { usageSource }), + }); + + it("treats a status without the field as on and follows later loads while idle", () => { + expect(isPrismUsageSourceOn(null)).toBe(true); + expect(isPrismUsageSourceOn(status())).toBe(true); + expect(isPrismUsageSourceOn(status(false))).toBe(false); + const loaded = reducePrismUsageSource(INITIAL_USAGE_SOURCE_STATE, { + type: "status", + status: status(), + }); + expect(loaded).toEqual({ enabled: true, rollback: null, error: null }); + expect(reducePrismUsageSource(loaded, { type: "status", status: status(false) }).enabled).toBe( + false, + ); + }); + + it("flips optimistically, ignores a stale poll meanwhile, and settles on the saved status", () => { + const loaded = reducePrismUsageSource(INITIAL_USAGE_SOURCE_STATE, { + type: "status", + status: status(true), + }); + const flipped = reducePrismUsageSource(loaded, { type: "toggle", enabled: false }); + expect(flipped).toEqual({ enabled: false, rollback: true, error: null }); + expect(reducePrismUsageSource(flipped, { type: "status", status: status(true) })).toBe(flipped); + expect(reducePrismUsageSource(flipped, { type: "toggle", enabled: true })).toBe(flipped); + expect(reducePrismUsageSource(flipped, { type: "saved", status: status(false) })).toEqual({ + enabled: false, + rollback: null, + error: null, + }); + }); + + it("rolls back with the error when the save fails, and clears it on the next attempt", () => { + const loaded = reducePrismUsageSource(INITIAL_USAGE_SOURCE_STATE, { + type: "status", + status: status(false), + }); + const flipped = reducePrismUsageSource(loaded, { type: "toggle", enabled: true }); + const failed = reducePrismUsageSource(flipped, { type: "saveFailed", error: "403" }); + expect(failed).toEqual({ enabled: false, rollback: null, error: "403" }); + expect(reducePrismUsageSource(failed, { type: "toggle", enabled: true }).error).toBeNull(); + }); + + it("does nothing before the status arrived or when the value would not change", () => { + expect( + reducePrismUsageSource(INITIAL_USAGE_SOURCE_STATE, { type: "toggle", enabled: false }), + ).toBe(INITIAL_USAGE_SOURCE_STATE); + const loaded = reducePrismUsageSource(INITIAL_USAGE_SOURCE_STATE, { + type: "status", + status: status(true), + }); + expect(reducePrismUsageSource(loaded, { type: "toggle", enabled: true })).toBe(loaded); + expect(reducePrismUsageSource(loaded, { type: "saveFailed", error: "late" })).toBe(loaded); + }); +}); + +describe("polling decisions", () => { + it("polls status only while focused and the app is active", () => { + expect(shouldPollPrismStatus({ focused: true, appState: "active" })).toBe(true); + expect(shouldPollPrismStatus({ focused: false, appState: "active" })).toBe(false); + expect(shouldPollPrismStatus({ focused: true, appState: "background" })).toBe(false); + expect(shouldPollPrismStatus({ focused: true, appState: "inactive" })).toBe(false); + }); + + it("keeps polling a restart until ready or failed, then gives up at the deadline", () => { + expect(nextRestartStep({ state: "starting", elapsedMs: 0 })).toBe("poll"); + expect(nextRestartStep({ state: "off", elapsedMs: 4_000 })).toBe("poll"); + expect(nextRestartStep({ state: "ready", elapsedMs: 4_000 })).toBe("settled"); + expect(nextRestartStep({ state: "failed", elapsedMs: 4_000 })).toBe("settled"); + expect(nextRestartStep({ state: "starting", elapsedMs: PRISM_RESTART_TIMEOUT_MS })).toBe( + "timeout", + ); + expect(nextRestartStep({ state: "ready", elapsedMs: PRISM_RESTART_TIMEOUT_MS + 1 })).toBe( + "settled", + ); + }); +}); + +describe("summarizePrismOverviews", () => { + it("stays empty until every environment answered", () => { + expect(summarizePrismOverviews([])).toBeUndefined(); + expect( + summarizePrismOverviews([ + { _tag: "loaded", state: "ready", accountCount: 2 }, + { _tag: "loading" }, + ]), + ).toBeUndefined(); + }); + + it("sums the counts across environments", () => { + expect( + summarizePrismOverviews([ + { _tag: "loaded", state: "ready", accountCount: 2 }, + { _tag: "loaded", state: "starting", accountCount: null }, + { _tag: "loaded", state: "ready", accountCount: 1 }, + ]), + ).toBe("3 accounts"); + expect(summarizePrismOverviews([{ _tag: "loaded", state: "ready", accountCount: 1 }])).toBe( + "1 account", + ); + }); + + it("falls back to the state that explains a missing count", () => { + expect( + summarizePrismOverviews([{ _tag: "loaded", state: "starting", accountCount: null }]), + ).toBe("Starting"); + expect(summarizePrismOverviews([{ _tag: "loaded", state: "ready", accountCount: null }])).toBe( + "Unavailable", + ); + expect(summarizePrismOverviews([{ _tag: "error" }])).toBe("Unreachable"); + }); +}); diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.ts b/apps/mobile/src/fork/prism/prismSettings.logic.ts new file mode 100644 index 000000000000..2ac044d928d4 --- /dev/null +++ b/apps/mobile/src/fork/prism/prismSettings.logic.ts @@ -0,0 +1,581 @@ +/** + * Pure state and labels for the Prism settings screen (the pooled provider + * accounts on mobile): which environments show it, status and error + * descriptions, the login-flow reducer, the optimistic accounts reducer, and + * the polling decisions. No React, no network; the screen wires these to + * `@t3tools/client-runtime/fork`. + */ +import type { + PrismAccount, + PrismLoginProvider, + PrismLoginStarted, + PrismLoginStatus, + PrismState, + PrismStatus, + PrismUnavailableReason, +} from "@q1code/core/prismApi"; +import { FORK_CONFIG_FILENAME, type PrismRoutingStrategy } from "@q1code/core/config"; +import { envVarForFlag } from "@q1code/core/flags"; +import { type PrismClientError, readForkFlag } from "@t3tools/client-runtime/fork"; +import type { EnvironmentId, ExecutionEnvironmentCapabilities } from "@t3tools/contracts"; + +import type { StatusTone } from "../../components/StatusPill"; + +// Environments + +export interface PrismEnvironmentRef { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +/** Connected environments whose `prism` flag is on, in catalog order. */ +export function selectPrismEnvironments( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connection: { readonly phase: string }; + }>, + capabilitiesOf: ( + environmentId: EnvironmentId, + ) => Pick | null | undefined, +): ReadonlyArray { + return environments + .filter( + (environment) => + environment.connection.phase === "connected" && + readForkFlag(capabilitiesOf(environment.environmentId), "prism"), + ) + .map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })); +} + +/** The calm explanation the screen shows when no connected environment has the flag on. */ +export const PRISM_OFF_HINT = `Prism is off. Set ${envVarForFlag("prism")}=1 or flags.prism in ${FORK_CONFIG_FILENAME} on the server, restart it, and this screen fills in.`; + +// Status + +export const PRISM_STATE_LABELS: Readonly> = { + off: "Off", + starting: "Starting", + ready: "Ready", + failed: "Failed", +}; + +export function prismStateTone(state: PrismState): StatusTone { + switch (state) { + case "ready": + return { + label: PRISM_STATE_LABELS.ready, + pillClassName: "bg-adaptive-emerald-500-a12-a16", + textClassName: "text-adaptive-emerald-700-300", + }; + case "starting": + return { + label: PRISM_STATE_LABELS.starting, + pillClassName: "bg-adaptive-amber-500-a12-a16", + textClassName: "text-adaptive-amber-700-300", + }; + case "failed": + return { + label: PRISM_STATE_LABELS.failed, + pillClassName: "bg-adaptive-rose-500-a12-a16", + textClassName: "text-adaptive-rose-700-300", + }; + case "off": + return { + label: PRISM_STATE_LABELS.off, + pillClassName: "bg-adaptive-neutral-500-a10-a16", + textClassName: "text-adaptive-neutral-600-300", + }; + } +} + +export interface PrismStatusLine { + readonly label: string; + readonly value: string; +} + +/** "CLIProxyAPI v7.2.147 (bundled)": the engine behind Prism, named only here. */ +export function describePrismEngine(status: Pick): string { + const release = status.version ? ` v${status.version}` : ""; + return `CLIProxyAPI${release} (${status.mode === "external" ? "external" : "bundled"})`; +} + +/** + * The key/value rows under the state pill. `relative` renders an ISO + * timestamp as "5m" so the caller controls the clock. + */ +export function describePrismStatus( + status: PrismStatus, + relative: (iso: string) => string, +): ReadonlyArray { + const lines: Array = [ + { label: "Mode", value: status.mode === "external" ? "External" : "Sidecar" }, + { label: "Base URL", value: status.baseUrl ?? `port ${status.port}` }, + ]; + lines.push({ label: "Engine", value: describePrismEngine(status) }); + if (status.since) lines.push({ label: "Since", value: `${relative(status.since)} ago` }); + if (status.restarts !== undefined) { + lines.push({ label: "Restarts", value: String(status.restarts) }); + } + if (status.lastError) lines.push({ label: "Last error", value: status.lastError }); + const sync: Array = [status.role]; + if (status.lastSyncAt) sync.push(`synced ${relative(status.lastSyncAt)} ago`); + if (status.lastSyncError) sync.push(`error: ${status.lastSyncError}`); + lines.push({ label: "Sync", value: sync.join(" · ") }); + return lines; +} + +// Usage source + +/** `usageSource` arrived after the first release; a status without it means the default, on. */ +export function isPrismUsageSourceOn(status: Pick | null): boolean { + return status?.usageSource ?? true; +} + +export const PRISM_USAGE_SOURCE_LABEL = "Show pooled accounts on Usage → Limits"; + +export interface PrismUsageSourceState { + /** What the switch shows: the optimistic value while a save is in flight, else the server's. `null` until the status arrived. */ + readonly enabled: boolean | null; + /** The server's value to fall back to while a save is in flight; `null` when idle. */ + readonly rollback: boolean | null; + /** Why the last save failed; cleared on the next attempt. */ + readonly error: string | null; +} + +export const INITIAL_USAGE_SOURCE_STATE: PrismUsageSourceState = { + enabled: null, + rollback: null, + error: null, +}; + +export type PrismUsageSourceEvent = + /** Any status load; ignored while a save is in flight so a stale poll cannot undo the optimistic value. */ + | { readonly type: "status"; readonly status: PrismStatus } + | { readonly type: "toggle"; readonly enabled: boolean } + | { readonly type: "saved"; readonly status: PrismStatus } + | { readonly type: "saveFailed"; readonly error: string }; + +export function reducePrismUsageSource( + state: PrismUsageSourceState, + event: PrismUsageSourceEvent, +): PrismUsageSourceState { + switch (event.type) { + case "status": + if (state.rollback !== null) return state; + return { ...state, enabled: isPrismUsageSourceOn(event.status) }; + case "toggle": { + if (state.enabled === null || state.rollback !== null || state.enabled === event.enabled) { + return state; + } + return { enabled: event.enabled, rollback: state.enabled, error: null }; + } + case "saved": + return { enabled: isPrismUsageSourceOn(event.status), rollback: null, error: null }; + case "saveFailed": + if (state.rollback === null) return state; + return { enabled: state.rollback, rollback: null, error: event.error }; + } +} + +// Errors + +/** What a call resolved to when it did not succeed; `UnknownError` is a defect the runtime rejected with. */ +export type PrismCallError = PrismClientError | { readonly _tag: "UnknownError" }; + +export const ADMIN_ACCESS_REQUIRED = "Administrative access required"; + +export function isPrismPermissionError(error: PrismCallError): boolean { + return ( + error._tag === "EnvironmentScopeRequiredError" || error._tag === "EnvironmentAuthInvalidError" + ); +} + +export function describePrismUnavailable( + reason: PrismUnavailableReason, + state: PrismState, +): string { + switch (reason) { + case "flag-off": + return PRISM_OFF_HINT; + case "sidecar-not-ready": + return state === "failed" + ? `Prism failed to start. Check the server log and the prism section of ${FORK_CONFIG_FILENAME}.` + : `Prism is ${PRISM_STATE_LABELS[state].toLowerCase()}. Accounts appear once it is ready.`; + case "replica-read-only": + return "Manage pooled accounts on the primary environment. This gateway receives serving credentials only."; + case "sync-not-configured": + return `Cross-machine sync is not configured for this role. Set prism.sync in ${FORK_CONFIG_FILENAME}.`; + } +} + +/** Inline text for a failed call. Never includes a token or a management secret. */ +export function describePrismError(error: PrismCallError): string { + switch (error._tag) { + case "PrismUnavailableError": + return describePrismUnavailable(error.reason, error.state); + case "PrismUpstreamError": + return `Prism answered ${error.status}: ${error.message}`; + case "PrismNotFoundError": + case "PrismConfigError": + case "PrismSyncFailedError": + return error.message; + case "EnvironmentScopeRequiredError": + return `${ADMIN_ACCESS_REQUIRED} (${error.requiredScope ?? "access:write"} scope).`; + case "EnvironmentAuthInvalidError": + return `${ADMIN_ACCESS_REQUIRED}: this environment session is no longer valid. Pair again.`; + case "UnknownError": + return "The request failed unexpectedly."; + default: + return "Could not reach the environment."; + } +} + +// Accounts + +/** Sidecar provider keys are lowercase words; show the ones we know by name. */ +const ACCOUNT_PROVIDER_LABELS: Readonly> = { + claude: "Claude", + anthropic: "Claude", + codex: "Codex", + openai: "Codex", + gemini: "Gemini", + antigravity: "Antigravity", + xai: "Grok", + grok: "Grok", + kimi: "Kimi", +}; + +export function labelPrismProvider(provider: string): string { + return ACCOUNT_PROVIDER_LABELS[provider.toLowerCase()] ?? provider; +} + +/** The subtitle under an account's name: provider, weight, age, and counters when the sidecar reports them. */ +export function describePrismAccount( + account: PrismAccount, + relative: (iso: string) => string, +): string { + const parts = [labelPrismProvider(account.provider)]; + if (account.weight !== undefined) parts.push(`weight ${account.weight}`); + parts.push(relative(account.updatedAt)); + if (account.usage) parts.push(`${account.usage.success} ok · ${account.usage.failed} failed`); + return parts.join(" · "); +} + +type PendingAccountOperation = + | { readonly _tag: "toggle"; readonly previousDisabled: boolean } + | { readonly _tag: "remove" }; + +export interface PrismAccountsState { + /** `null` until the first list arrives; the list survives later load failures. */ + readonly accounts: ReadonlyArray | null; + /** Why the last load failed, shown above whatever list is still there. */ + readonly error: string | null; + /** In-flight per-account operations; a toggle remembers what to roll back to. */ + readonly pending: Readonly>; + /** The last failed operation per account; cleared on the next attempt. */ + readonly rowErrors: Readonly>; +} + +export const INITIAL_ACCOUNTS_STATE: PrismAccountsState = { + accounts: null, + error: null, + pending: {}, + rowErrors: {}, +}; + +export type PrismAccountsEvent = + | { readonly type: "loaded"; readonly accounts: ReadonlyArray } + | { readonly type: "loadFailed"; readonly error: string } + | { readonly type: "toggle"; readonly id: string; readonly disabled: boolean } + | { readonly type: "toggled"; readonly id: string; readonly account: PrismAccount } + | { readonly type: "toggleFailed"; readonly id: string; readonly error: string } + | { readonly type: "remove"; readonly id: string } + | { readonly type: "removed"; readonly id: string } + | { readonly type: "removeFailed"; readonly id: string; readonly error: string }; + +const without = (record: Readonly>, key: string): Record => { + const { [key]: _dropped, ...rest } = record; + return rest; +}; + +const replaceAccount = ( + accounts: ReadonlyArray, + id: string, + update: (account: PrismAccount) => PrismAccount, +): ReadonlyArray => + accounts.map((account) => (account.id === id ? update(account) : account)); + +export function reducePrismAccounts( + state: PrismAccountsState, + event: PrismAccountsEvent, +): PrismAccountsState { + switch (event.type) { + case "loaded": { + // A list fetched while a toggle is in flight may predate it; keep the optimistic value. + const accounts = event.accounts.map((account) => { + const pending = state.pending[account.id]; + return pending?._tag === "toggle" && account.disabled === pending.previousDisabled + ? { ...account, disabled: !pending.previousDisabled } + : account; + }); + return { ...state, accounts, error: null }; + } + case "loadFailed": + return { ...state, error: event.error }; + case "toggle": { + const current = state.accounts?.find((account) => account.id === event.id); + if (!current || state.pending[event.id] || current.disabled === event.disabled) return state; + return { + ...state, + accounts: replaceAccount(state.accounts ?? [], event.id, (account) => ({ + ...account, + disabled: event.disabled, + })), + pending: { + ...state.pending, + [event.id]: { _tag: "toggle", previousDisabled: current.disabled }, + }, + rowErrors: without(state.rowErrors, event.id), + }; + } + case "toggled": + if (state.pending[event.id]?._tag !== "toggle") return state; + return { + ...state, + accounts: replaceAccount(state.accounts ?? [], event.id, () => event.account), + pending: without(state.pending, event.id), + }; + case "toggleFailed": { + const pending = state.pending[event.id]; + if (pending?._tag !== "toggle") return state; + return { + ...state, + accounts: replaceAccount(state.accounts ?? [], event.id, (account) => ({ + ...account, + disabled: pending.previousDisabled, + })), + pending: without(state.pending, event.id), + rowErrors: { ...state.rowErrors, [event.id]: event.error }, + }; + } + case "remove": + if (state.pending[event.id]) return state; + return { + ...state, + pending: { ...state.pending, [event.id]: { _tag: "remove" } }, + rowErrors: without(state.rowErrors, event.id), + }; + case "removed": + return { + ...state, + accounts: (state.accounts ?? []).filter((account) => account.id !== event.id), + pending: without(state.pending, event.id), + rowErrors: without(state.rowErrors, event.id), + }; + case "removeFailed": + if (state.pending[event.id]?._tag !== "remove") return state; + return { + ...state, + pending: without(state.pending, event.id), + rowErrors: { ...state.rowErrors, [event.id]: event.error }, + }; + } +} + +// Add account + +export const PRISM_LOGIN_PROVIDERS: ReadonlyArray<{ + readonly value: PrismLoginProvider; + readonly label: string; +}> = [ + { value: "codex", label: "Codex" }, + { value: "anthropic", label: "Claude" }, + { value: "antigravity", label: "Antigravity" }, + { value: "xai", label: "Grok" }, + { value: "kimi", label: "Kimi" }, +]; + +export function labelPrismLoginProvider(provider: PrismLoginProvider): string { + return PRISM_LOGIN_PROVIDERS.find((entry) => entry.value === provider)?.label ?? provider; +} + +export type PrismLoginFlowState = + | { readonly _tag: "idle" } + | { readonly _tag: "starting"; readonly provider: PrismLoginProvider } + | { + readonly _tag: "pending"; + readonly provider: PrismLoginProvider; + readonly sessionId: string; + readonly authUrl: string; + readonly flow: PrismLoginStarted["flow"]; + readonly userCode: string | null; + /** A pasted redirect URL is in flight; polling keeps going meanwhile. */ + readonly submittingRedirect: boolean; + /** The last pasted redirect the sidecar rejected; cleared on the next paste. */ + readonly redirectError: string | null; + } + | { + readonly _tag: "completed"; + readonly provider: PrismLoginProvider; + readonly accountId: string | null; + } + | { readonly _tag: "failed"; readonly provider: PrismLoginProvider; readonly error: string } + | { readonly _tag: "cancelled"; readonly provider: PrismLoginProvider }; + +export type PrismLoginFlowEvent = + | { readonly type: "start"; readonly provider: PrismLoginProvider } + | { readonly type: "started"; readonly started: PrismLoginStarted } + | { readonly type: "startFailed"; readonly error: string } + /** A poll, callback, or cancel answer. Answers for another session are ignored. */ + | { readonly type: "status"; readonly status: PrismLoginStatus } + | { readonly type: "pasteRedirect" } + | { readonly type: "redirectFailed"; readonly sessionId: string; readonly error: string } + | { readonly type: "cancel" } + | { readonly type: "reset" }; + +export const IDLE_LOGIN_FLOW: PrismLoginFlowState = { _tag: "idle" }; + +const GENERIC_LOGIN_FAILURE = "The sign-in did not complete."; + +export function reducePrismLoginFlow( + state: PrismLoginFlowState, + event: PrismLoginFlowEvent, +): PrismLoginFlowState { + switch (event.type) { + case "start": + // A flow already waiting on the browser keeps its session; cancel first. + if (state._tag === "starting" || state._tag === "pending") return state; + return { _tag: "starting", provider: event.provider }; + case "started": + if (state._tag !== "starting") return state; + return { + _tag: "pending", + provider: state.provider, + sessionId: event.started.sessionId, + authUrl: event.started.authUrl, + flow: event.started.flow, + userCode: event.started.userCode ?? null, + submittingRedirect: false, + redirectError: null, + }; + case "startFailed": + if (state._tag !== "starting") return state; + return { _tag: "failed", provider: state.provider, error: event.error }; + case "status": { + if (state._tag !== "pending" || state.sessionId !== event.status.sessionId) return state; + switch (event.status.status) { + case "pending": + return state.submittingRedirect ? { ...state, submittingRedirect: false } : state; + case "completed": + return { + _tag: "completed", + provider: state.provider, + accountId: event.status.accountId ?? null, + }; + case "failed": + return { + _tag: "failed", + provider: state.provider, + error: event.status.error ?? GENERIC_LOGIN_FAILURE, + }; + case "cancelled": + return { _tag: "cancelled", provider: state.provider }; + } + return state; + } + case "pasteRedirect": + if (state._tag !== "pending" || state.submittingRedirect) return state; + return { ...state, submittingRedirect: true, redirectError: null }; + case "redirectFailed": + if (state._tag !== "pending" || state.sessionId !== event.sessionId) return state; + return { ...state, submittingRedirect: false, redirectError: event.error }; + case "cancel": + // Optimistic: polling stops at once, and a late "cancelled" answer for + // the old session is dropped by the session check above. + if (state._tag !== "pending" && state._tag !== "starting") return state; + return { _tag: "cancelled", provider: state.provider }; + case "reset": + return IDLE_LOGIN_FLOW; + } +} + +/** The session the screen should keep polling, if any. */ +export function pendingPrismLoginSession(state: PrismLoginFlowState): string | null { + return state._tag === "pending" ? state.sessionId : null; +} + +// Routing + +export const PRISM_ROUTING_OPTIONS: ReadonlyArray<{ + readonly value: PrismRoutingStrategy; + readonly label: string; +}> = [ + { value: "round-robin", label: "Round robin" }, + { value: "weighted-round-robin", label: "Weighted" }, + { value: "fill-first", label: "Fill first" }, +]; + +// Polling + +export const PRISM_STATUS_POLL_MS = 10_000; +export const PRISM_LOGIN_POLL_MS = 2_000; +export const PRISM_RESTART_POLL_MS = 2_000; +export const PRISM_RESTART_TIMEOUT_MS = 30_000; + +/** Background status polling only costs a request while someone can see the answer. */ +export function shouldPollPrismStatus(input: { + readonly focused: boolean; + readonly appState: string; +}): boolean { + return input.focused && input.appState === "active"; +} + +/** After a restart: keep polling until the proxy settles, or give up at the deadline. */ +export function nextRestartStep(input: { + readonly state: PrismState; + readonly elapsedMs: number; +}): "settled" | "poll" | "timeout" { + if (input.state === "ready" || input.state === "failed") return "settled"; + return input.elapsedMs >= PRISM_RESTART_TIMEOUT_MS ? "timeout" : "poll"; +} + +// Settings row + +export type PrismOverview = + | { readonly _tag: "loading" } + | { readonly _tag: "error" } + | { + readonly _tag: "loaded"; + readonly state: PrismState; + /** `null` when the list could not be fetched (proxy not ready, no scope). */ + readonly accountCount: number | null; + }; + +/** + * The trailing value of the "Accounts" row: the pooled count once every + * environment answered, else the state that explains why there is no count. + * `undefined` keeps the row's value slot empty while answers are still coming. + */ +export function summarizePrismOverviews( + overviews: ReadonlyArray, +): string | undefined { + if (overviews.length === 0 || overviews.some((overview) => overview._tag === "loading")) { + return undefined; + } + const loaded = overviews.filter( + (overview): overview is Extract => + overview._tag === "loaded", + ); + const counted = loaded.filter((overview) => overview.accountCount !== null); + if (counted.length > 0) { + const total = counted.reduce((sum, overview) => sum + (overview.accountCount ?? 0), 0); + return total === 1 ? "1 account" : `${total} accounts`; + } + const notReady = loaded.find((overview) => overview.state !== "ready"); + if (notReady) return PRISM_STATE_LABELS[notReady.state]; + return loaded.length > 0 ? "Unavailable" : "Unreachable"; +} diff --git a/apps/mobile/src/fork/prism/usePrismApi.ts b/apps/mobile/src/fork/prism/usePrismApi.ts new file mode 100644 index 000000000000..f1f705a56e27 --- /dev/null +++ b/apps/mobile/src/fork/prism/usePrismApi.ts @@ -0,0 +1,89 @@ +/** + * The accounts client bound to one environment's prepared connection. Every + * call resolves to a plain result so the screen never `try`s: typed failures + * land in `error`, and only a defect becomes `UnknownError`. + */ +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { + cancelPrismLogin, + type PrismAccountId, + type PrismAccountPatch, + type PrismClientError, + type PrismClientInput, + type PrismLoginProvider, + completePrismLogin, + deletePrismAccount, + getPrismLoginStatus, + getPrismRouting, + getPrismStatus, + listPrismAccounts, + patchPrismAccount, + restartPrism, + setPrismRouting, + setPrismUsageSource, + startPrismLogin, +} from "@t3tools/client-runtime/fork"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { HttpClient } from "effect/unstable/http"; +import { useMemo } from "react"; + +import { runtime } from "../../lib/runtime"; +import { usePreparedConnection } from "../../state/session"; +import type { PrismCallError } from "./prismSettings.logic"; + +export type PrismResult = + | { readonly _tag: "ok"; readonly value: A } + | { readonly _tag: "error"; readonly error: PrismCallError }; + +type Call = ( + input: PrismClientInput, +) => Effect.Effect; + +export function bindPrismCalls(prepared: PrismClientInput["prepared"]) { + const run = (call: Call): Promise> => + runtime + .runPromise( + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + return yield* call({ prepared, signer }); + }).pipe( + Effect.match({ + onFailure: (error): PrismResult => ({ _tag: "error", error }), + onSuccess: (value): PrismResult => ({ _tag: "ok", value }), + }), + ), + ) + .catch((): PrismResult => ({ _tag: "error", error: { _tag: "UnknownError" } })); + + return { + status: () => run(getPrismStatus), + restart: () => run(restartPrism), + listAccounts: () => run(listPrismAccounts), + startLogin: (provider: PrismLoginProvider) => + run((input) => startPrismLogin({ ...input, provider })), + loginStatus: (sessionId: string) => + run((input) => getPrismLoginStatus({ ...input, sessionId })), + completeLogin: (sessionId: string, redirectUrl: string) => + run((input) => completePrismLogin({ ...input, sessionId, redirectUrl })), + cancelLogin: (sessionId: string) => run((input) => cancelPrismLogin({ ...input, sessionId })), + patchAccount: (id: PrismAccountId, patch: PrismAccountPatch) => + run((input) => patchPrismAccount({ ...input, id, patch })), + deleteAccount: (id: PrismAccountId) => run((input) => deletePrismAccount({ ...input, id })), + getRouting: () => run(getPrismRouting), + setRouting: (strategy: PrismRoutingStrategy) => + run((input) => setPrismRouting({ ...input, strategy })), + setUsageSource: (enabled: boolean) => + run((input) => setPrismUsageSource({ ...input, enabled })), + }; +} + +export type PrismApi = ReturnType; + +/** `null` until the environment has a prepared connection. */ +export function usePrismApi(environmentId: EnvironmentId): PrismApi | null { + const prepared = Option.getOrNull(usePreparedConnection(environmentId)); + return useMemo(() => (prepared ? bindPrismCalls(prepared) : null), [prepared]); +} diff --git a/apps/mobile/src/fork/useForkFlag.ts b/apps/mobile/src/fork/useForkFlag.ts new file mode 100644 index 000000000000..d111a1f85808 --- /dev/null +++ b/apps/mobile/src/fork/useForkFlag.ts @@ -0,0 +1,9 @@ +import { readForkFlag, type ForkFlagKey } from "@t3tools/client-runtime/fork"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useEnvironmentServerConfig } from "../state/entities"; + +/** Value of a fork flag on one environment; registry default until its config arrives. */ +export function useForkFlag(environmentId: EnvironmentId | null, key: ForkFlagKey): boolean { + const config = useEnvironmentServerConfig(environmentId); + return readForkFlag(config?.environment.capabilities, key); +} diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 3458f0120f99..417a66138d95 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,19 +2,13 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, resolveMobileCodeSurface, resolveNativeMarkdownTypography, resolveTextScaleVariables, - stepBaseFontSize, - stepCodeFontSize, stepTerminalFontSize, } from "./appearancePreferences"; @@ -50,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -69,15 +61,12 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); - it("steps font sizes within bounds", () => { + it("steps terminal font size within bounds", () => { expect(stepTerminalFontSize(6, -1)).toBe(6); - expect(stepBaseFontSize(11, -1)).toBe(11); - expect(stepCodeFontSize(8, -1)).toBe(8); - expect(stepBaseFontSize(15, 1)).toBe(16); }); it("scales markdown typography from the base size", () => { @@ -97,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dda..2ce6a8b5a367 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } @@ -235,22 +235,12 @@ export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMar }; } -export function stepBaseFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; - return normalizeBaseFontSize(next); -} - export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next); } -export function stepCodeFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; - return normalizeCodeFontSize(next); -} - export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, diff --git a/apps/mobile/src/lib/commandMetadata.test.ts b/apps/mobile/src/lib/commandMetadata.test.ts deleted file mode 100644 index d1ba1d86eba9..000000000000 --- a/apps/mobile/src/lib/commandMetadata.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { makeQueuedMessageMetadata, makeTurnCommandMetadata } from "./commandMetadata"; - -vi.mock("expo-crypto", () => ({ - randomUUID: () => crypto.randomUUID(), -})); - -describe("mobile command metadata", () => { - it("creates ids and timestamps for thread starts", () => { - const metadata = makeTurnCommandMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.threadId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it("creates ids and timestamps for queued messages", () => { - const metadata = makeQueuedMessageMetadata(); - - expect(metadata.commandId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.messageId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - expect(metadata.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); -}); diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index 6b040b698e3d..2ebacc740891 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -230,6 +230,24 @@ describe("draft upload scope and offline submission", () => { ); }); + it("reads an id-keyed new-task draft's environment from its project stamp", () => { + const stamped = { + project: { + environmentId, + projectId: "project" as never, + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + expect(composerDraftEnvironmentId("new-task:abc123-def456", [], stamped)).toBe(environmentId); + // An id-keyed draft that lost its stamp belongs to nobody: uploads must + // not start and sign-out must not sweep it into some other environment. + expect(composerDraftEnvironmentId("new-task:abc123-def456", [])).toBeNull(); + // The stamp wins over a legacy-looking key when both are present. + expect(composerDraftEnvironmentId("new-task:environment-2:project", [], stamped)).toBe( + environmentId, + ); + }); + it("allows offline queuing while a connected composer waits for upload or retry", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts index 071afefa4c7d..538b343abeb0 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.ts @@ -1,6 +1,7 @@ import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { parseLegacyNewTaskDraftKey } from "../state/new-task-draft-key"; import type { DraftComposerAttachment } from "./composerImages"; export interface ComposerAttachmentUploadRequest { @@ -20,12 +21,19 @@ export function composerAttachmentUploadKey( return `${environmentId}:${attachmentId}`; } +/** + * Which environment a composer draft belongs to. Thread drafts carry it in + * the key; pending-task editor drafts borrow it from the queued message; + * new-task drafts carry it in their project stamp (legacy project-keyed + * new-task drafts still parse from the key until they are migrated on load). + */ export function composerDraftEnvironmentId( draftKey: string, queuedMessages: ReadonlyArray<{ readonly messageId: string; readonly environmentId: EnvironmentId; }>, + draft?: { readonly project?: { readonly environmentId: EnvironmentId } }, ): EnvironmentId | null { if (draftKey.startsWith("pending-task:")) { return ( @@ -33,9 +41,15 @@ export function composerDraftEnvironmentId( ?.environmentId ?? null ); } - const scope = draftKey.startsWith("new-task:") ? draftKey.slice("new-task:".length) : draftKey; - const separator = scope.lastIndexOf(":"); - return separator > 0 ? EnvironmentId.make(scope.slice(0, separator)) : null; + if (draftKey.startsWith("new-task:")) { + if (draft?.project) { + return draft.project.environmentId; + } + const legacy = parseLegacyNewTaskDraftKey(draftKey); + return legacy === null ? null : EnvironmentId.make(legacy.environmentId); + } + const separator = draftKey.lastIndexOf(":"); + return separator > 0 ? EnvironmentId.make(draftKey.slice(0, separator)) : null; } type UploadServerConfig = { diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6487e572c87d..6cc9ce8607d1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; -import { - isRelayManagedConnection, - redactPairingCredential, - toStableSavedRemoteConnection, -} from "./connection"; +import { isRelayManagedConnection, toStableSavedRemoteConnection } from "./connection"; import { authClientMetadata } from "./authClientMetadata"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); @@ -83,23 +79,6 @@ describe("mobile remote connection records", () => { }); }); - it("removes one-time bootstrap credentials before persisting pairing URLs", () => { - expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - }); - - it("removes hosted pairing credentials while keeping the advertised host", () => { - expect( - redactPairingCredential( - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop", - ), - ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); - }); - it("recognizes explicitly managed relay connections", () => { expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index df26a192cd0f..5919a805ddd2 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,5 +1,4 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; export interface SavedRemoteConnection { @@ -17,15 +16,6 @@ export interface SavedRemoteConnection { export type RemoteClientConnectionState = EnvironmentConnectionPhase; -export function redactPairingCredential(pairingUrl: string): string { - const trimmed = pairingUrl.trim(); - try { - return stripPairingTokenFromUrl(new URL(trimmed)).toString(); - } catch { - return trimmed; - } -} - export function isRelayManagedConnection( connection: Pick, ): boolean { diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8342fd1aeebc..7d288b1352a5 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -75,12 +73,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -392,14 +384,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4199dc8dc8ed..ee38ac020e74 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -16,7 +16,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -50,10 +49,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -218,22 +213,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -275,20 +254,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts index bf3d009b74ab..7d02cb71f7b5 100644 --- a/apps/mobile/src/lib/markdownLinks.test.ts +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; +import { + resolveMarkdownLinkIcon, + resolveMarkdownLinkPresentation, +} from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkIcon", () => { + it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { + expect(resolveMarkdownLinkIcon("github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("github.community")).toBeNull(); + expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull(); + expect(resolveMarkdownLinkIcon("example.com")).toBeNull(); + }); +}); describe("resolveMarkdownLinkPresentation", () => { it("treats protocol-relative media as an external URL, not a filesystem path", () => { diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index a3c6712abae8..652de9296f1f 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,10 +153,16 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(65); + expect(Object.keys(variables)).toHaveLength(68); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); + expect(variables["--color-warning"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + ); + expect(variables["--color-warning-foreground"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 23034511287e..10ef1b58edec 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -239,6 +239,9 @@ export function createMobileThemeVariables( "--color-switch-active-thumb": c.accentForeground, "--color-switch-inactive-track": c.secondary, "--color-switch-inactive-thumb": c.mutedForeground, + "--color-warning": c.warningSurface, + "--color-warning-border": withAlpha(c.warning, 0.32), + "--color-warning-foreground": c.warningForeground, "--color-danger": c.errorSurface, "--color-danger-border": withAlpha(c.error, 0.32), "--color-danger-foreground": c.errorForeground, diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts new file mode 100644 index 000000000000..adde56fbf0b1 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { downscaleProjectFavicon } from "./projectFaviconCache"; + +const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts new file mode 100644 index 000000000000..26a6d848d11d --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,112 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, +} from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; + try { + signal.throwIfAborted(); + if (decoded.width > size || decoded.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(decoded, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + decoded.release(); + } + } + throw new Error("Project icon thumbnail exceeds the cache limit."); +} + +/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d87df6baaf1d..9b94cecb3db9 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; -import { - applyProviderOptionSelection, - providerOptionValueLabels, - resolveProviderOptionDescriptors, -} from "./providerOptions"; +import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -34,15 +30,6 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("summarizes the option values currently in effect", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); - }); - it("updates generic select options without knowing provider-specific ids", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, @@ -62,7 +49,7 @@ describe("mobile provider options", () => { expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); - it("treats an unspecified boolean capability as off", () => { + it("updates generic boolean options", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: { optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], @@ -70,7 +57,6 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(providerOptionValueLabels(descriptors)).toEqual([]); expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ { id: "fastMode", value: true }, ]); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 593f5a37442c..dec0d327030d 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, - getProviderOptionCurrentLabel, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -22,23 +21,6 @@ export function resolveProviderOptionDescriptors(input: { }); } -/** - * Labels for the option values currently in effect (select values plus - * enabled booleans), used to summarize the thread configuration in the - * composer trigger pill. - */ -export function providerOptionValueLabels( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.flatMap((descriptor) => { - if (descriptor.type === "boolean") { - return descriptor.currentValue ? [descriptor.label] : []; - } - const label = getProviderOptionCurrentLabel(descriptor); - return label ? [label] : []; - }); -} - /** * Applies one option change (by descriptor id) and returns the full selection * list to store on the model selection, or null when the change doesn't match diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df3dd3197d0d..03fb7118b553 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,5 +1,5 @@ +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { describe, expect, it } from "vite-plus/test"; -import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -13,46 +13,19 @@ import { } from "@t3tools/contracts"; import { + agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, - derivePendingApprovals, - derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, + workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, + type WorkLogEntry, } from "./threadActivity"; -describe("Codex feedback pseudo-messages", () => { - it("keeps pending and completed feedback messages in the mobile thread body", () => { - const pending = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: "2026-08-23T00:00:00.000Z", - status: "uploading" as const, - }; - const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( - (message) => ({ - type: "message" as const, - id: message.id, - createdAt: message.createdAt, - message, - }), - ); - - expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); - expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); - - const completed = codexFeedbackMessage( - { ...pending, status: "sent", feedbackId: "codex-thread-1" }, - "assistant", - ); - expect(completed.text).toContain("codex-thread-1"); - }); -}); - const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -104,7 +77,7 @@ describe("pending user input answers", () => { createdAt: "2026-09-03T00:00:00.000Z", payload: { requestId: "async-1", responseMode: "message", questions: [question] }, }); - const questions = derivePendingUserInputs([requested])[0]?.questions; + const questions = derivePendingRequests([requested]).userInputs[0]?.questions; expect(questions).toEqual([question]); expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ "0": "Example", @@ -123,7 +96,7 @@ describe("pending user input answers", () => { }, }); - expect(derivePendingUserInputs([requested])).toEqual([ + expect(derivePendingRequests([requested]).userInputs).toEqual([ { requestId: "interaction_1", createdAt: requested.createdAt, @@ -262,121 +235,6 @@ describe("pending user input answers", () => { }); }); -describe("pending approvals", () => { - it.each([{}, { requestType: "unknown" }])( - "exposes legacy OpenCode approvals without a known request kind: %j", - (legacyPayload) => { - const requested = makeActivity({ - id: EventId.make("approval-legacy"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, - }); - - expect(derivePendingApprovals([requested])).toEqual([ - { - requestId: "per-legacy", - requestKind: "command", - createdAt: requested.createdAt, - detail: "*", - }, - ]); - }, - ); - - it.each(["tool_user_input", "auth_tokens_refresh"])( - "does not turn %s into an approval", - (requestType) => { - const activity = makeActivity({ - id: EventId.make("approval-non-approval"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "not-an-approval", requestType }, - }); - - expect(derivePendingApprovals([activity])).toEqual([]); - }, - ); - - it.each(["approval.resolved", "provider.approval.respond.failed"])( - "removes legacy approvals after %s", - (kind) => { - const requested = makeActivity({ - id: EventId.make("approval-legacy-open"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "per-legacy", requestType: "unknown" }, - }); - const resolved = makeActivity({ - id: EventId.make("approval-legacy-resolved"), - kind, - summary: "Approval resolved", - createdAt: "2026-08-24T00:00:01.000Z", - payload: { - requestId: "per-legacy", - detail: "Unknown pending permission request: per-legacy", - }, - }); - - expect(derivePendingApprovals([requested, resolved])).toEqual([]); - }, - ); - - it("keeps app access approvals and persistence choices from remote environments", () => { - const options = [ - { decision: "decline", label: "Decline" }, - { decision: "acceptAlways", label: "Always allow Safari" }, - { decision: "accept", label: "Approve" }, - ]; - const activity = makeActivity({ - id: EventId.make("approval-safari"), - kind: "approval.requested", - summary: "App access approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { - requestId: "req-safari", - requestType: "mcp_elicitation_approval", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - }); - - expect(derivePendingApprovals([activity])).toEqual([ - { - requestId: "req-safari", - requestKind: "mcp-elicitation", - createdAt: "2026-08-24T00:00:00.000Z", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - ]); - }); - - it("removes an app access approval after a remote client rejects it", () => { - const requested = makeActivity({ - id: EventId.make("approval-safari-open"), - kind: "approval.requested", - summary: "App access approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "req-safari", requestKind: "mcp-elicitation" }, - }); - const resolved = makeActivity({ - id: EventId.make("approval-safari-resolved"), - kind: "approval.resolved", - summary: "Approval resolved", - createdAt: "2026-08-24T00:00:01.000Z", - payload: { requestId: "req-safari", decision: "decline" }, - }); - - expect(derivePendingApprovals([requested, resolved])).toEqual([]); - }); -}); - function makeActivity( input: Partial & Pick, @@ -702,6 +560,95 @@ describe("buildThreadFeed", () => { const [row] = group.activities; expect(row?.workEntry.detail).toBe(command); expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + // Opening it would only repeat the command the row already shows. + expect(row?.canExpand).toBe(false); + }); + + it.each([ + { + name: "a task summary that is its own detail", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "bh2p996o4", + status: "completed", + title: "Check CI on the new head", + summary: "Check CI on the new head", + detail: "Check CI on the new head", + agentKind: "background", + taskType: "local_bash", + }, + }, + label: "Check CI on the new head", + canExpand: false, + }, + { + name: "a runtime warning with only its message", + activity: { + kind: "runtime.warning" as const, + tone: "info" as const, + summary: "Bash is unusable in this environment", + payload: { detail: "Bash is unusable in this environment" }, + }, + label: "Bash is unusable in this environment", + canExpand: false, + }, + { + name: "a multi-line task report", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "bpxcizf97", + status: "completed", + title: "Audit the PR", + detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", + agentKind: "background", + taskType: "local_bash", + }, + }, + label: "**Tooling note:** Bash is unusable. # Audit No blockers.", + canExpand: true, + }, + { + name: "a command whose output differs from the command", + activity: { + kind: "tool.completed" as const, + tone: "tool" as const, + summary: "Command run", + payload: { + itemType: "command_execution", + title: "Command run", + detail: "Bash: printf hello", + data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } }, + }, + }, + label: "printf hello", + canExpand: true, + }, + ])("only lets $name expand when the body adds something: $canExpand", (input) => { + const thread = makeThread({ + id: ThreadId.make("thread-expand-rule"), + projectId: ProjectId.make("project-1"), + title: "Expand rule", + activities: [ + makeActivity({ + id: EventId.make("expand-rule"), + createdAt: "2026-09-01T00:00:00.000Z", + ...input.activity, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(workEntryRowLabel(row!.workEntry)).toBe(input.label); + expect(row?.canExpand).toBe(input.canExpand); }); it("drops a truncated Claude echo of a long command", () => { @@ -903,44 +850,6 @@ describe("buildThreadFeed", () => { }, ); - it("keeps older local feedback before newer messages returned by the server", () => { - const submission = { - id: MessageId.make("feedback-command-ordering"), - command: "/feedback The agent stopped early.", - createdAt: "2026-08-23T00:00:01.000Z", - status: "sent" as const, - feedbackId: "codex-thread-1", - }; - const laterMessage = { - id: MessageId.make("later-server-message"), - role: "assistant" as const, - text: "Newer server response", - turnId: null, - createdAt: "2026-08-23T00:00:02.000Z", - updatedAt: "2026-08-23T00:00:02.000Z", - streaming: false, - }; - const thread = makeThread({ - id: ThreadId.make("thread-feedback-ordering"), - projectId: ProjectId.make("project-1"), - title: "Feedback ordering", - messages: [laterMessage], - }); - - const feed = buildThreadFeed(thread, { - localMessages: [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ], - }); - - expect(feed.map((entry) => entry.id)).toEqual([ - "feedback-command-ordering", - "feedback-command-ordering:feedback", - "later-server-message", - ]); - }); - it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), @@ -1566,7 +1475,8 @@ describe("buildThreadFeed", () => { summaryToolIcon: "browser", hasFailure, live: true, - shimmer: false, + // A successful trailing call keeps shining; a failure hands off to "Thinking". + shimmer: !hasFailure, }, { type: "activity-group", @@ -1580,6 +1490,7 @@ describe("buildThreadFeed", () => { }, ], }, + ...(hasFailure ? [{ type: "thinking", turnId }] : []), ]); const terminalGroup = terminalRows[1]; if (terminalGroup?.type !== "activity-group") return; @@ -2128,7 +2039,7 @@ describe("buildThreadFeed", () => { ( [ { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, + { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true }, { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, @@ -2211,10 +2122,12 @@ describe("buildThreadFeed", () => { new Set(), latestTurn.startedAt, ); + // The shimmering row is the turn's live slot; once it stops shimmering + // the slot belongs to "Thinking" and the group keeps its own identity. expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ ["work-toggle:work-group:activity-1", "work-toggle"], ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], + [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"], ]); expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ false, @@ -2228,8 +2141,12 @@ describe("buildThreadFeed", () => { shimmer, }); expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + // Exactly one live activity: the shimmering call, or "Thinking" once it fails. + expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1); + expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking"); const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false); expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ { live: false, shimmer: false }, { @@ -2253,6 +2170,182 @@ describe("buildThreadFeed", () => { }, ); + it("shows one Thinking row while a turn works without live tool activity", () => { + const turnId = TurnId.make("turn-thinking"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-thinking"), + projectId: ProjectId.make("project-1"), + title: "Thinking", + latestTurn, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "hello", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ], + }), + ); + + const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); + expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); + expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId }); + // The row identity is stable across re-derivations so the list can reuse it. + expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( + rows[1], + ); + // Idle threads show no live activity. + expect( + deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map( + (entry) => entry.type, + ), + ).toEqual(["message"]); + }); + + it("keeps one live slot while calls fail and restart", () => { + // Recorded from a Claude session whose Bash was broken: every call went + // inProgress → failed within two seconds. Each transition used to insert + // or remove a Thinking row under the group; now the same row id holds + // the live call and then "Thinking", so the list updates it in place. + const turnId = TurnId.make("turn-failing-calls"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const call = (n: number, status: "inProgress" | "failed") => + makeActivity({ + id: EventId.make(`call-${n}-${status}`), + kind: status === "failed" ? "tool.completed" : "tool.updated", + tone: "tool", + summary: "Command run", + createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`, + turnId, + payload: { + itemType: "command_execution", + toolCallId: `call-${n}`, + title: "Command run", + status, + detail: `Bash: ls ${n}`, + }, + }); + const liveIds = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-failing-calls"), + projectId: ProjectId.make("project-1"), + title: "Failing calls", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ).map((row) => `${row.type}:${row.id}`); + + expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]); + expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "thinking:live-activity-row", + ]); + expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([ + "work-toggle:live-activity-row", + ]); + // A call whose end was never reported, in a run before an error row, + // keeps its own identity: only the trailing run can hold the live slot. + const errorRow = makeActivity({ + id: EventId.make("runtime-error"), + kind: "runtime.error", + tone: "error", + summary: "Provider error", + createdAt: "2026-04-01T00:00:02.500Z", + turnId, + payload: { message: "boom" }, + }); + expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "activity-group:runtime-error", + "work-toggle:live-activity-row", + ]); + }); + + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { + const turnId = TurnId.make("turn-streaming-tail"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-streaming-tail"), + projectId: ProjectId.make("project-1"), + title: "Streaming tail", + latestTurn, + messages: [ + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "Here is what I found", + turnId, + streaming: true, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("read-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read file", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + itemType: "file_read", + toolCallId: "read-1", + title: "Read file", + status: "completed", + detail: "src/index.ts", + }, + }), + ], + }), + ); + + const rows = deriveThreadFeedPresentation( + feed, + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + }); + it("preserves serialized shell wrappers with non-matching boundary quotes", () => { const turnId = TurnId.make("turn-serialized-shell-wrapper"); const command = @@ -2546,16 +2639,295 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // The agent folds into its spawn batch, which stays live after a resume. expect(rows).toMatchObject([ { lifecycleStatus: "inProgress", - summary: "Reviewer", - workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" }, + summary: "Kicked off 1 subagent · 1 working", + workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } }, }, ]); }, ); + it("folds a turn's direct spawns into one batch row that tracks their states", () => { + const turnId = TurnId.make("turn-spawn"); + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed" | "task.updated", + taskId: string, + status: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${taskId} ${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + status, + ...extra, + }, + }); + const shell = makeActivity({ + id: EventId.make("shell-1"), + kind: "task.completed", + summary: "Task completed", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + taskId: "sh-1", + agentKind: "background", + taskType: "local_bash", + status: "completed", + title: "Run tests", + detail: "Run tests", + }, + }); + const activities = [ + agent("a-start", "task.started", "a", "running", 1), + agent("b-start", "task.started", "b", "running", 2), + agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }), + shell, + agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }), + ]; + const rowsFor = (extraActivities: ReadonlyArray>) => + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn"), + projectId: ProjectId.make("project-1"), + title: "Spawns", + activities: [...activities, ...extraActivities], + }), + ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + + // The batch anchors on the first task.started: a fixed id and timestamp, + // unlike progress ticks (which the server rewrites in place). + const running = rowsFor([]); + expect(running.map((row) => [row.id, row.summary])).toEqual([ + ["a-start", "Kicked off 2 subagents · 2 working"], + ["shell-1", "Run tests"], + ]); + expect(running[0]).toMatchObject({ + createdAt: "2026-04-01T00:00:01.000Z", + lifecycleStatus: "inProgress", + workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, + }); + + const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); + expect(oneDone[0]).toMatchObject({ + id: "a-start", + summary: "Kicked off 2 subagents · 1 working", + lifecycleStatus: "inProgress", + }); + + const allDone = rowsFor([ + agent("a-done", "task.completed", "a", "completed", 7), + agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), + ]); + expect(allDone[0]).toMatchObject({ + id: "a-start", + summary: "Ran 2 subagents · 1 failed", + lifecycleStatus: "failed", + status: "failure", + }); + expect(allDone).toHaveLength(2); + }); + + it("folds the tool call that launched an agent into its spawn card", () => { + const turnId = TurnId.make("turn-agent-tool"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-agent-tool"), + projectId: ProjectId.make("project-1"), + title: "Agent tool", + activities: [ + makeActivity({ + id: EventId.make("agent-call-updated"), + kind: "tool.updated", + tone: "tool", + summary: "Subagent task", + createdAt: at(1), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "inProgress", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: EventId.make("agent-started"), + kind: "task.started", + summary: "Locate code", + createdAt: at(2), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + }, + }), + makeActivity({ + id: EventId.make("agent-done"), + kind: "task.completed", + summary: "Locate code", + createdAt: at(3), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + status: "completed", + }, + }), + makeActivity({ + id: EventId.make("agent-call-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Subagent task", + createdAt: at(4), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "completed", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + ], + }), + ); + const rows = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], + ); + expect(rows).toEqual(["agent-started"]); + expect( + deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type), + ).toEqual(["turn-fold", "agent-spawn"]); + }); + + it("presents a spawn batch as one card whose status line follows the newest member activity", () => { + const turnId = TurnId.make("turn-spawn-card"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed", + taskId: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `Agent ${taskId}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + ...extra, + }, + }); + const presentFor = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn-card"), + projectId: ProjectId.make("project-1"), + title: "Spawn card", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + + // A working card is the live activity; no Thinking row sits under it. + const single = presentFor([agent("a-start", "task.started", "a", 1)]); + expect(single.map((row) => row.type)).toEqual(["agent-spawn"]); + expect(single[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { title: "Agent a", status: "Working", tone: "working" }, + }); + + // The server upserts the progress row with a new createdAt each tick; + // the card keeps its identity and only the status line changes. + const tick = (seconds: number, detail: string) => + presentFor([ + agent("a-start", "task.started", "a", 1), + agent("task-progress:a", "task.progress", "a", seconds, { detail }), + ]); + expect(tick(2, "Reading a.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { title: "Agent a", status: "Reading a.ts", tone: "working" }, + }); + expect(tick(3, "Reading b.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { status: "Reading b.ts" }, + }); + + const batch = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + ]); + expect(batch[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { + title: "2 subagents", + status: "Grepping", + tone: "working", + members: [ + { title: "Agent a", status: "completed", tone: "completed" }, + { title: "Agent b", status: "working", tone: "working", detail: "Grepping" }, + ], + }, + }); + + const settled = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }), + ]); + expect(settled[0]).toMatchObject({ + type: "agent-spawn", + summary: { title: "2 subagents", status: "1 failed", tone: "failed" }, + }); + expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2603,19 +2975,195 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // Turn-less batches never share a spawn group, so each keeps its own row. expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ lifecycleStatus: status === "failed" ? "failed" : "stopped", - detail, - workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" }, + summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`, + workEntry: { + taskId: "trajectory:4", + toolTitle: "Antigravity subagent batch", + agentSpawn: { agents: [{ detail }] }, + }, }); + expect(rows[0]?.getFullDetail()).toContain(detail); expect(rows[1]).toMatchObject({ lifecycleStatus: "inProgress", + summary: "Kicked off 1 subagent · 1 working", workEntry: { taskId: "trajectory:5" }, }); }, ); + it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => { + const turnId = TurnId.make("turn-workflow"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const thread = makeThread({ + id: ThreadId.make("thread-workflow"), + projectId: ProjectId.make("project-1"), + title: "Workflow", + activities: [ + makeActivity({ + id: EventId.make("wf-progress"), + kind: "task.progress", + summary: "Workflow running", + createdAt: at(1), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + title: "review", + status: "running", + }, + }), + // Members are synthesized with timelineBypass and never render alone. + ...[0, 1].map((index) => + makeActivity({ + id: EventId.make(`member-${index}`), + kind: "task.progress", + summary: `Agent ${index}`, + createdAt: at(2 + index), + turnId, + payload: { + taskId: `wf-1:wf:${index}`, + agentKind: "agent", + title: `Reviewer ${index}`, + description: `Reviewer ${index}`, + status: index === 0 ? "completed" : "running", + parentAgentId: "wf-1", + timelineBypass: true, + }, + }), + ), + makeActivity({ + id: EventId.make("wf-done"), + kind: "task.completed", + summary: "Task completed", + createdAt: at(10), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + status: "completed", + title: "review", + }, + }), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + // The member that never reported its own end settles with the coordinator. + expect(rows[0]).toMatchObject({ + id: "wf-progress", + summary: "Ran 2 subagents · completed", + lifecycleStatus: "completed", + workEntry: { + agentSpawn: { + workflowId: "wf-1", + agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"], + }, + }, + }); + expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); + }); + + it("summarizes a spawn card from the newest member report and the batch outcome", () => { + type Member = NonNullable["agents"][number]; + const member = (title: string, status: Member["status"], detail: string, seconds: number) => + ({ + title, + status, + detail, + updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + }) satisfies Member; + const direct = (agents: ReadonlyArray) => ({ + workflowId: null, + agentTaskIds: agents.map((_, index) => `a${index}`), + agents, + }); + + // The newest report wins regardless of member order. + expect( + agentSpawnSummary( + direct([ + member("Agent 0", "inProgress", "Reading b.ts", 5), + member("Agent 1", "inProgress", "Reading a.ts", 2), + ]), + "inProgress", + ), + ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" }); + + // A declined request is a failed batch, not a completed one. + expect( + agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"), + ).toMatchObject({ status: "failed", tone: "failed" }); + + // A coordinator that failed on its own reports the failure even when every + // member succeeded; before any member reports, the card has a neutral title. + const workflow = (agents: ReadonlyArray) => ({ + workflowId: "wf", + agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)], + agents: [member("review", "failed", "", 9), ...agents], + }); + expect( + agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"), + ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" }); + expect( + agentSpawnSummary( + { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] }, + "inProgress", + ), + ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] }); + }); + + it("treats a Codex child's idle turn end as a finished batch member", () => { + const turnId = TurnId.make("turn-codex"); + const child = ( + id: string, + kind: "task.started" | "task.updated", + status: string, + seconds: number, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId: "child-1", + agentKind: "agent", + title: "math_one", + status, + timelineBypass: true, + }, + }); + const thread = makeThread({ + id: ThreadId.make("thread-codex"), + projectId: ProjectId.make("project-1"), + title: "Codex children", + activities: [ + child("c-start", "task.started", "running", 1), + child("c-running", "task.updated", "running", 2), + child("c-idle", "task.updated", "idle", 5), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + summary: "Ran 1 subagent · completed", + lifecycleStatus: "completed", + }); + }); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), @@ -2649,7 +3197,12 @@ describe("quiet timeline: nested agents", () => { expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ - { type: "activity-group", id: "nested-done" }, + { + type: "agent-spawn", + id: "agent-spawn:n-1", + activity: { id: "nested-done" }, + summary: { title: "Task completed", status: "completed", tone: "completed" }, + }, ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ef75fce56af4..42e0db669a73 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,9 +1,8 @@ import { - ApprovalRequestId, - isToolLifecycleItemType, - ProviderApprovalOption, - ProviderRequestKind, -} from "@t3tools/contracts"; + requestKindFromRequestType, + type PendingApproval, +} from "@t3tools/client-runtime/pending-requests"; +import { isToolLifecycleItemType } from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, @@ -16,6 +15,7 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { commandDetailRepeatsCommand, extractCommandOutputText, + extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, @@ -24,32 +24,19 @@ import { summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, + workEntryIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, type ToolGroupSummaryKind, + type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; -import * as Schema from "effect/Schema"; - -export interface PendingApproval { - readonly requestId: ApprovalRequestId; - readonly requestKind: ProviderRequestKind; - readonly createdAt: string; - readonly detail?: string; - readonly appName?: string; - readonly options?: ReadonlyArray; -} -const isProviderRequestKind = Schema.is(ProviderRequestKind); -const isProviderApprovalOption = Schema.is(ProviderApprovalOption); - -export interface PendingUserInput { - readonly requestId: ApprovalRequestId; - readonly createdAt: string; - readonly questions: ReadonlyArray; -} +export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; export interface PendingUserInputDraftAnswer { readonly selectedOptionValues?: ReadonlyArray; @@ -88,8 +75,6 @@ export interface ThreadFeedActivity { readonly live?: boolean; } -type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; - export interface WorkLogEntry { id: string; createdAt: string; @@ -110,7 +95,22 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; - agentSpawn?: boolean; + /** + * One row per workflow run or per-turn batch of direct spawns, like web's + * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row + * also carries each agent's terminal state to derive its status label. + */ + agentSpawn?: { + readonly workflowId: string | null; + readonly agentTaskIds: ReadonlyArray; + readonly agents: ReadonlyArray<{ + readonly title: string; + readonly status: WorkLogToolLifecycleStatus | undefined; + readonly detail: string | undefined; + /** When this member last reported, so the card can show the newest activity. */ + readonly updatedAt: string; + }>; + }; toolData?: unknown; } @@ -119,6 +119,11 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + /** The tool call that launched this agent, when the provider reports one. */ + agentSpawnToolCallId?: string; + isWorkflowCoordinator?: boolean; + /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ + isBackgroundTask?: boolean; } type RawThreadFeedEntry = @@ -169,8 +174,49 @@ export type ThreadFeedEntry = readonly turnId: TurnId; readonly label: string; readonly expanded: boolean; + } + | { + /** + * The turn's single live slot. Web keys its live tool row and its + * "Thinking" row identically so the slot updates in place; here the + * slot holds "Thinking" whenever no tool row is shimmering, so a tool + * failing does not insert a row under the group it lives in. + */ + readonly type: "thinking"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + } + | { + /** + * One batch of spawned subagents. Rendered as its own card because a + * single-line tool row has no room for what the agents are doing now, + * which on a phone is the one thing worth showing. + */ + readonly type: "agent-spawn"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + readonly activity: ThreadFeedActivity; + readonly expanded: boolean; + readonly summary: AgentSpawnSummary; }; +export interface AgentSpawnSummary { + /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ + readonly title: string; + /** Latest member activity while working, else the batch outcome. */ + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly members: ReadonlyArray<{ + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly detail: string | undefined; + readonly updatedAt: string; + }>; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" @@ -201,6 +247,7 @@ const turnFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract >(); +let cachedThinkingRow: Extract | null = null; export function isContextCompactionActivityGroup( entry: Extract, @@ -211,94 +258,6 @@ export function isContextCompactionActivityGroup( ); } -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - case "mcp_elicitation_approval": - return "mcp-elicitation"; - default: - return null; - } -} - -function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { - const normalized = detail?.toLowerCase(); - if (!normalized) { - return false; - } - return ( - normalized.includes("stale pending approval request") || - normalized.includes("stale pending user-input request") || - normalized.includes("unknown pending approval request") || - normalized.includes("unknown pending permission request") || - normalized.includes("unknown pending user-input request") - ); -} - -function parseApprovalRequestId(value: unknown): ApprovalRequestId | null { - return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null; -} - -function parseUserInputQuestions( - payload: Record | null, -): ReadonlyArray | null { - const questions = payload?.questions; - if (!Array.isArray(questions)) { - return null; - } - - const parsed = questions - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - const question = entry as Record; - if ( - typeof question.id !== "string" || - typeof question.header !== "string" || - typeof question.question !== "string" || - !Array.isArray(question.options) - ) { - return null; - } - const options = question.options - .map((option) => { - if (!option || typeof option !== "object") return null; - const record = option as Record; - if (typeof record.label !== "string" || typeof record.description !== "string") { - return null; - } - return { - label: record.label, - description: record.description, - ...(typeof record.value === "string" ? { value: record.value } : {}), - }; - }) - .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0 && question.allowCustomAnswer === false) { - return null; - } - return { - id: question.id, - header: question.header, - question: question.question, - options, - multiSelect: question.multiSelect === true, - ...(typeof question.allowCustomAnswer === "boolean" - ? { allowCustomAnswer: question.allowCustomAnswer } - : {}), - }; - }) - .filter((question): question is UserInputQuestion => question !== null); - - return parsed.length > 0 ? parsed : null; -} - function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; @@ -381,10 +340,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { /** * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal - * activity lives in the Agents sheet, not the work log. Terminal rows are - * kept — with no Agents surface on mobile they are the terminal signal - * (a surface that hides rows must keep its own terminal signal). That means - * task.completed and terminal task.updated, including Antigravity cancellation. + * activity lives in the Agents sheet, not the work log. Agent lifecycle rows + * pass even when bypassed or owned by another agent, because they fold into + * their spawn batch rather than rendering on their own; that is how Codex + * children (all bypassed) and Claude workflow members reach the batch row. + * Terminal rows are kept regardless — with no Agents surface on mobile they + * are the terminal signal. */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = @@ -394,20 +355,36 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean if (!payload) { return false; } - const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); - if (payload.timelineBypass === true && !isTerminalTaskRow) { - return true; - } - // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is - // the only signal mobile gets (no Agents sheet), so it stays. Only an - // agent's own background work (stamped "background") is internal — same - // rule as web (review finding: hiding on agentId alone dropped nested - // completions with no replacement UI). + const isTaskRow = + activity.kind === "task.started" || + activity.kind === "task.progress" || + activity.kind === "task.updated" || + activity.kind === "task.completed"; const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; - if (!ownedByAgent) { - return false; + if (isTaskRow) { + if (!ownedByAgent && payload.timelineBypass !== true) { + return false; + } + // An agent's own shells stay internal; the agents themselves fold into + // their batch. A bypassed batch marker keeps its terminal row. + if (typeof payload.taskId === "string" && payload.agentKind === "agent") { + return false; + } + if (ownedByAgent) { + return true; + } + return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity)); } - return !(isTerminalTaskRow && payload.agentKind === "agent"); + return payload.timelineBypass === true || ownedByAgent; +} + +/** Agent (non-background) task.started rows seed spawn batches. */ +function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.taskId === "string" && payload.agentKind === "agent"; } function deriveWorkLogEntries( @@ -418,7 +395,11 @@ function deriveWorkLogEntries( for (const activity of ordered) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started") continue; + // Like web: an agent's task.started row anchors its batch. It has a fixed + // id and timestamp, unlike progress ticks, whose stable per-task id is + // rewritten with a new createdAt on every update (and would otherwise + // make the batch row a "fresh" row again on each tick). + if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; @@ -465,6 +446,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; @@ -504,8 +486,20 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolCallId) { entry.toolCallId = toolCallId; } - if (isTaskActivity && payload?.agentKind === "agent") { - entry.agentSpawn = true; + if (isTaskActivity && payload) { + if (payload.agentKind !== "agent") { + entry.isBackgroundTask = true; + } + const spawnToolCallId = asTrimmedString(payload.toolUseId); + if (spawnToolCallId) { + entry.agentSpawnToolCallId = spawnToolCallId; + } + if ( + payload.taskType === "local_workflow" || + (typeof payload.workflowName === "string" && payload.workflowName.length > 0) + ) { + entry.isWorkflowCoordinator = true; + } } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); @@ -569,7 +563,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.requestKind = requestKind; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.completed") { + if ( + !toolLifecycleStatus && + (activity.kind === "tool.completed" || activity.kind === "task.completed") + ) { + toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed"; + } + // A Codex child that finishes its turn reports "idle" (resumable, not + // terminal). For the batch row that is a finished member. + if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") { toolLifecycleStatus = "completed"; } if (toolLifecycleStatus) { @@ -582,28 +584,160 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo return entry; } +/** + * Spawn-group key for a subagent lifecycle row. Workflow members and their + * coordinator share the coordinator's group; direct spawns batch per turn. + * Same keys as web's session-logic so both clients fold the same rows. + */ +function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { + const taskId = entry.taskId ?? ""; + const workflowSlot = taskId.indexOf(":wf:"); + if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`; + if (entry.isWorkflowCoordinator) return `wf:${taskId}`; + return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; +} + +/** + * The batch row keeps the group's anchor identity (id, createdAt, turnId, + * label) so it renders where the run launched instead of drifting to the + * newest progress tick, and gains each member's latest lifecycle state. + */ +function agentSpawnRow( + anchor: DerivedWorkLogEntry, + workflowId: string | null, + agentTaskIds: ReadonlyArray, + members: NonNullable["agents"], +): DerivedWorkLogEntry { + // A finished coordinator settles members that never reported their own + // end; Claude stops synthesizing member ticks once the workflow is done. + const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)]; + const agents = + coordinator?.status !== undefined && coordinator.status !== "inProgress" + ? members.map((agent) => + agent.status === undefined || agent.status === "inProgress" + ? { ...agent, status: coordinator.status } + : agent, + ) + : members; + const agentSpawn = { workflowId, agentTaskIds, agents }; + // The batch row has no detail of its own: its body lists the members. + const { detail: _detail, ...anchorWithoutDetail } = anchor; + return { + ...anchorWithoutDetail, + // The row's own lifecycle is the batch's: live while any member is, then + // the worst terminal state, so the group summary and shimmer follow it. + toolLifecycleStatus: agentSpawnLifecycleStatus(agents), + agentSpawn, + }; +} + +function agentSpawnMember( + entry: DerivedWorkLogEntry, + previous?: NonNullable["agents"][number], +) { + return { + title: entry.toolTitle ?? previous?.title ?? entry.label, + status: entry.toolLifecycleStatus ?? previous?.status, + detail: entry.detail ?? previous?.detail, + updatedAt: entry.createdAt, + }; +} + +function mergeAgentSpawnEntries( + existing: DerivedWorkLogEntry, + entry: DerivedWorkLogEntry, +): DerivedWorkLogEntry { + const spawn = existing.agentSpawn!; + const taskId = entry.taskId ?? ""; + const memberIndex = spawn.agentTaskIds.indexOf(taskId); + if (memberIndex === -1) { + return agentSpawnRow( + existing, + spawn.workflowId, + [...spawn.agentTaskIds, taskId], + [...spawn.agents, agentSpawnMember(entry)], + ); + } + const agents = spawn.agents.map((agent, index) => + index === memberIndex ? agentSpawnMember(entry, agent) : agent, + ); + return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents); +} + +function agentSpawnLifecycleStatus( + agents: NonNullable["agents"], +): WorkLogToolLifecycleStatus { + const statuses = agents.map((agent) => agent.status); + if (statuses.some((status) => status === undefined || status === "inProgress")) { + return "inProgress"; + } + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("declined")) return "declined"; + if (statuses.includes("stopped")) return "stopped"; + return "completed"; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; - // Subagent rows collapse by identity, not adjacency (quiet-timeline - // guarantee; mirrors web's session-logic). + // Task rows collapse by identity, not adjacency (quiet-timeline guarantee; + // mirrors web's session-logic). Background tasks keep one row per taskId; + // agent spawns fold into one row per spawn group, decided at the FIRST row + // seen for a taskId because later rows can arrive under synthetic turns. const taskRowIndex = new Map(); + const spawnRowIndex = new Map(); + const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); + // Tool calls that launched an agent (Claude's Agent tool, ACP subagent + // calls). The batch card is the whole story of that call, so its own + // lifecycle row is dropped. + const spawnToolCallIds = new Set( + entries.flatMap((entry) => + entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [], + ), + ); for (const entry of entries) { + if ( + entry.toolCallId !== undefined && + entry.taskId === undefined && + spawnToolCallIds.has(entry.toolCallId) + ) { + continue; + } const isTaskRow = entry.taskId !== undefined && - (entry.sourceActivityKind === "task.progress" || + (entry.sourceActivityKind === "task.started" || + entry.sourceActivityKind === "task.progress" || entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { - const existingIndex = taskRowIndex.get(entry.taskId); + if (entry.isBackgroundTask) { + const existingIndex = taskRowIndex.get(entry.taskId); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId, collapsed.length); + collapsed.push(entry); + continue; + } + const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry); + spawnGroupByTaskId.set(entry.taskId, groupKey); + const existingIndex = spawnRowIndex.get(groupKey); if (existingIndex !== undefined) { - collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry); continue; } - taskRowIndex.set(entry.taskId, collapsed.length); - collapsed.push(entry); + spawnRowIndex.set(groupKey, collapsed.length); + collapsed.push( + agentSpawnRow( + entry, + groupKey.startsWith("wf:") ? groupKey.slice(3) : null, + [entry.taskId], + [agentSpawnMember(entry)], + ), + ); continue; } const lifecycleKey = toolLifecycleCollapseMapKey(entry); @@ -752,68 +886,17 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un return [itemType, normalizedLabel, detail].join("\u001f"); } -function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { - if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { - return true; - } - if (entry.command !== undefined && entry.command.trim().length > 0) { - return true; - } - if (entry.requestKind !== undefined) { - return true; - } - return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); -} - -function toolDetailTextLooksLikeFailure(text: string): boolean { - const normalized = text.toLowerCase(); - return ( - normalized.includes("file not found") || - normalized.includes("no files found") || - normalized.includes("enoent") || - normalized.includes("no such file or directory") || - normalized.includes("no such file") || - normalized.includes("commandnotfoundexception") || - normalized.includes("command not found") || - (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || - (normalized.includes("is not recognized") && normalized.includes("the term '")) || - normalized.includes("is not recognized as the name of a cmdlet") || - normalized.includes("a parameter cannot be found that matches parameter name") || - //i.test(text) || - /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || - /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) - ); -} - -function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { - if (entry.tone === "error") { - return true; - } - if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { - return true; - } - if (!workLogEntryIsToolLike(entry)) { - return false; - } - return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); -} - -function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { - if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { - return false; - } - if (entry.tone === "thinking") { - return false; - } - return ( - entry.toolLifecycleStatus !== "inProgress" && - entry.toolLifecycleStatus !== "stopped" && - entry.toolLifecycleStatus !== "failed" && - entry.toolLifecycleStatus !== "declined" - ); -} - function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (entry.agentSpawn) { + switch (entry.toolLifecycleStatus) { + case "failed": + return "failure"; + case "completed": + return "success"; + default: + return "neutral"; + } + } if (!workLogEntryIsToolLike(entry)) { return null; } @@ -827,6 +910,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { } function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { + if (entry.agentSpawn) return "agent"; if ( entry.sourceActivityKind === "user-input.requested" || entry.sourceActivityKind === "user-input.resolved" @@ -853,6 +937,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { } function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { + if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn); const blocks: string[] = []; const appendBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); @@ -871,13 +956,45 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } -function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { - return ( - (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || - Boolean((entry.rawCommand ?? entry.command)?.trim()) || - Boolean(entry.detail?.trim()) || - (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) - ); +/** + * A row only opens when its body says more than its collapsed line. A row + * whose only detail is the single-line text it already shows (a runtime + * warning, a task summary, a short command) has nothing to reveal. + * Multi-line text still expands: the collapsed row truncates it to one line. + * Cheap field checks come first so large tool payloads are not serialized + * for every row (see the deferred-expansion test). + */ +function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { + if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; + if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; + if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; + const parts = [entry.rawCommand ?? entry.command, entry.detail] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + if (parts.length === 0) return false; + if (parts.length > 1 && new Set(parts).size > 1) return true; + const only = parts[0]!; + return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +/** The one-line text a collapsed work row shows. */ +export function workEntryRowLabel(entry: WorkLogEntry): string { + if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation) return presentation.displayName; + const preview = workEntryPreview(entry); + const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); + return compactPreview || workEntryHeading(entry); } function memoizeValue(build: () => T): () => T { @@ -913,7 +1030,114 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } +/** + * Batch label for a spawn row, matching web's CTA wording. Web reads live + * agent state from its Agents panel; mobile has only the lifecycle states + * folded into the row, so "working" means a member has not reported a + * terminal state yet. + */ +export function agentSpawnLabel(spawn: NonNullable): string { + const members = agentSpawnMembers(spawn); + const count = Math.max(members.length, 1); + const subjects = `${count} subagent${count === 1 ? "" : "s"}`; + const working = members.filter( + (agent) => agent.status === undefined || agent.status === "inProgress", + ).length; + const failed = members.filter((agent) => agent.status === "failed").length; + const stopped = members.filter((agent) => agent.status === "stopped").length; + if (working > 0) { + return `Kicked off ${subjects} · ${working} working`; + } + const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed"; + return `Ran ${subjects} · ${status}`; +} + +/** Workflow coordinators sit in their own batch but are not a member. */ +function agentSpawnMembers(spawn: NonNullable) { + return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); +} + +function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] { + switch (status) { + case undefined: + case "inProgress": + return "working"; + case "completed": + return "completed"; + case "failed": + case "declined": + return "failed"; + case "stopped": + return "stopped"; + } +} + +/** + * What the spawn card shows. While members work, the status line is the + * newest member activity (its progress detail), so the card reads like the + * live tool row does for a single call. Once every member settles, it is the + * batch outcome in web's CTA wording. + */ +export function agentSpawnSummary( + spawn: NonNullable, + batchStatus: WorkLogToolLifecycleStatus | undefined, +): AgentSpawnSummary { + const members = agentSpawnMembers(spawn).map((agent) => { + const tone = agentSpawnTone(agent.status); + return { + title: agent.title, + status: tone === "working" ? "working" : (agent.status ?? tone), + tone, + detail: agent.detail, + updatedAt: agent.updatedAt, + }; + }); + const tone = agentSpawnTone(batchStatus); + // A workflow's coordinator is not a member; before any member reports the + // batch has none. + const title = + members.length === 0 + ? "Subagents" + : members.length === 1 + ? members[0]!.title + : `${members.length} subagents`; + if (tone === "working") { + const working = members.filter((member) => member.tone === "working"); + const latest = working + .filter((member) => member.detail !== undefined) + .reduce<(typeof working)[number] | undefined>( + (newest, member) => + newest === undefined || member.updatedAt > newest.updatedAt ? member : newest, + undefined, + ); + const status = + latest?.detail ?? + (members.length > 1 ? `${working.length} of ${members.length} working` : "Working"); + return { title, status, tone, members }; + } + // The batch tone covers a coordinator that failed or stopped on its own. + const failed = members.filter((member) => member.tone === "failed").length; + const stopped = members.filter((member) => member.tone === "stopped").length; + const outcome = + tone === "failed" || failed > 0 + ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed` + : tone === "stopped" || stopped > 0 + ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped` + : "completed"; + return { title, status: outcome, tone, members }; +} + +function agentSpawnExpandedBody(spawn: NonNullable): string | null { + const lines = agentSpawnMembers(spawn).map((agent) => { + const status = + agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status; + return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`; + }); + return lines.length > 0 ? lines.join("\n") : null; +} + function workEntryHeading(workEntry: WorkLogEntry): string { + if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(workEntry); if (presentation) return presentation.displayName; if (!workEntry.toolTitle) { @@ -1133,27 +1357,6 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } -function extractWorkLogToolLifecycleStatus( - payload: Record | null, -): WorkLogToolLifecycleStatus | undefined { - const status = payload?.status; - // The parent turn ended, so batch tracking is inactive. The detail explains - // that child status is unavailable; do not retain the earlier running marker. - if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped"; - if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; - if (status === "cancelled" || status === "interrupted") return "stopped"; - if ( - status === "inProgress" || - status === "completed" || - status === "failed" || - status === "declined" || - status === "stopped" - ) { - return status; - } - return undefined; -} - function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -1510,7 +1713,11 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && + entry.type !== "work-toggle" && + entry.type !== "thinking" && + entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1570,12 +1777,45 @@ export function deriveThreadFeedPresentation( ); } } + // A working turn always shows one live activity. When no tool row is + // shimmering (no tools yet, or the latest failed), that row is "Thinking". + // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so + // the handoff between them happens in place (one row, new content) instead + // of a row being inserted below the group every time a call fails. + if ( + activeWorkStartedAt !== null && + !result.some( + (row) => + (row.type === "work-toggle" && row.shimmer) || + // A working spawn card is the live activity: its status line shows + // what the agents are doing, so a Thinking row under it would lie. + (row.type === "agent-spawn" && + row.summary.tone === "working" && + row.turnId === unsettledTurnId), + ) + ) { + result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); + } return result; } +/** + * Shared by the trailing tool group's live row and the "Thinking" row so the + * list keeps one mounted row for the turn's live slot (mirrors web's + * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them. + */ +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + +function thinkingRow(createdAt: string, turnId: TurnId | null) { + if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { + cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId }; + } + return cachedThinkingRow; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, unsettledTurnId: TurnId | null, isWorking: boolean, @@ -1597,7 +1837,9 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || + (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded), ) ) { const rows: ThreadFeedEntry[] = []; @@ -1653,11 +1895,27 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) { + const spawn = activity.workEntry.agentSpawn; + if (activity.workEntry.tone !== "error" && spawn === undefined) { groupableRun.push(activity); continue; } flushGroupableRun(false); + if (spawn !== undefined) { + // Keyed by the batch, not the anchor activity: the anchor can change + // as members arrive, and a changed key remounts the card. + const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`; + result.push({ + type: "agent-spawn", + id: groupId, + createdAt: activity.createdAt, + turnId: activity.turnId, + activity, + expanded: expandedWorkGroupIds.has(groupId), + summary: agentSpawnSummary(spawn, activity.lifecycleStatus), + }); + continue; + } result.push({ type: "activity-group", id: activity.id, @@ -1696,6 +1954,11 @@ function appendToolGroupRows( const active = latestActiveActivity !== undefined; const live = activeTail || active; const latestActivity = latestActiveActivity ?? activities.at(-1)!; + // Like web, the trailing run keeps shining after its latest call succeeds; + // only a failed, declined, or stopped call hands the live slot to "Thinking". + // Only the trailing run can be the turn's live slot; an in-progress row in + // an earlier run (a call whose end was never reported) stays in place. + const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1736,7 +1999,9 @@ function appendToolGroupRows( : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + // The shimmering trailing row is the turn's live slot; it keeps that + // identity (and so its mounted view) until "Thinking" takes the slot. + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, createdAt: sourceGroup.createdAt, turnId: sourceGroup.turnId, groupId, @@ -1751,7 +2016,7 @@ function appendToolGroupRows( ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - shimmer: active, + shimmer, }); if (!expanded) { return; @@ -1798,116 +2063,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo return activity.detail ?? activity.summary; } -/** - * Sorts activities into lifecycle order. `derivePendingApprovals` and - * `derivePendingUserInputs` both expect this ordering; sorting once and - * passing the result to both avoids re-sorting the full activity history - * per derivation. - */ -export function sortThreadActivities( - activities: ReadonlyArray, -): ReadonlyArray { - return Arr.sort(activities, activityOrder); -} - -export function derivePendingApprovals( - sortedActivities: ReadonlyArray, -): PendingApproval[] { - const openByRequestId = new Map(); - - for (const activity of sortedActivities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = isProviderRequestKind(payload?.requestKind) - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); - const detail = typeof payload?.detail === "string" ? payload.detail : undefined; - const appName = typeof payload?.appName === "string" ? payload.appName : undefined; - const options = Array.isArray(payload?.options) - ? payload.options.filter(isProviderApprovalOption) - : undefined; - - if ( - activity.kind === "approval.requested" && - requestId && - payload?.requestType !== "tool_user_input" && - payload?.requestType !== "auth_tokens_refresh" - ) { - openByRequestId.set(requestId, { - requestId, - // Older OpenCode requests can have no recognized approval kind. - requestKind: requestKind ?? "command", - createdAt: activity.createdAt, - ...(detail ? { detail } : {}), - ...(appName ? { appName } : {}), - ...(options && options.length > 0 ? { options } : {}), - }); - continue; - } - - if (activity.kind === "approval.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.approval.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); -} - -export function derivePendingUserInputs( - sortedActivities: ReadonlyArray, -): PendingUserInput[] { - const openByRequestId = new Map(); - - for (const activity of sortedActivities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = parseApprovalRequestId(payload?.requestId); - const detail = typeof payload?.detail === "string" ? payload.detail : undefined; - - if (activity.kind === "user-input.requested" && requestId) { - const questions = parseUserInputQuestions(payload); - if (!questions) { - continue; - } - openByRequestId.set(requestId, { - requestId, - createdAt: activity.createdAt, - questions, - }); - continue; - } - - if (activity.kind === "user-input.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.user-input.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); -} - export function setPendingUserInputCustomAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, @@ -2072,7 +2227,7 @@ function toThreadFeedActivityEntry( turnId: entry.turnId, summary, detail, - canExpand: workEntryHasExpandedBody(entry), + canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), getFullDetail, getCopyText, icon: workEntryIcon(entry), diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -15,14 +16,21 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index ad38551d5915..d454133f1a31 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -79,7 +79,7 @@ export function useComposerAttachmentUploadWorker() { let retained = false; for (const [key, draft] of Object.entries(appAtomRegistry.get(composerDraftsAtom))) { if ( - composerDraftEnvironmentId(key, queued) === environmentId && + composerDraftEnvironmentId(key, queued, draft) === environmentId && draft.attachments.some((candidate) => candidate.id === attachment.id) ) { retained = setComposerDraftAttachmentUpload(key, uploaded) || retained; @@ -113,7 +113,7 @@ export function useComposerAttachmentUploadWorker() { .map((environment) => environment.environmentId), ); const requests = Object.entries(drafts).flatMap(([key, draft]) => { - const environmentId = composerDraftEnvironmentId(key, queued); + const environmentId = composerDraftEnvironmentId(key, queued, draft); if (environmentId === null || !connected.has(environmentId)) return []; return draft.attachments .filter((attachment) => diff --git a/apps/mobile/src/state/new-task-draft-key.ts b/apps/mobile/src/state/new-task-draft-key.ts new file mode 100644 index 000000000000..942380dbaa3d --- /dev/null +++ b/apps/mobile/src/state/new-task-draft-key.ts @@ -0,0 +1,30 @@ +const NEW_TASK_DRAFT_PREFIX = "new-task:"; + +/** Every new-task draft key: `new-task:`. */ +export function newTaskDraftKey(draftId: string): string { + return `${NEW_TASK_DRAFT_PREFIX}${draftId}`; +} + +export function isNewTaskDraftKey(draftKey: string): boolean { + return draftKey.startsWith(NEW_TASK_DRAFT_PREFIX); +} + +/** + * Builds before drafts were id-keyed used `new-task::`, + * one slot per project. Ids are UUIDs and never contain a colon, so a colon + * after the prefix marks the legacy shape. Returns the split scope, or null + * when the key is not legacy. + */ +export function parseLegacyNewTaskDraftKey( + draftKey: string, +): { readonly environmentId: string; readonly projectId: string } | null { + if (!isNewTaskDraftKey(draftKey)) { + return null; + } + const scope = draftKey.slice(NEW_TASK_DRAFT_PREFIX.length); + const separator = scope.lastIndexOf(":"); + if (separator <= 0 || separator === scope.length - 1) { + return null; + } + return { environmentId: scope.slice(0, separator), projectId: scope.slice(separator + 1) }; +} diff --git a/apps/mobile/src/state/pending-new-tasks-model.test.ts b/apps/mobile/src/state/pending-new-tasks-model.test.ts new file mode 100644 index 000000000000..0bb61ed2c429 --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; + +import type { QueuedThreadMessage } from "./thread-outbox-model"; +import type { ComposerDraft } from "./use-composer-drafts"; +import { buildPendingNewTasks } from "./pending-new-tasks-model"; + +const environmentId = EnvironmentId.make("env-1"); +const projectId = ProjectId.make("project-1"); + +function queuedCreation(id: string, createdAt: string): QueuedThreadMessage { + return { + environmentId, + threadId: ThreadId.make(`thread-${id}`), + messageId: MessageId.make(id), + commandId: CommandId.make(`command-${id}`), + text: `queued ${id}`, + attachments: [], + createdAt, + creation: { + projectId, + workspaceMode: "local", + branch: "main", + worktreePath: null, + }, + }; +} + +function draft( + text: string, + createdAt: string, + overrides: Partial = {}, +): ComposerDraft { + return { + text, + attachments: [], + project: { environmentId, projectId, createdAt }, + ...overrides, + }; +} + +describe("buildPendingNewTasks", () => { + it("surfaces every new-task draft with content alongside queued creations", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [queuedCreation("a", "2026-09-05T10:00:00.000Z")], + drafts: { + "new-task:draft-old": draft("first idea", "2026-09-05T09:00:00.000Z", { + workspaceSelection: { mode: "worktree", branch: "main", worktreePath: null }, + }), + "new-task:draft-new": draft("second idea", "2026-09-05T11:00:00.000Z"), + }, + }); + + expect(tasks.map((task) => [task.kind, task.title, task.branch])).toEqual([ + ["draft", "second idea", null], + ["draft", "first idea", "main"], + ["pending", "queued a", "main"], + ]); + expect(tasks[1]).toMatchObject({ + key: "draft-task:new-task:draft-old", + environmentId, + projectId, + draftKey: "new-task:draft-old", + createdAt: "2026-09-05T09:00:00.000Z", + }); + }); + + it("hides settings-only drafts, unstamped drafts, and drafts for other surfaces", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [], + drafts: { + "new-task:settings-only": draft("", "2026-09-05T09:00:00.000Z", { + modelSelection: { instanceId: "codex" as never, model: "gpt" }, + }), + "new-task:blank": draft(" ", "2026-09-05T09:00:00.000Z"), + "new-task:unstamped": { text: "no project", attachments: [] }, + [`${environmentId}:thread-1`]: { text: "thread composer text", attachments: [] }, + "pending-task:message-1": { text: "editor copy of a queued task", attachments: [] }, + }, + }); + + expect(tasks).toEqual([]); + }); + + it("titles an attachment-only draft by its attachment count", () => { + const attachment = { + type: "image", + id: "image-1", + uri: "file:///image-1.png", + mimeType: "image/png", + name: "image-1.png", + width: 1, + height: 1, + sizeBytes: 1, + } as unknown as ComposerDraft["attachments"][number]; + const tasks = buildPendingNewTasks({ + queuedMessages: [], + drafts: { + "new-task:with-image": draft("", "2026-09-05T09:00:00.000Z", { + attachments: [attachment], + }), + }, + }); + + expect(tasks.map((task) => task.title)).toEqual(["1 attachment"]); + }); + + it("orders queued creations newest first and skips existing-thread messages", () => { + const tasks = buildPendingNewTasks({ + queuedMessages: [ + queuedCreation("old", "2026-09-05T08:00:00.000Z"), + { ...queuedCreation("follow-up", "2026-09-05T11:00:00.000Z"), creation: undefined }, + queuedCreation("new", "2026-09-05T10:00:00.000Z"), + ], + drafts: {}, + }); + + expect(tasks.map((task) => task.title)).toEqual(["queued new", "queued old"]); + }); +}); diff --git a/apps/mobile/src/state/pending-new-tasks-model.ts b/apps/mobile/src/state/pending-new-tasks-model.ts new file mode 100644 index 000000000000..b66c5273b443 --- /dev/null +++ b/apps/mobile/src/state/pending-new-tasks-model.ts @@ -0,0 +1,111 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; +import type { QueuedThreadCreation, QueuedThreadMessage } from "./thread-outbox-model"; +import { isNewTaskDraftKey } from "./new-task-draft-key"; +import type { ComposerDraft } from "./use-composer-drafts"; + +/** + * Unsent work that will become a thread, shaped for thread-list presentation. + * A `pending` task sits in the outbox and sends itself when its environment + * reconnects; a `draft` is new-task composer content, which only sends when + * the user submits it. Both share the list slot so the user can find + * everything they have written but not yet started in one place. + */ +export type PendingNewTask = PendingQueuedTask | PendingDraftTask; + +export interface PendingQueuedTask { + readonly kind: "pending"; + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectTitle: string | undefined; + readonly projectCwd: string | undefined; + readonly branch: string | null; + readonly title: string; + readonly createdAt: string; + readonly message: QueuedThreadMessage; + readonly creation: QueuedThreadCreation; +} + +export interface PendingDraftTask { + readonly kind: "draft"; + readonly key: string; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly projectTitle: undefined; + readonly projectCwd: undefined; + readonly branch: string | null; + readonly title: string; + readonly createdAt: string; + readonly draftKey: string; + readonly draft: ComposerDraft; +} + +/** + * Settings-only drafts (a model pick with no text) are not work the user + * would look for in the list; only text or attachments make a draft visible. + */ +export function composerDraftHasUserContent(draft: ComposerDraft): boolean { + return draft.text.trim().length > 0 || draft.attachments.length > 0; +} + +function draftTitle(draft: ComposerDraft): string { + if (draft.text.trim().length > 0) { + return deriveThreadTitleFromPrompt(draft.text); + } + const count = draft.attachments.length; + return count === 1 ? "1 attachment" : `${count} attachments`; +} + +export function buildPendingNewTasks(input: { + readonly queuedMessages: ReadonlyArray; + readonly drafts: Readonly>; +}): ReadonlyArray { + const tasks: PendingNewTask[] = []; + for (const message of input.queuedMessages) { + if (!message.creation) { + continue; + } + tasks.push({ + kind: "pending", + key: `pending-task:${message.messageId}`, + environmentId: message.environmentId, + projectId: message.creation.projectId, + projectTitle: message.creation.projectTitle, + projectCwd: message.creation.projectCwd, + branch: message.creation.branch, + title: deriveThreadTitleFromPrompt(message.text), + createdAt: message.createdAt, + message, + creation: message.creation, + }); + } + for (const [draftKey, draft] of Object.entries(input.drafts)) { + if (!isNewTaskDraftKey(draftKey) || !draft.project || !composerDraftHasUserContent(draft)) { + continue; + } + tasks.push({ + kind: "draft", + key: `draft-task:${draftKey}`, + environmentId: draft.project.environmentId, + projectId: draft.project.projectId, + projectTitle: undefined, + projectCwd: undefined, + branch: draft.workspaceSelection?.branch ?? null, + title: draftTitle(draft), + createdAt: draft.project.createdAt, + draftKey, + draft, + }); + } + // Drafts are what the user is writing now, so they lead; within each kind, + // newest first. + tasks.sort((left, right) => { + if (left.kind !== right.kind) { + return left.kind === "draft" ? -1 : 1; + } + return right.createdAt.localeCompare(left.createdAt) || left.key.localeCompare(right.key); + }); + return tasks; +} diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 2157c72e13ef..28cd2af57062 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts new file mode 100644 index 000000000000..4959ad989626 --- /dev/null +++ b/apps/mobile/src/state/thread-order.test.ts @@ -0,0 +1,117 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { Atom } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createPendingThreadOrder } from "../features/threads/threadOrder"; +import { appAtomRegistry } from "./atom-registry"; +import { + beginPendingThreadOrder, + getPendingThreadOrder, + pendingThreadOrderAtom, +} from "./thread-order"; +import { environmentThreadShells } from "./threads"; + +vi.mock("./atom-registry", async () => { + const { AtomRegistry } = await import("effect/unstable/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; +}); +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; +}); + +// The mocked shell source is writable so tests can deliver canonical upserts. +const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< + readonly EnvironmentThreadShell[], + readonly EnvironmentThreadShell[] +>; + +function fixture() { + // Only section membership and order fields are read by this coordinator. + const rows = ["a", "b"].map( + (id, index) => + ({ + id: ThreadId.make(id), + environmentId: EnvironmentId.make("env"), + createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, + archivedAt: null, + pinnedAt: null, + activeOrderKey: null, + }) as EnvironmentThreadShell, + ); + appAtomRegistry.set(shellsAtom, rows); + const pending = createPendingThreadOrder({ + section: "active", + ordered: rows, + movedId: "env:b", + direction: "up", + assignments: [ + { id: "env:b", orderKey: "aa" }, + { id: "env:a", orderKey: "bb" }, + ], + }); + const start = () => beginPendingThreadOrder(pending); + const upsert = (id: string, key: string) => { + const current = appAtomRegistry.get(shellsAtom); + appAtomRegistry.set( + shellsAtom, + current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)), + ); + }; + return { rows, start, upsert }; +} + +afterEach(() => appAtomRegistry.reset()); + +describe("shared mobile pending move", () => { + it("blocks another pickup after receipts and clears on final canonical upsert", () => { + const { start, upsert } = fixture(); + const move = start(); + move.complete(); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("b", "aa"); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("a", "bb"); + expect(getPendingThreadOrder()).toBeNull(); + expect(move.isPending()).toBe(false); + }); + + it("waits for receipts when shells arrive first", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + upsert("a", "bb"); + expect(getPendingThreadOrder()).not.toBeNull(); + move.complete(); + expect(getPendingThreadOrder()).toBeNull(); + }); + + it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + move.cancel(); + expect(getPendingThreadOrder()).toBeNull(); + expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa"); + const next = start(); + move.cancel(); + expect(next.isPending()).toBe(true); + next.cancel(); + }); + + it("stops remaining writes when a canonical membership change invalidates the move", () => { + const { rows, start } = fixture(); + const move = start(); + appAtomRegistry.set(shellsAtom, rows.slice(1)); + expect(move.isPending()).toBe(false); + expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull(); + move.complete(); + appAtomRegistry.set(shellsAtom, rows); + expect(getPendingThreadOrder()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts new file mode 100644 index 000000000000..0fb57fc826e0 --- /dev/null +++ b/apps/mobile/src/state/thread-order.ts @@ -0,0 +1,89 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; +import { Atom } from "effect/unstable/reactivity"; + +import { + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "../features/threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; +import { appAtomRegistry } from "./atom-registry"; +import { environmentServerConfigsAtom } from "./server"; +import { environmentThreadShells } from "./threads"; + +export const pendingThreadOrderAtom = Atom.make(null).pipe( + Atom.keepAlive, +); + +export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) { + const pending = useAtomValue(pendingThreadOrderAtom); + // A timed wake can change section membership without a shell event. Use the + // lists' existing clocks to retire that hold and re-enable their move menus. + useEffect(() => { + getPendingThreadOrder(); + }, [nowMinute, snoozeWakeTick]); + return pending; +} + +let refreshPendingOrder: (() => void) | undefined; + +/** Shared by Home and the navigation sidebar, including their action guards. */ +export function getPendingThreadOrder(): PendingThreadOrder | null { + refreshPendingOrder?.(); + return appAtomRegistry.get(pendingThreadOrderAtom); +} + +export function beginPendingThreadOrder(pending: PendingThreadOrder) { + const unsubscribers: (() => void)[] = []; + const cancel = () => { + if (refreshPendingOrder !== refresh) return; + refreshPendingOrder = undefined; + for (const unsubscribe of unsubscribers) unsubscribe(); + appAtomRegistry.set(pendingThreadOrderAtom, null); + }; + const refresh = () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current === null) return; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const ordered = getThreadListV2OrderedSection({ + threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + section: current.section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), + ), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), + ), + }); + const next = reconcilePendingThreadOrder(current, ordered); + if (next === null) cancel(); + else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next); + }; + refreshPendingOrder = refresh; + appAtomRegistry.set(pendingThreadOrderAtom, pending); + unsubscribers.push( + appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), + appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), + ); + return { + isPending: () => { + refresh(); + return refreshPendingOrder === refresh; + }, + complete: () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current !== null) { + appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true }); + refresh(); + } + }, + cancel, + }; +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 99f32e5c1764..2676c935e01e 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -251,14 +251,30 @@ function errorMessage(error: unknown): string | null { return typeof error === "string" ? error : null; } +/** + * Only a failure the server actually decided (`OrchestrationDispatchCommandError`, + * or an authorization rejection) means the payload itself is bad. The other + * typed failures a queued send can hit are transport-shaped: a socket that + * dropped mid-request (`RpcClientError` wrapping a Socket read/write/close + * reason), or an environment that is not connected or not registered. Those + * are matched by tag, not by message text, because a `SocketReadError` message + * is just "An error occurred during Read". A wrong answer here restores the + * pending task into a draft and it disappears from the list. + */ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { - if ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "ConnectionTransientError" - ) { - return true; + if (typeof error === "object" && error !== null && "_tag" in error) { + switch (error._tag) { + case "OrchestrationDispatchCommandError": + case "EnvironmentAuthorizationError": + return false; + case "ConnectionTransientError": + case "RpcClientError": + case "EnvironmentRpcUnavailableError": + case "EnvironmentNotRegisteredError": + return true; + default: + break; + } } return isTransportConnectionErrorMessage(errorMessage(error)); } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index c426d552a7e7..c186bc098e5b 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentNotRegisteredError } from "@t3tools/client-runtime/connection"; +import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { EnvironmentRpcUnavailableError } from "@t3tools/client-runtime/rpc"; import { CommandId, + EnvironmentAuthorizationError, EnvironmentId, MessageId, + OrchestrationDispatchCommandError, ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; +import * as RpcClientError from "effect/unstable/rpc/RpcClientError"; +import * as Socket from "effect/unstable/socket/Socket"; import { onTestFinished, vi } from "vite-plus/test"; const outboxFiles = vi.hoisted(() => new Map()); @@ -1399,6 +1406,62 @@ describe("thread outbox", () => { }), ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); + expect( + shouldRetryThreadOutboxDelivery( + new OrchestrationDispatchCommandError({ message: "Thread no longer exists" }), + ), + ).toBe(false); + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentAuthorizationError({ + message: "Missing scope", + requiredScope: "orchestration:operate", + }), + ), + ).toBe(false); + }); + + // A pending task created offline drains the moment the phone reconnects, + // which is exactly when the socket is most likely to drop again. Every way a + // request can fail in flight must retry; a restore turns the pending task + // into a draft and it disappears from the list. + it("retries every in-flight transport failure by tag, not by message text", () => { + const socketReasons = [ + new Socket.SocketReadError({ cause: new Error("The network connection was lost.") }), + new Socket.SocketWriteError({ cause: new Error("Broken pipe") }), + new Socket.SocketCloseError({ code: 1006 }), + new Socket.SocketOpenError({ kind: "Timeout", cause: new Error("timeout") }), + ]; + for (const reason of socketReasons) { + const error = new RpcClientError.RpcClientError({ reason }); + expect(isTransportConnectionErrorMessage(error.message)).toBe( + reason._tag === "SocketCloseError" || reason._tag === "SocketOpenError", + ); + expect(shouldRetryThreadOutboxDelivery(error)).toBe(true); + } + expect( + shouldRetryThreadOutboxDelivery( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Error decoding message", + cause: new Error("Unexpected end of JSON input"), + }), + }), + ), + ).toBe(true); + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentRpcUnavailableError({ + environmentId: "environment-1", + message: "Home is not connected.", + }), + ), + ).toBe(true); + expect( + shouldRetryThreadOutboxDelivery( + new EnvironmentNotRegisteredError({ environmentId: EnvironmentId.make("environment-1") }), + ), + ).toBe(true); }); it("retains queued messages when settings synchronization fails before startTurn", () => { diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 53d19abd95a4..fd7a171810ee 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -20,7 +20,7 @@ export interface ThreadPrPresentation { const PR_STATE_TEXT_CLASS: Record = { open: "text-adaptive-emerald-600-400", merged: "text-adaptive-violet-600-400", - closed: "text-adaptive-zinc-500-400", + closed: "text-foreground-muted", }; export function presentThreadPr( @@ -37,6 +37,6 @@ export function presentThreadPr( url: pr.url, label: String(pr.number), accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, - textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], + textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..d49c26a40a44 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; + readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => environmentId, label: presentation.entry.target.label, isPending: result.waiting, + isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); @@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) => export interface UsageView { readonly merged: MergedUsage; readonly environments: readonly EnvironmentUsageStatus[]; + readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; /** True until at least one environment has answered. */ readonly isPending: boolean; /** * True while environments that have not failed are still answering. Failed - * environments are reported through their own error rows: totals will not + * environments are reported in the environment menu: totals will not * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } -export function useUsage(input: UsageSummaryInput): UsageView { +export function useUsage( + input: UsageSummaryInput, + selectedEnvironmentIds: ReadonlySet | null = null, +): UsageView { const windowKey = useMemo( () => JSON.stringify({ @@ -97,30 +103,28 @@ export function useUsage(input: UsageSummaryInput): UsageView { ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); + const selectedEnvironments = useMemo( + () => + selectedEnvironmentIds === null + ? environments + : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)), + [environments, selectedEnvironmentIds], + ); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so pull-to-refresh always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [environments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [selectedEnvironments, windowKey], + ); const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => + const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => environment.summary === null ? [] : [ @@ -132,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView { ], ); return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + }, [selectedEnvironments]); - const answeredCount = environments.filter((environment) => environment.summary !== null).length; - const stillReporting = environments.filter( + const answeredCount = selectedEnvironments.filter( + (environment) => environment.summary !== null, + ).length; + const stillReporting = selectedEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; return { merged, environments, + selectedEnvironments, isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, refresh, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index e355e0e6dd7f..c055b515448a 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -3,6 +3,7 @@ import { CommandId, EnvironmentId, MessageId, + ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; @@ -145,6 +146,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); +import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { @@ -156,21 +158,22 @@ import { ComposerDraftPersistenceError, composerDraftsAtom, composerCloudDraftsAtom, - copyComposerDraftContentIfEmpty, - copyComposerDraftContentState, + createNewTaskDraft, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, + findNewTaskDraftKeys, flushComposerDrafts, getComposerDraftSnapshot, mergeComposerDraftContentState, + migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, removeComposerDraftsForEnvironment, resetComposerDraftsLoadState, retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, + retargetNewTaskDraft, setComposerDraftText, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -210,37 +213,6 @@ afterEach(() => { describe("mobile composer drafts", () => { // Hydration is one-shot per module instance and the attachment sweep now // triggers it too, so this test must observe it before any sweep test runs. - it("waits for persisted drafts before copying content between projects", async () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const unrelatedKey = "environment-1:thread-1"; - const source = { text: "Current task", attachments: [] } satisfies ComposerDraft; - const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft; - const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft; - - composerDraftFileMocks.setDocument({ - schemaVersion: 1, - drafts: { - [targetKey]: target, - [unrelatedKey]: unrelated, - }, - }); - composerDraftFileMocks.blockRead(); - appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source }); - - const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey); - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source }); - - composerDraftFileMocks.releaseRead(); - await copy; - - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ - [sourceKey]: source, - [targetKey]: target, - [unrelatedKey]: unrelated, - }); - }); - it("hydrates generic file attachments from their saved local paths", () => { const file = { id: "file-1", @@ -252,12 +224,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -987,7 +959,7 @@ describe("mobile composer drafts", () => { it("rejects persisted images without image bytes or a file URI", () => { expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1009,8 +981,8 @@ describe("mobile composer drafts", () => { }); it("hydrates selector state even when the message content is empty", () => { - expect( - decodePersistedComposerDrafts({ + const hydrated = Object.entries( + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -1030,41 +1002,48 @@ describe("mobile composer drafts", () => { }, }, }, - }), - ).toEqual({ - "new-task:environment-1:project-1": { - text: "", - attachments: [], - modelSelection: { - instanceId: "codex", - model: "gpt-5.4", - options: [{ id: "reasoningEffort", value: "xhigh" }], - }, - runtimeMode: "approval-required", - interactionMode: "plan", - workspaceSelection: { - mode: "worktree", - branch: "main", - worktreePath: null, - }, + }).drafts, + ); + expect(hydrated).toHaveLength(1); + const [key, draft] = hydrated[0]!; + // Legacy project keys are rewritten to id keys on load. + expect(key).toMatch(/^new-task:[0-9a-z-]+$/); + expect(draft).toEqual({ + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + project: { + environmentId: "environment-1", + projectId: "project-1", + createdAt: expect.any(String), }, }); }); - it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1085,7 +1064,7 @@ describe("mobile composer drafts", () => { // The stale-model strip must not touch receipt-bearing drafts, and the // empty filter must keep them — or the same share would re-import after // restart. - expect( + const stripped = Object.values( decodePersistedComposerState({ schemaVersion: 1, drafts: { @@ -1098,20 +1077,146 @@ describe("mobile composer drafts", () => { }, }, }).drafts, - ).toEqual({ - "new-task:environment-1:project-1": { - text: "", - attachments: [], - importedShareIds: ["share-1"], - }, + ); + expect(stripped).toHaveLength(1); + expect(stripped[0]).toMatchObject({ + text: "", + attachments: [], + importedShareIds: ["share-1"], + project: { environmentId: "environment-1", projectId: "project-1" }, }); + expect(stripped[0]?.modelSelection).toBeUndefined(); - expect( + const kept = Object.values( decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": receiptDraft }, }).drafts, - ).toEqual({ "new-task:environment-1:project-1": receiptDraft }); + ); + expect(kept).toHaveLength(1); + expect(kept[0]).toMatchObject(receiptDraft); + }); + + it("migrates archived signed-out new-task drafts the same way as live ones", () => { + const decoded = decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + cloudAccountId: "account-1", + signedOutDrafts: { + "account-1": { + drafts: { "new-task:environment-1:project-1": { text: "archived", attachments: [] } }, + queuedMessages: [], + }, + }, + }); + const archived = Object.entries(decoded.cloudDrafts.signedOut["account-1"]?.drafts ?? {}); + expect(archived).toHaveLength(1); + expect(archived[0]?.[0]).toMatch(/^new-task:[0-9a-z]+-[0-9a-z]+$/); + expect(archived[0]?.[1]).toMatchObject({ + text: "archived", + project: { environmentId: "environment-1", projectId: "project-1" }, + }); + }); + + it("migrates project-keyed new-task drafts to id keys with the project stamped in", () => { + const now = "2026-09-05T12:00:00.000Z"; + const [key, draft] = migrateLegacyNewTaskDraft( + "new-task:environment-1:project-1", + { text: "keep me", attachments: [] }, + now, + ); + // The new key has no colon after the prefix, so it can never be + // mistaken for the legacy shape on the next load. + expect(key).toMatch(/^new-task:[0-9a-z-]+$/); + expect(draft).toEqual({ + text: "keep me", + attachments: [], + project: { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + createdAt: now, + }, + }); + + // Already-migrated, thread, and pending-task keys pass through untouched. + const stamped: ComposerDraft = { + text: "x", + attachments: [], + project: { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + createdAt: now, + }, + }; + expect(migrateLegacyNewTaskDraft("new-task:some-id", stamped, now)).toEqual([ + "new-task:some-id", + stamped, + ]); + expect(migrateLegacyNewTaskDraft("environment-1:thread-1", DRAFT, now)).toEqual([ + "environment-1:thread-1", + DRAFT, + ]); + expect(migrateLegacyNewTaskDraft("pending-task:message-1", DRAFT, now)).toEqual([ + "pending-task:message-1", + DRAFT, + ]); + }); + + it("keeps a freshly minted new-task draft bound until content arrives, then lists it per project", () => { + const project = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const first = createNewTaskDraft(project); + const second = createNewTaskDraft(project); + expect(first).not.toBe(second); + // Empty stamped drafts stay in memory so the composer has a key to write + // to, but the persisted document leaves them out. + expect(appAtomRegistry.get(composerDraftsAtom)[first]?.project).toMatchObject(project); + + setComposerDraftText(first, "first idea"); + setComposerDraftText(second, "second idea"); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), project)).toEqual( + expect.arrayContaining([first, second]), + ); + + // Clearing content on the way out drops the stamp with it. + clearComposerDraftContent(first, { clearModelSelection: true, clearWorkspaceSelection: true }); + expect(appAtomRegistry.get(composerDraftsAtom)[first]).toBeUndefined(); + expect(getComposerDraftSnapshot(second).text).toBe("second idea"); + }); + + it("retargets a new-task draft to another project without losing its text", () => { + const from = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }; + const to = { + environmentId: EnvironmentId.make("environment-2"), + projectId: ProjectId.make("project-2"), + }; + const key = createNewTaskDraft(from); + setComposerDraftText(key, "moving house"); + appAtomRegistry.set(composerDraftsAtom, { + ...appAtomRegistry.get(composerDraftsAtom), + [key]: { + ...getComposerDraftSnapshot(key), + runtimeMode: "approval-required", + workspaceSelection: { mode: "worktree", branch: "feature/a", worktreePath: null }, + }, + }); + const createdAt = getComposerDraftSnapshot(key).project?.createdAt; + + retargetNewTaskDraft(key, to); + + const moved = getComposerDraftSnapshot(key); + expect(moved.text).toBe("moving house"); + expect(moved.runtimeMode).toBe("approval-required"); + // Branch and worktree belong to the old repo. + expect(moved.workspaceSelection).toBeUndefined(); + expect(moved.project).toEqual({ ...to, createdAt }); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), from)).toEqual([]); + expect(findNewTaskDraftKeys(appAtomRegistry.get(composerDraftsAtom), to)).toEqual([key]); }); it("hydrates the global sticky model selection", () => { @@ -1387,51 +1492,54 @@ describe("mobile composer drafts", () => { expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); }); - it("carries unfinished content to a newly selected project without overwriting its settings", () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const source: ComposerDraft = { - text: "Keep this task", - attachments: [], - importedShareIds: ["share-1"], - workspaceSelection: { - mode: "worktree", - branch: "feature/source", - worktreePath: null, - }, + it("drops another environment's upload stamp when a draft moves across machines", () => { + const uploadedElsewhere: DraftComposerAttachment = { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + uploadedAttachmentId: "upload-1", + uploadEnvironmentId: EnvironmentId.make("environment-1"), }; - const target: ComposerDraft = { - text: "", - attachments: [], - runtimeMode: "approval-required", + const uploadedOnTarget: DraftComposerAttachment = { + ...uploadedElsewhere, + id: "image-2", + uploadedAttachmentId: "upload-2", + uploadEnvironmentId: EnvironmentId.make("environment-2"), }; - - expect( - copyComposerDraftContentState( - { [sourceKey]: source, [targetKey]: target }, - sourceKey, - targetKey, - ), - ).toEqual({ - [sourceKey]: source, - [targetKey]: { - ...target, - text: source.text, - attachments: source.attachments, - importedShareIds: source.importedShareIds, + const key = createNewTaskDraft({ + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + }); + appAtomRegistry.set(composerDraftsAtom, { + ...appAtomRegistry.get(composerDraftsAtom), + [key]: { + ...getComposerDraftSnapshot(key), + text: "Ship it", + attachments: [uploadedElsewhere, uploadedOnTarget], }, }); - }); - it("does not overwrite unfinished content already stored for the selected project", () => { - const sourceKey = "new-task:environment-1:project-1"; - const targetKey = "new-task:environment-1:project-2"; - const drafts: Record = { - [sourceKey]: { text: "Source task", attachments: [] }, - [targetKey]: { text: "Target task", attachments: [] }, - }; + retargetNewTaskDraft(key, { + environmentId: EnvironmentId.make("environment-2"), + projectId: ProjectId.make("project-2"), + }); - expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); + expect(getComposerDraftSnapshot(key).attachments).toEqual([ + { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + }, + uploadedOnTarget, + ]); }); it("merges shared content into a project draft without duplicating retries", () => { @@ -1522,19 +1630,36 @@ describe("mobile composer drafts", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); + const cloudDraft: ComposerDraft = { + ...DRAFT, + project: { + environmentId, + projectId: ProjectId.make("project-cloud"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + const localDraft: ComposerDraft = { + ...DRAFT, + project: { + environmentId: retainedEnvironmentId, + projectId: ProjectId.make("project-local"), + createdAt: "2026-09-05T00:00:00.000Z", + }, + }; + expect( removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, - [`new-task:${environmentId}:project-cloud`]: DRAFT, + "new-task:cloud-draft": cloudDraft, [`${retainedEnvironmentId}:thread-local`]: DRAFT, - [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + "new-task:local-draft": localDraft, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, - [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, + "new-task:local-draft": localDraft, }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 065331d67064..0c129ad07508 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,11 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; import { + EnvironmentId as EnvironmentIdSchema, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + ProjectId as ProjectIdSchema, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, + type ProjectId, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; @@ -23,6 +26,11 @@ import { import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; +import { + isNewTaskDraftKey, + newTaskDraftKey, + parseLegacyNewTaskDraftKey, +} from "./new-task-draft-key"; import { decodeQueuedThreadMessage, encodeQueuedThreadMessage, @@ -59,6 +67,18 @@ export interface ComposerDraft { readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; + /** + * Set on new-task drafts only. The project is stored here rather than in + * the key so a project can hold any number of drafts and a draft can be + * retargeted to another project without changing identity. + */ + readonly project?: ComposerDraftProject; +} + +export interface ComposerDraftProject { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly createdAt: string; } export interface ComposerDraftContent { @@ -76,7 +96,7 @@ export interface ComposerDraftWorkspaceSelection { export type ComposerDraftSettingsUpdate = Pick< ComposerDraft, - "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" | "project" >; const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ @@ -86,6 +106,12 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ startFromOrigin: Schema.optional(Schema.Boolean), }); +const ComposerDraftProjectSchema = Schema.Struct({ + environmentId: EnvironmentIdSchema, + projectId: ProjectIdSchema, + createdAt: Schema.String, +}); + const ComposerDraftSchema = Schema.Struct({ text: Schema.String, attachments: Schema.Array(DraftComposerAttachmentSchema), @@ -94,6 +120,7 @@ const ComposerDraftSchema = Schema.Struct({ runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), + project: Schema.optional(ComposerDraftProjectSchema), }); const PersistedComposerDraftsSchema = Schema.Struct({ @@ -176,6 +203,8 @@ export function isComposerDraftEmpty(draft: ComposerDraft): boolean { return isEmptyDraft(draft); } +// The project stamp is identity, not content: a new-task draft with nothing +// else in it is still empty and gets dropped like any other. function isEmptyDraft(draft: ComposerDraft): boolean { return ( draft.text.length === 0 && @@ -187,35 +216,90 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } +/** + * Writes a draft back, dropping it once empty. A new-task draft keeps its + * entry while the composer is bound to it (the project stamp is what the + * composer binds to); the persist sweep still leaves empty ones off disk. + */ +function withComposerDraft( + current: Record, + draftKey: string, + draft: ComposerDraft, +): Record { + if (isEmptyDraft(draft) && draft.project === undefined) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { ...current, [draftKey]: draft }; +} + +export { isNewTaskDraftKey, newTaskDraftKey } from "./new-task-draft-key"; + +// Draft ids only need to be unique within this device's draft file. Deriving +// them from time plus randomness keeps this module free of native imports, +// which the persistence tests rely on. +function newDraftId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Project-keyed new-task drafts from earlier builds are rewritten on load into + * id-keyed drafts with the project stamped in, so existing drafts survive the + * switch to many-per-project. + */ +export function migrateLegacyNewTaskDraft( + key: string, + draft: ComposerDraft, + now: string, +): readonly [key: string, draft: ComposerDraft] { + const legacy = draft.project === undefined ? parseLegacyNewTaskDraftKey(key) : null; + if (legacy === null) { + return [key, draft]; + } + return [ + newTaskDraftKey(newDraftId()), + { + ...draft, + project: { + environmentId: EnvironmentIdSchema.make(legacy.environmentId), + projectId: ProjectIdSchema.make(legacy.projectId), + createdAt: now, + }, + }, + ]; +} + export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); + const now = new Date().toISOString(); return { drafts: Object.fromEntries( Object.entries(parsed.drafts) - .map( - ([key, draft]) => - [ - key, - // Stale new-task drafts left on disk by builds before the - // model-precedence fix carry a bare modelSelection with no - // other selector settings. Strip it so the next compose pass - // re-resolves project → sticky → provider defaults. Drafts - // with runtime/interaction/workspace settings or actual text / - // attachments were deliberately configured and are left alone. - key.startsWith("new-task:") && + .map(([key, draft]) => + migrateLegacyNewTaskDraft( + key, + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + isNewTaskDraftKey(key) && draft.modelSelection && draft.text.length === 0 && draft.attachments.length === 0 && draft.runtimeMode === undefined && draft.interactionMode === undefined && draft.workspaceSelection === undefined - ? { ...draft, modelSelection: undefined } - : draft, - ] as const, + ? { ...draft, modelSelection: undefined } + : draft, + now, + ), ) // importedShareIds are share-import receipts: a contentless draft // carrying one is not empty, or the same native share would be @@ -229,7 +313,13 @@ export function decodePersistedComposerState(value: unknown): { Object.entries(parsed.signedOutDrafts ?? {}).map(([id, saved]) => [ id, { - drafts: saved.drafts, + // Archived drafts come back through restoreCloudComposerDrafts + // without another decode, so they get the same key migration. + drafts: Object.fromEntries( + Object.entries(saved.drafts).map(([key, draft]) => + migrateLegacyNewTaskDraft(key, draft, now), + ), + ), queuedMessages: saved.queuedMessages.map(decodeQueuedThreadMessage), }, ]), @@ -238,10 +328,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); @@ -626,7 +712,7 @@ export async function archiveCloudComposerDrafts( const remaining = { ...current }; const savedDrafts = { ...cloud.signedOut[owner]?.drafts }; for (const [key, draft] of Object.entries(current)) { - const environmentId = composerDraftEnvironmentId(key, queued); + const environmentId = composerDraftEnvironmentId(key, queued, draft); if (environmentId !== null && environmentIds.has(environmentId)) { savedDrafts[key] = draft; delete remaining[key]; @@ -812,15 +898,7 @@ export function setComposerDraftText(draftKey: string, value: string): void { ...normalizeDraft(current[draftKey]), text: value, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); } @@ -885,15 +963,7 @@ export function replaceComposerDraftAttachments( ...normalizeDraft(current[draftKey]), attachments, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); const retainedIds = new Set(attachments.map((attachment) => attachment.id)); scheduleUnusedComposerAttachmentCleanup( @@ -909,15 +979,7 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) ...existing, attachments: existing.attachments.filter((image) => image.id !== imageId), }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); scheduleUnusedComposerAttachmentCleanup( previousAttachments.filter((attachment) => attachment.id === imageId), @@ -968,15 +1030,7 @@ export function updateComposerDraftSettings( ...normalizeDraft(current[draftKey]), ...settings, }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); }); } @@ -992,10 +1046,14 @@ export function clearComposerDraftContentState( if (!existing) { return current; } + // Clearing content is the "this draft is done" moment (sent, queued, or + // discarded), so the project stamp goes too and an otherwise-empty new-task + // draft leaves the store rather than lingering as a blank row. const { importedShareIds: _importedShareIds, modelSelection, workspaceSelection, + project: _project, ...retained } = existing; const draft = { @@ -1032,49 +1090,11 @@ export function restoreComposerDraftSnapshotState( return next; } -export function copyComposerDraftContentState( - current: Record, - sourceDraftKey: string, - targetDraftKey: string, -): Record { - if (sourceDraftKey === targetDraftKey) { - return current; - } - const source = normalizeDraft(current[sourceDraftKey]); - const target = normalizeDraft(current[targetDraftKey]); - const sourceHasContent = - source.text.length > 0 || - source.attachments.length > 0 || - (source.importedShareIds?.length ?? 0) > 0; - const targetHasContent = - target.text.length > 0 || - target.attachments.length > 0 || - (target.importedShareIds?.length ?? 0) > 0; - if (!sourceHasContent || targetHasContent) { - return current; - } - return { - ...current, - [targetDraftKey]: { - ...target, - text: source.text, - attachments: source.attachments, - ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), - }, - }; -} - -export async function copyComposerDraftContentIfEmpty( - sourceDraftKey: string, - targetDraftKey: string, -): Promise { - ensureComposerDraftsLoaded(); - if (loadPromise !== null) { - await loadPromise; - } - updateComposerDrafts((current) => - copyComposerDraftContentState(current, sourceDraftKey, targetDraftKey), - ); +function stripAttachmentUploadReference( + attachment: DraftComposerAttachment, +): DraftComposerAttachment { + const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment; + return rest; } function mergeComposerDraftText(existing: string, incoming: string): string { @@ -1261,15 +1281,7 @@ export function undoComposerDraftMergeState( interactionMode: undoSetting("interactionMode"), workspaceSelection: undoSetting("workspaceSelection"), }; - if (isEmptyDraft(draft)) { - const next = { ...current }; - delete next[draftKey]; - return next; - } - return { - ...current, - [draftKey]: draft, - }; + return withComposerDraft(current, draftKey, draft); } /** Applies undoComposerDraftMergeState and lands it durably. */ @@ -1341,15 +1353,100 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; - const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( Object.entries(drafts).filter( - ([draftKey]) => - !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ([draftKey, draft]) => + !draftKey.startsWith(environmentPrefix) && draft.project?.environmentId !== environmentId, ), ); } +/** + * Mints a new-task draft for a project. The entry is published immediately so + * the composer can bind to its key before the user types; it stays out of the + * list until it has content, and the empty-draft sweep drops it on persist if + * nothing is ever written. + */ +export function createNewTaskDraft(project: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +}): string { + const draftKey = newTaskDraftKey(newDraftId()); + const stamp: ComposerDraftProject = { + environmentId: project.environmentId, + projectId: project.projectId, + createdAt: new Date().toISOString(), + }; + updateComposerDrafts((current) => ({ + ...current, + [draftKey]: { ...EMPTY_DRAFT, project: stamp }, + })); + return draftKey; +} + +/** + * Points an existing new-task draft at a different project, keeping its + * content and identity. Workspace selection is project-specific (branch, + * worktree), so it is cleared; model and mode choices carry over. + */ +export function retargetNewTaskDraft( + draftKey: string, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): void { + updateComposerDrafts((current) => { + const existing = current[draftKey]; + const stamp = existing?.project; + if ( + stamp !== undefined && + stamp.environmentId === project.environmentId && + stamp.projectId === project.projectId + ) { + return current; + } + const { workspaceSelection: _workspaceSelection, ...retained } = normalizeDraft(existing); + // Pending uploads live on one server. Crossing environments keeps the + // local bytes (the upload worker re-sends them to the new environment) + // but drops the old stamp, so it cannot pin the source environment's + // pending upload alive from the moved draft. + const attachments = retained.attachments.map((attachment) => + attachment.uploadEnvironmentId !== undefined && + attachment.uploadEnvironmentId !== project.environmentId + ? stripAttachmentUploadReference(attachment) + : attachment, + ); + return { + ...current, + [draftKey]: { + ...retained, + attachments, + project: { + environmentId: project.environmentId, + projectId: project.projectId, + createdAt: stamp?.createdAt ?? new Date().toISOString(), + }, + }, + }; + }); +} + +/** New-task drafts for a project, newest first. */ +export function findNewTaskDraftKeys( + drafts: Readonly>, + project: { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }, +): ReadonlyArray { + return Object.entries(drafts) + .filter( + ([key, draft]) => + isNewTaskDraftKey(key) && + draft.project?.environmentId === project.environmentId && + draft.project.projectId === project.projectId, + ) + .sort(([, left], [, right]) => + (right.project?.createdAt ?? "").localeCompare(left.project?.createdAt ?? ""), + ) + .map(([key]) => key); +} + export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise { ensureComposerDraftsLoaded(); if (loadPromise !== null) { diff --git a/apps/mobile/src/state/use-pending-new-tasks.ts b/apps/mobile/src/state/use-pending-new-tasks.ts index ccfe1527b3dc..bf5f0191bf35 100644 --- a/apps/mobile/src/state/use-pending-new-tasks.ts +++ b/apps/mobile/src/state/use-pending-new-tasks.ts @@ -1,35 +1,26 @@ +import { useAtomValue } from "@effect/atom-react"; import { useMemo } from "react"; -import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn"; -import { - flattenQueuedThreadMessages, - type QueuedThreadCreation, - type QueuedThreadMessage, -} from "./thread-outbox-model"; +import { buildPendingNewTasks, type PendingNewTask } from "./pending-new-tasks-model"; +import { flattenQueuedThreadMessages } from "./thread-outbox-model"; +import { composerDraftsAtom } from "./use-composer-drafts"; import { useThreadOutboxMessages } from "./use-thread-outbox"; -/** A queued new-task creation, shaped for thread-list presentation. */ -export interface PendingNewTask { - readonly message: QueuedThreadMessage; - readonly creation: QueuedThreadCreation; - readonly title: string; -} +export type { + PendingDraftTask, + PendingNewTask, + PendingQueuedTask, +} from "./pending-new-tasks-model"; export function usePendingNewTasks(): ReadonlyArray { const queuedMessagesByThreadKey = useThreadOutboxMessages(); - return useMemo(() => { - const tasks: PendingNewTask[] = []; - for (const message of flattenQueuedThreadMessages(queuedMessagesByThreadKey)) { - if (!message.creation) { - continue; - } - tasks.push({ - message, - creation: message.creation, - title: deriveThreadTitleFromPrompt(message.text), - }); - } - tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt)); - return tasks; - }, [queuedMessagesByThreadKey]); + const drafts = useAtomValue(composerDraftsAtom); + return useMemo( + () => + buildPendingNewTasks({ + queuedMessages: flattenQueuedThreadMessages(queuedMessagesByThreadKey), + drafts, + }), + [queuedMessagesByThreadKey, drafts], + ); } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 6208a806819d..1b5209dec319 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,3 +1,4 @@ +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; @@ -12,10 +13,7 @@ import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, - derivePendingApprovals, - derivePendingUserInputs, setPendingUserInputCustomAnswer, - sortThreadActivities, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; @@ -83,20 +81,11 @@ export function useSelectedThreadRequests() { null, ); - // Sort once; both derivations expect the same lifecycle ordering. - const sortedActivities = useMemo( - () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []), - [selectedThread], - ); - const activePendingApprovals = useMemo( - () => derivePendingApprovals(sortedActivities), - [sortedActivities], + const { approvals: activePendingApprovals, userInputs: activePendingUserInputs } = useMemo( + () => derivePendingRequests(selectedThread?.activities ?? []), + [selectedThread?.activities], ); const activePendingApproval = activePendingApprovals[0] ?? null; - const activePendingUserInputs = useMemo( - () => derivePendingUserInputs(sortedActivities), - [sortedActivities], - ); const activePendingUserInput = activePendingUserInputs[0] ?? null; const activePendingUserInputDrafts = activePendingUserInput && selectedThreadShell diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 64a7ddeb7882..b9362ba8f172 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Alert } from "react-native"; -import * as Cause from "effect/Cause"; import { CommandId, @@ -16,12 +15,10 @@ import { } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { - codexFeedbackMessage, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -35,7 +32,6 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { @@ -126,27 +122,31 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const localFeedbackMessages = useMemo(() => { - const submissions = selectedThreadKey - ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) - : []; - return submissions.flatMap((submission) => - submission.status === "interrupted" - ? [] - : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], - ); - }, [feedbackSubmissionsByThreadKey, selectedThreadKey]); + const feedbackSubmissions = useMemo( + () => (selectedThreadKey ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) : []), + [feedbackSubmissionsByThreadKey, selectedThreadKey], + ); + const dismissFeedback = useCallback( + (id: MessageId) => { + if (!selectedThreadKey) return; + setFeedbackSubmissionsByThreadKey((current) => ({ + ...current, + [selectedThreadKey]: (current[selectedThreadKey] ?? []).filter((entry) => entry.id !== id), + })); + }, + [selectedThreadKey], + ); const selectedThreadMessages = selectedThreadDetail?.messages; const selectedThreadActivities = selectedThreadDetail?.activities; const selectedThreadFeed = useMemo( () => selectedThreadMessages && selectedThreadActivities - ? buildThreadFeed( - { messages: selectedThreadMessages, activities: selectedThreadActivities }, - { localMessages: localFeedbackMessages }, - ) + ? buildThreadFeed({ + messages: selectedThreadMessages, + activities: selectedThreadActivities, + }) : [], - [localFeedbackMessages, selectedThreadActivities, selectedThreadMessages], + [selectedThreadActivities, selectedThreadMessages], ); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; @@ -294,7 +294,7 @@ export function useThreadComposerState() { return null; } const metadata = makeQueuedMessageMetadata(); - const result = await submitCodexFeedback({ + await submitCodexFeedback({ submission: { id: MessageId.make(metadata.messageId), command: text, @@ -322,25 +322,6 @@ export function useThreadComposerState() { }, }), }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return null; - } - const error = Cause.squash(result.cause); - Alert.alert( - "Could not send feedback to OpenAI", - error instanceof Error ? error.message : "An error occurred.", - ); - return null; - } - const feedbackId = result.value.feedbackId; - Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ - { text: "OK", style: "cancel" }, - { - text: "Copy ID", - onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), - }, - ]); return null; } @@ -573,6 +554,8 @@ export function useThreadComposerState() { ); return { + feedbackSubmissions, + dismissFeedback, selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.test.ts index 9d8f6f02793f..bc295054038b 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.test.ts @@ -582,7 +582,7 @@ describe("thread outbox delivered creation recovery", () => { }); describe("thread outbox recovery rollback", () => { - it("restores a rejected new task into its durable project draft", async () => { + it("restores a rejected new task as its own draft for the project", async () => { const message: QueuedThreadMessage = { ...queuedMessage({ messageId: "message-creation-restore", text: "new task text" }), modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, @@ -599,14 +599,19 @@ describe("thread outbox recovery rollback", () => { "restored", ); + // The draft is keyed by the message so a retry lands on the same one, and + // stamped with the project so it shows up as a Draft row for that project. expect( - composerDrafts.getComposerDraftSnapshot( - `new-task:${message.environmentId}:${message.creation!.projectId}`, - ), + composerDrafts.getComposerDraftSnapshot(`new-task:restored-${message.messageId}`), ).toMatchObject({ text: message.text, attachments: message.attachments, modelSelection: message.modelSelection, + project: { + environmentId: message.environmentId, + projectId: message.creation!.projectId, + createdAt: message.createdAt, + }, }); expect(remainingMessages()).toEqual([]); expect(harness.setPendingConnectionError).toHaveBeenCalledWith("rejected by server"); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 487aa4da4c2f..9f1b6422ba53 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,7 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; -import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; +import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; import { randomHex } from "../lib/uuid"; @@ -53,6 +53,7 @@ import { type ComposerDraft, getComposerDraftSnapshot, mergeComposerDraftContent, + newTaskDraftKey, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, undoComposerDraftMerge, @@ -369,6 +370,7 @@ export async function restoreRejectedQueuedMessage( let mergedDraft: ComposerDraft; try { + stampRecoveryDraftProject(queuedMessage, draftKey); await mergeComposerDraftContent(draftKey, { text: queuedMessage.text, attachments: queuedMessage.attachments, @@ -451,12 +453,31 @@ export async function restoreRejectedQueuedMessage( } } +/** + * A rejected creation becomes its own new-task draft rather than merging into + * whatever the user is typing for that project. The key derives from the + * message id so a retry after a mid-recovery failure lands on the same draft + * instead of minting another. + */ function recoveryDraftKey(queuedMessage: QueuedThreadMessage): string { return queuedMessage.creation - ? `new-task:${scopedProjectKey(queuedMessage.environmentId, queuedMessage.creation.projectId)}` + ? newTaskDraftKey(`restored-${queuedMessage.messageId}`) : scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId); } +function stampRecoveryDraftProject(queuedMessage: QueuedThreadMessage, draftKey: string): void { + if (!queuedMessage.creation) { + return; + } + updateComposerDraftSettings(draftKey, { + project: { + environmentId: queuedMessage.environmentId, + projectId: queuedMessage.creation.projectId, + createdAt: queuedMessage.createdAt, + }, + }); +} + async function preserveUploadedAttachmentsForEditor( originalMessage: QueuedThreadMessage, uploadedMessage: QueuedThreadMessage, diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index ddda3b1acd96..f6fddfdc5578 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -39,7 +39,7 @@ describe("presentThreadPr", () => { presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), ).toMatchObject({ accessibilityLabel: "#3774 pull request draft", - textClassName: "text-adaptive-zinc-500-400", + textClassName: "text-foreground-muted", }); }); }); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 8e4eb27963ac..e824ce56217b 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; -import { vcsEnvironment } from "./vcs"; -const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; interface ThreadPrSnapshot { @@ -23,7 +22,7 @@ interface ThreadPrSnapshot { } // One bounded cache survives row virtualization without retaining one live -// atom for every thread, branch, directory, or linked pull request ever seen. +// atom for every thread or pull request ever seen. const threadPrSnapshotsAtom = Atom.make>(new Map()).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-pr-snapshots"), @@ -36,20 +35,13 @@ export { } from "./thread-pr-presentation"; /** - * Live PR status for a thread's branch. Subscriptions are deduplicated per - * (environmentId, cwd) by the atom family, so many rows on the same worktree - * or project root share one stream — and virtualization means only visible - * rows subscribe at all. + * Live status for a thread's server-provided PR. Visible rows share a summary + * request for the same PR in the same environment. */ -export function useThreadPr( - thread: EnvironmentThreadShell, - projectCwd: string | null, -): ThreadPrPresentation | null { - const cwd = thread.worktreePath ?? projectCwd; +export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { + const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshotIdentity = JSON.stringify( - thread.linkedPullRequest ?? { branch: thread.branch, cwd }, - ); + const snapshotIdentity = JSON.stringify(pullRequestRef); // Select this row's entry so writes for other rows do not re-render it. const snapshotEntry = useAtomValue( threadPrSnapshotsAtom, @@ -59,45 +51,30 @@ export function useThreadPr( ), ); const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; - const gitStatus = useEnvironmentQuery( - thread.linkedPullRequest == null && thread.branch !== null && cwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd }, - }) - : null, - ); - const linkedPullRequest = useEnvironmentQuery( - thread.linkedPullRequest == null + const pullRequestSummary = useEnvironmentQuery( + pullRequestRef === null ? null - : linkedPullRequestDetailAtom({ + : pullRequestSummaryAtom({ environmentId: thread.environmentId, input: { - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, + projectId: pullRequestRef.projectId, + repository: pullRequestRef.repository, + number: pullRequestRef.number, }, }), ); const live = useMemo(() => { - if (thread.linkedPullRequest != null) { - const detail = linkedPullRequest.data; - return detail === null - ? undefined - : presentThreadPr(pullRequestDetailToVcsStatus(detail), { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }); - } - - const status = gitStatus.data; - if (thread.branch === null) return null; - if (status === null) return undefined; - if (status.refName !== thread.branch || !status.pr) return null; - return presentThreadPr(status.pr, status.sourceControlProvider); - }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); + if (pullRequestRef === null) return null; + const summary = pullRequestSummary.data; + return summary === null + ? undefined + : presentThreadPr(pullRequestDetailToVcsStatus(summary), { + kind: summary.provider, + name: summary.provider, + baseUrl: "", + }); + }, [pullRequestRef, pullRequestSummary.data]); useEffect(() => { if (live === undefined) return; diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index e0e87d609d5f..7e012cb78903 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -55,12 +55,17 @@ function threadDetailToShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + branchPullRequest: thread.branchPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + activeOrderKey: thread.activeOrderKey, + pinnedAt: thread.pinnedAt, + pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, session: thread.session, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ce34855a3194..1c035d5fa15a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,6 +67,7 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { start: () => Effect.void, diff --git a/apps/server/package.json b/apps/server/package.json index 3d03fcd45dba..1f472a71f49d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,7 +8,7 @@ "directory": "apps/server" }, "bin": { - "t3": "./dist/bin.mjs" + "q1code": "./dist/bin.mjs" }, "files": [ "dist" @@ -36,6 +36,7 @@ }, "devDependencies": { "@effect/vitest": "catalog:", + "@q1code/core": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 53e33cf7fd5b..b83b8684432c 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -83,6 +83,30 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("reports pixel dimensions from an image header and nothing for other files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-dimensions-" }); + const png = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, + 0x06, 0x40, 0, 0, 0x03, 0x84, + ]); + yield* fs.writeFile(path.join(root, "shot.png"), png); + yield* fs.writeFileString(path.join(root, "clip.mp4"), "video"); + yield* fs.writeFileString(path.join(root, "broken.png"), "not a png"); + const issue = (name: string) => + issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: name }, + workspaceRoot: root, + }); + + expect((yield* issue("shot.png")).imageDimensions).toEqual({ width: 1600, height: 900 }); + expect((yield* issue("clip.mp4")).imageDimensions).toBeUndefined(); + expect((yield* issue("broken.png")).imageDimensions).toBeUndefined(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("resolves relative media paths from the thread workspace, including outside it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index a0d849bf603a..956c4ac44211 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,6 +21,11 @@ import { WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; +import { + IMAGE_DIMENSIONS_HEADER_BYTES, + readImageDimensions, + type ImageDimensions, +} from "@t3tools/shared/imageDimensions"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -44,7 +49,7 @@ import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; -import { openMediaFile, type OpenMediaFile } from "./MediaFile.ts"; +import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; export const ASSET_ROUTE_PREFIX = "/api/assets"; @@ -224,6 +229,34 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { Effect.orElseSucceed(() => null), ); +/** + * Reads pixel dimensions from an image's header so clients can reserve the + * exact box before the bytes arrive. Best effort: an unreadable or unsupported + * file just leaves the field out, and the client measures after decode. Only + * formats the parser understands are opened; SVG and the rest are skipped. + */ +const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); + +/** From the identity-checked, non-blocking handle the caller already holds. */ +const readImageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) => + readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe( + Effect.map(readImageDimensions), + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + +/** + * Opens through `openMediaFile` so a path swapped for a FIFO cannot block the + * request; a regular open would wait for a writer that never comes. + */ +const readImageDimensionsFromHeader = (filePath: string) => + openMediaFile(filePath).pipe( + Effect.flatMap((file) => + file === null ? Effect.succeed(null) : readImageDimensionsFromOpenFile(filePath, file), + ), + Effect.scoped, + Effect.orElseSucceed((): ImageDimensions | null => null), + ); + export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; @@ -236,6 +269,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let claims: AssetClaims; let fileName: string; let sourcePath: string | undefined; + let imageDimensions: ImageDimensions | null = null; switch (input.resource._tag) { case "media-file": { @@ -265,18 +299,33 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - const identity = yield* openMediaFile(canonicalFile).pipe( - Effect.map((file) => - file ? { device: file.info.dev.toString(), inode: file.info.ino.toString() } : null, + const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has( + path.extname(canonicalFile).toLowerCase(), + ); + const opened = yield* openMediaFile(canonicalFile).pipe( + Effect.flatMap((file) => + file === null + ? Effect.succeed(null) + : Effect.map( + wantsDimensions + ? readImageDimensionsFromOpenFile(canonicalFile, file) + : Effect.succeed(null), + (dimensions) => ({ + identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, + dimensions, + }), + ), ), Effect.scoped, Effect.mapError( (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), ), ); - if (!identity) { + if (!opened) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } + const identity = opened.identity; + imageDimensions = opened.dimensions; claims = { version: 1, kind: "media-file-exact", @@ -347,6 +396,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (HEADER_IMAGE_EXTENSIONS.has(path.extname(resolved.relativePath).toLowerCase())) { + imageDimensions = yield* readImageDimensionsFromHeader(canonicalFile); + } claims = isWorkspaceImagePreviewPath(resolved.relativePath) ? { version: 1, @@ -390,6 +442,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i INLINE_DOCUMENT_EXTENSIONS.has(extension) ? INLINE_DOCUMENT_MIME_TYPES[extension] : undefined; + if (!isGenericFile) { + imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + } claims = { version: 1, kind: "attachment", @@ -547,6 +602,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), + ...(imageDimensions !== null ? { imageDimensions } : {}), }; }); diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts index e1053555b052..7fb0c1135607 100644 --- a/apps/server/src/assets/MediaFile.ts +++ b/apps/server/src/assets/MediaFile.ts @@ -19,6 +19,18 @@ class MediaFileOpenError extends Schema.TaggedErrorClass()( } } +class MediaFileReadError extends Schema.TaggedErrorClass()( + "MediaFileReadError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read media file '${this.path}'.`; + } +} + class MediaFileStatError extends Schema.TaggedErrorClass()( "MediaFileStatError", { @@ -95,6 +107,17 @@ export const openMediaFile = Effect.fn("openMediaFile")(function* ( ); }); +/** Reads the leading bytes of an already-validated media file, never past the end. */ +export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) => + Effect.tryPromise({ + try: async () => { + const buffer = new Uint8Array(byteCount); + const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0); + return buffer.subarray(0, bytesRead); + }, + catch: (cause) => new MediaFileReadError({ path: filePath, cause }), + }); + export const statMediaFile = Effect.fn("statMediaFile")(function* ( filePath: string, file: OpenMediaFile, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..7262239577b4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de6661f45886..a069322aa8bf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, @@ -99,6 +100,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 0a2e4091560b..90c8abfe9406 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -20,6 +20,8 @@ import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; +import { forkCommand } from "./fork/cli/forkCommand.ts"; // fork: base +import { prismCommand } from "./fork/cli/prism.ts"; // fork: prism const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -63,6 +65,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, + forkCommand, // fork: base + prismCommand, // fork: prism ]), ); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c8b143f87ebc..c9f514037804 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -108,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { }); }), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -202,9 +204,11 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -289,9 +293,11 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -361,9 +367,11 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -418,9 +426,11 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index 0dddca4b1bf0..272f7fc404fb 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -211,7 +211,7 @@ describe("t3 app", () => { withTempDirectory("t3-app-preferred-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".q1code"); const desktop = yield* fakeDesktop({ baseDir }); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); @@ -227,7 +227,7 @@ describe("t3 app", () => { withTempDirectory("t3-app-dev-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".q1code"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); yield* runCli(["app"]); @@ -243,7 +243,7 @@ describe("t3 app", () => { withTempDirectory("t3-app-explicit-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".q1code"); const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); @@ -261,7 +261,7 @@ describe("t3 app", () => { withTempDirectory("t3-app-response-test-", (root) => Effect.gen(function* () { vi.mocked(NodeOS.homedir).mockReturnValue(root); - const baseDir = NodePath.join(root, ".t3"); + const baseDir = NodePath.join(root, ".q1code"); const desktop = yield* fakeDesktop({ baseDir, reply: (request) => diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e84..f3cc88d1b58a 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,11 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, - isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); @@ -209,10 +189,3 @@ it.effect("keeps disconnect causes in structured logs and out of console warning ), ); }); - -it("treats only the literal 'true' as publish-enabled", () => { - assert.equal(isPublishAgentActivityEnabledValue("true"), true); - assert.equal(isPublishAgentActivityEnabledValue("false"), false); - assert.equal(isPublishAgentActivityEnabledValue(null), false); - assert.equal(isPublishAgentActivityEnabledValue("TRUE"), false); -}); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 25cfb18f3402..b7c78e5ea68b 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -144,10 +144,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -export function isPublishAgentActivityEnabledValue(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - interface CloudCliStatus { readonly desired: boolean; readonly authenticated: boolean; @@ -464,7 +460,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } @@ -573,7 +569,7 @@ const connectStatusCommand = Command.make("status", { linked: Option.isSome(cloudUserId), cloudUserId: Option.isSome(cloudUserId) ? bytesToString(cloudUserId.value) : null, relayUrl: Option.isSome(relayUrl) ? bytesToString(relayUrl.value) : null, - publishAgentActivity: isPublishAgentActivityEnabledValue( + publishAgentActivity: isAgentActivityPublishingEnabledValue( Option.isSome(publishAgentActivity) ? bytesToString(publishAgentActivity.value) : null, ), relayClient: executable, diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..c084812c1bf0 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,49 +1,62 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { formatCliCommand } from "./invocation.ts"; -it("detects package runners from their cache entry paths", () => { - assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); - assert.equal( - detectCliRunner( +it("formats package runner commands from their cache entry paths", () => { + for (const [entryPath, expected] of [ + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx q1code serve"], + [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "npx", - ); - assert.equal( - detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner( + "npx q1code serve", + ], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx q1code serve"], + [ + "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx q1code serve", + ], + [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "pnpm dlx", - ); - assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); - assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); - assert.equal( - detectCliRunner( + "pnpm dlx q1code serve", + ], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx q1code serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx q1code serve"], + [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - ), - "bunx", - ); + "bunx q1code serve", + ], + ] as const) { + assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); + } }); it("treats stable installs as direct invocations", () => { - assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("")); + for (const entryPath of [ + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", + "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", + "", + ]) { + assert.equal( + formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), + "q1code serve", + ); + } }); it("re-suggests the nightly channel only for nightly builds", () => { - assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); - assert.equal(suggestedPackageSpec("0.0.31"), "t3"); + for (const [version, expected] of [ + ["0.0.31-nightly.20260729", "npx q1code@nightly serve"], + ["0.0.31", "npx q1code serve"], + ] as const) { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", + version, + }), + expected, + ); + } }); it("formats serve suggestions to match the launching command", () => { @@ -53,7 +66,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs", version: "0.0.31-nightly.20260729", }), - "npx t3@nightly serve", + "npx q1code@nightly serve", ); assert.equal( formatCliCommand({ @@ -61,7 +74,7 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", version: "0.0.31", }), - "bunx t3 serve", + "bunx q1code serve", ); assert.equal( formatCliCommand({ @@ -69,6 +82,6 @@ it("formats serve suggestions to match the launching command", () => { entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs", version: "0.0.31-nightly.20260729", }), - "t3 serve", + "q1code serve", ); }); diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..2b83281e4127 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,3 +1,4 @@ +import { BRAND } from "@q1code/core/brand"; // fork: base import * as Effect from "effect/Effect"; import { HostProcessArguments } from "@t3tools/shared/hostProcess"; @@ -18,7 +19,7 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ -export function detectCliRunner(entryPath: string): CliRunner | null { +function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; @@ -42,8 +43,8 @@ export function detectCliRunner(entryPath: string): CliRunner | null { * from the running version: nightly builds re-suggest the nightly channel, * anything else suggests the bare package. */ -export function suggestedPackageSpec(version: string): string { - return version.includes("-nightly.") ? "t3@nightly" : "t3"; +function suggestedPackageSpec(version: string): string { + return version.includes("-nightly.") ? `${BRAND.cliName}@nightly` : BRAND.cliName; // fork: base } /** @@ -59,7 +60,7 @@ export function formatCliCommand(input: { }): string { const runner = detectCliRunner(input.entryPath); if (runner === null) { - return `t3 ${input.subcommand}`; + return `${BRAND.cliName} ${input.subcommand}`; // fork: base } return `${runner} ${suggestedPackageSpec(input.version)} ${input.subcommand}`; } diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..33a0f1224961 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize T3 Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..8c3869accc76 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 688617440500..577f8f4c4931 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -29,7 +29,7 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", + unitPath: "/home/theo/.config/systemd/user/q1code.service", }); expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); @@ -43,7 +43,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => { launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", + unitPath: "/home/theo/.config/systemd/user/q1code.service", }); expect(unit).toContain("OOMPolicy=continue"); @@ -54,7 +54,7 @@ const macPlan = { launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", + unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.q1code.service.plist", }; const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; @@ -149,11 +149,11 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const failed = command === control.failCommand; if (!failed && command === "loginctl enable-linger --no-ask-password 501") control.linger = "yes"; - if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; - if (!failed && command === "systemctl --user restart t3code.service") control.active = true; + if (!failed && command === "systemctl --user enable q1code.service") control.enabled = true; + if (!failed && command === "systemctl --user restart q1code.service") control.active = true; if ( control.stateAfterStop !== undefined && - (command === "systemctl --user stop t3code.service" || + (command === "systemctl --user stop q1code.service" || command.startsWith("launchctl bootout --wait ")) ) { yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); @@ -264,7 +264,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ); expect(yield* fs.readFileString(statePath)).toBe(before); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); - expect(commands).not.toContain("systemctl --user stop t3code.service"); + expect(commands).not.toContain("systemctl --user stop q1code.service"); }), ); @@ -337,7 +337,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(commands.some((command) => command.startsWith("npm "))).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. - expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( + expect(timeouts.get("systemctl --user disable --now q1code.service")).toEqual( Duration.seconds(120), ); }), @@ -411,9 +411,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ), ).toEqual( platform === "linux" - ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] + ? ["systemctl --user stop q1code.service", "systemctl --user restart q1code.service"] : [ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/com.t3tools.q1code.service", `launchctl bootstrap gui/501 ${plan.unitPath}`, ], ); @@ -476,9 +476,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", + "systemctl --user stop q1code.service", "systemctl --user daemon-reload", - "systemctl --user restart t3code.service", + "systemctl --user restart q1code.service", ]); }), ); @@ -511,8 +511,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), ), ).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user restart t3code.service", + "systemctl --user stop q1code.service", + "systemctl --user restart q1code.service", ]); } }), @@ -534,7 +534,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect( plan.unitPath.endsWith( - path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"), + path.join("Library", "LaunchAgents", "com.t3tools.q1code.service.plist"), ), ).toBe(true); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -555,7 +555,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. - expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual( + expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.q1code.service")).toEqual( Duration.seconds(120), ); }), @@ -572,8 +572,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - "launchctl enable gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/com.t3tools.q1code.service", + "launchctl enable gui/501/com.t3tools.q1code.service", `launchctl bootstrap gui/501 ${plistPath}`, `launchctl bootstrap gui/501 ${plistPath}`, ]); @@ -636,7 +636,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); yield* service.install(); - control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; + control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.q1code.service"; yield* service.install(); expect((yield* service.status).current).toBe(true); @@ -668,7 +668,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { ); expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + "launchctl bootout --wait gui/501/com.t3tools.q1code.service", `launchctl bootstrap gui/501 ${plistPath}`, ]); } diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index a0bd6f30c6ca..2068007dd9ea 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -14,6 +14,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { BRAND } from "@q1code/core/brand"; // fork: base import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -31,11 +32,11 @@ import { type ServiceState, } from "./serviceProtocol.ts"; -const BOOT_SERVICE_NAME = "t3code"; +const BOOT_SERVICE_NAME = BRAND.serviceName; // fork: base const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. -const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; +const BOOT_SERVICE_LAUNCHD_LABEL = `com.t3tools.${BOOT_SERVICE_NAME}.service`; // fork: base const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts deleted file mode 100644 index 1104927b9800..000000000000 --- a/apps/server/src/cloud/cliAuthHtml.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; - -import { - renderLoopbackAuthorizationCompleteHtml, - resolveLoopbackAuthorizationStage, -} from "./cliAuthHtml.ts"; - -it("renders the branded loopback authorization completion page", () => { - const html = renderLoopbackAuthorizationCompleteHtml(); - - expect(resolveLoopbackAuthorizationStage()).toBe("dev"); - expect(html).toContain("T3 Code (Dev)"); - expect(html).toContain('class="stage stage-dev"'); - expect(html).not.toContain("Secure terminal handoff"); - expect(html).toContain("You're connected"); - expect(html).toContain("Return to your terminal"); - expect(html).not.toContain('class="next"'); - expect(html).toContain('name="viewport"'); - expect(html).not.toContain('class="status"'); -}); - -it("renders the matching header treatment for each release channel", () => { - const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); - const latest = renderLoopbackAuthorizationCompleteHtml("latest"); - - expect(nightly).toContain("T3 Code (Nightly)"); - expect(nightly).toContain('class="stage stage-nightly"'); - expect(latest).toContain('

T3 Code

'); - expect(latest).not.toContain("(Latest)"); - expect(latest).toContain('class="stage stage-latest"'); -}); diff --git a/apps/server/src/cloud/cliAuthHtml.ts b/apps/server/src/cloud/cliAuthHtml.ts index 5a22a25993a9..69d3b471ae32 100644 --- a/apps/server/src/cloud/cliAuthHtml.ts +++ b/apps/server/src/cloud/cliAuthHtml.ts @@ -2,7 +2,7 @@ export type LoopbackAuthorizationStage = "dev" | "nightly" | "latest"; declare const __T3CODE_BUILD_CHANNEL__: "nightly" | "latest" | undefined; -export function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { +function resolveLoopbackAuthorizationStage(): LoopbackAuthorizationStage { return typeof __T3CODE_BUILD_CHANNEL__ === "undefined" ? "dev" : __T3CODE_BUILD_CHANNEL__; } diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..44cb127bb2b0 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -1,12 +1,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import { BRAND, releaseTarballName } from "@q1code/core/brand"; // fork: base import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; // fork: base import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { releaseDownloaderTestLayer } from "../fork/releaseTarball.testing.ts"; // fork: base import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -21,7 +25,7 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => const prefixIndex = input.args.indexOf("--prefix"); const stagingDir = input.args[prefixIndex + 1]; if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); - const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); + const entry = path.join(stagingDir, BRAND.runtimeEntryRelativePath); // fork: base yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); return { @@ -37,140 +41,351 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }), }); -it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("validates a staging tree before atomically publishing it", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); - let validatedDirectory = ""; - - const installed = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: "1.2.3", - fs, - path, - runner: successfulRunner(fs, path), - validate: (staging) => - Effect.gen(function* () { - validatedDirectory = staging.versionDir; - assert.isFalse(yield* fs.exists(finalPaths.versionDir)); - assert.isTrue(yield* fs.exists(staging.entryPath)); - }).pipe(Effect.orDie), - }); - - assert.notEqual(validatedDirectory, finalPaths.versionDir); - assert.deepEqual(installed, finalPaths); - assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); - }), - ); - - it.effect("removes staging and leaves no final runtime when validation fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); - - yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: "1.2.3", - fs, - path, - runner: successfulRunner(fs, path), - validate: () => - Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), - }).pipe(Effect.flip); - - assert.isFalse(yield* fs.exists(finalPaths.versionDir)); - assert.deepEqual( - (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) => - entry.startsWith(".staging-"), - ), - [], - ); - }), - ); - - it.effect("replaces an incomplete pinned runtime", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); - yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); - yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); - - yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: "1.2.3", - fs, - path, - runner: successfulRunner(fs, path), - validate: () => Effect.void, - }); - - assert.isFalse(yield* fs.exists(path.join(finalPaths.versionDir, "partial"))); - assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - }), - ); - - it.effect("preserves a completed runtime when validation fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); - yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); - yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); - yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); - - let validations = 0; - yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: "1.2.3", - fs, - path, - runner: successfulRunner(fs, path), - validate: (paths) => - Effect.gen(function* () { - validations += 1; - const source = yield* fs.readFileString(paths.entryPath).pipe(Effect.orDie); - if (source === "broken\n") { - return yield* new PinnedRuntimeInstallError({ step: "validating the runtime" }); - } +// fork: base +it.layer(Layer.merge(NodeServices.layer, releaseDownloaderTestLayer("1.2.3")))( + "ensurePinnedRuntimeInstalled", + (it) => { + it.effect("installs through pnpm when its Node runtime has no npm executable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); + const commands: Array = []; + const install = successfulRunner(fs, path); + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input); + return input.command === "npm" + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ) + : install.run(input); + }, + }), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.deepEqual( + commands.map((command) => command.command), + ["npm", "pnpm"], + ); + assert.deepEqual(commands[1]!.args, [ + "--package=npm@11", + "dlx", + "npm", + ...commands[0]!.args, + ]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("does not try a different installer for npm permission failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input.command); + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }), + }), + ); + }, }), - }).pipe(Effect.flip); - - assert.equal(validations, 1); - assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); - }), - ); - - it.effect("removes staging when installation is interrupted", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-interrupt-" }); - const started = yield* Deferred.make(); - const runner = ProcessRunner.ProcessRunner.of({ - run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }); - const install = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: "1.2.3", - fs, - path, - runner, - validate: () => Effect.void, - }).pipe(Effect.forkScoped); - - yield* Deferred.await(started); - yield* Fiber.interrupt(install); - const versionsDir = path.join(baseDir, "runtime", "versions"); - assert.deepEqual(yield* fs.readDirectory(versionsDir), []); - }), - ); -}); + validate: () => Effect.die("must not validate a failed install"), + }).pipe(Effect.flip); + assert.deepEqual(commands, ["npm"]); + }), + ); + + it.effect("validates a staging tree before atomically publishing it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + let validatedDirectory = ""; + + const installed = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: (staging) => + Effect.gen(function* () { + validatedDirectory = staging.versionDir; + assert.isFalse(yield* fs.exists(finalPaths.versionDir)); + assert.isTrue(yield* fs.exists(staging.entryPath)); + }).pipe(Effect.orDie), + }); + + assert.notEqual(validatedDirectory, finalPaths.versionDir); + assert.deepEqual(installed, finalPaths); + assert.isTrue(yield* fs.exists(finalPaths.entryPath)); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("removes staging and leaves no final runtime when validation fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: () => + Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), + }).pipe(Effect.flip); + + assert.isFalse(yield* fs.exists(finalPaths.versionDir)); + assert.deepEqual( + (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) => + entry.startsWith(".staging-"), + ), + [], + ); + }), + ); + + it.effect("replaces an incomplete pinned runtime", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); + yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); + + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: () => Effect.void, + }); + + assert.isFalse(yield* fs.exists(path.join(finalPaths.versionDir, "partial"))); + assert.isTrue(yield* fs.exists(finalPaths.entryPath)); + }), + ); + + it.effect("preserves a completed runtime when validation fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); + yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + + let validations = 0; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: successfulRunner(fs, path), + validate: (paths) => + Effect.gen(function* () { + validations += 1; + const source = yield* fs.readFileString(paths.entryPath).pipe(Effect.orDie); + if (source === "broken\n") { + return yield* new PinnedRuntimeInstallError({ step: "validating the runtime" }); + } + }), + }).pipe(Effect.flip); + + assert.equal(validations, 1); + assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); + }), + ); + + it.effect("removes staging when installation is interrupted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-pinned-runtime-interrupt-", + }); + const started = yield* Deferred.make(); + const runner = ProcessRunner.ProcessRunner.of({ + run: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }); + const install = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner, + validate: () => Effect.void, + }).pipe(Effect.forkScoped); + + yield* Deferred.await(started); + yield* Fiber.interrupt(install); + const versionsDir = path.join(baseDir, "runtime", "versions"); + assert.deepEqual(yield* fs.readDirectory(versionsDir), []); + }), + ); + + // fork: base + it.effect("installs the verified release tarball from the staging directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "q1code-pinned-runtime-tarball-", + }); + let installArgs: ReadonlyArray = []; + let tarballExistedAtInstall = false; + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + installArgs = input.args; + const tarballPath = input.args.at(-1); + if (tarballPath !== undefined) { + tarballExistedAtInstall = yield* fs.exists(tarballPath).pipe(Effect.orDie); + } + return yield* successfulRunner(fs, path).run(input); + }), + }); + + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner, + validate: () => Effect.void, + }); + + const tarballPath = installArgs.at(-1) ?? ""; + assert.equal(path.basename(tarballPath), releaseTarballName("1.2.3")); + assert.equal( + installArgs.at(-1)?.startsWith(path.join(baseDir, "runtime", "versions")), + true, + ); + assert.isTrue(tarballExistedAtInstall); + assert.deepEqual(installArgs.slice(0, 2), ["install", "--prefix"]); + }), + ); + + // fork: base + it.effect("fails closed on a checksum mismatch without running npm", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "q1code-pinned-runtime-mismatch-", + }); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + let npmRuns = 0; + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => { + npmRuns += 1; + return successfulRunner(fs, path).run(input); + }, + }); + + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner, + validate: () => Effect.void, + }).pipe( + Effect.flip, + Effect.provide( + releaseDownloaderTestLayer("1.2.3", { + checksums: `${"0".repeat(64)} ${releaseTarballName("1.2.3")}\n`, + }), + ), + ); + + assert.equal(error._tag, "PinnedRuntimeInstallError"); + assert.include(error.message, "verifying the sha256"); + assert.equal(npmRuns, 0); + assert.isFalse(yield* fs.exists(finalPaths.versionDir)); + assert.deepEqual( + (yield* fs.readDirectory(path.dirname(finalPaths.versionDir))).filter((entry) => + entry.startsWith(".staging-"), + ), + [], + ); + }), + ); + + // fork: base + it.effect("fails closed when the release checksums do not list the tarball", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "q1code-pinned-runtime-missing-", + }); + let npmRuns = 0; + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => { + npmRuns += 1; + return successfulRunner(fs, path).run(input); + }, + }); + + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner, + validate: () => Effect.void, + }).pipe( + Effect.flip, + Effect.provide(releaseDownloaderTestLayer("1.2.3", { checksums: "" })), + ); + + assert.equal(error._tag, "PinnedRuntimeInstallError"); + assert.include(error.message, "release checksums"); + assert.equal(npmRuns, 0); + }), + ); + }, +); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..bda472c9aadb 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -2,10 +2,13 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import { BRAND } from "@q1code/core/brand"; // fork: base +import { stageReleaseTarball } from "../fork/releaseTarball.ts"; // fork: base import * as ProcessRunner from "../processRunner.ts"; /** @@ -36,7 +39,7 @@ export function pinnedRuntimePaths( const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); return { versionDir, - entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: path.join(versionDir, BRAND.runtimeEntryRelativePath), // fork: base sentinelPath: path.join(versionDir, ".install-complete"), }; } @@ -146,20 +149,45 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ); const stagingPaths: PinnedRuntimePaths = { versionDir: stagingDir, - entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: input.path.join(stagingDir, BRAND.runtimeEntryRelativePath), // fork: base sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const tarballPath = yield* stageReleaseTarball(input, stagingDir).pipe( + // fork: base + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: cause.step, cause })), // fork: base + ); // fork: base + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + tarballPath, // fork: base + ]; yield* runner .run({ command: "npm", - args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`], + args: installArgs, // Native dependencies may compile from source on slower machines. timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, }) .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), Effect.filterOrFail( (result) => result.code === 0, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index e6d8010f19d7..8f870c104fe6 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -6,6 +6,7 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; // fork: base import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -16,6 +17,7 @@ import * as ProcessRunner from "../processRunner.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; import * as ServerSelfUpdate from "./selfUpdate.ts"; +import { releaseDownloaderTestLayer } from "../fork/releaseTarball.testing.ts"; // fork: base interface HarnessOptions { readonly mode?: "web" | "desktop"; @@ -105,7 +107,8 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( return { selfUpdate, order }; }); -it.layer(NodeServices.layer)("server self update", (it) => { +const selfUpdateTestLayer = Layer.merge(NodeServices.layer, releaseDownloaderTestLayer("1.1.0")); // fork: base +it.layer(selfUpdateTestLayer)("server self update", (it) => { it.effect("marks running threads at the boot-service handoff", () => Effect.gen(function* () { const events: string[] = []; diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 2efa3375d275..5bcc74206893 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -19,7 +19,7 @@ function makeNativeSnapshot( processes: ResourceMonitorSnapshotEvent["processes"], ): ResourceMonitorSnapshotEvent { return { - version: 2, + version: 3, type: "snapshot", sequence: 1, sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 141aa405af21..a674a25c1ec8 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); + expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index cfecfc00c86d..11c9a5e1d139 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -18,6 +18,8 @@ import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; +import * as ForkFlags from "../fork/ForkFlags.ts"; // fork: base +import * as Prism from "../fork/prism/PrismService.ts"; // fork: prism import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; @@ -184,6 +186,7 @@ export const make = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; const identity = yield* ServerEnvironmentIdentity; + const forkFlags = yield* ForkFlags.ForkFlagsService; // fork: base const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; const environmentId = yield* identity.getEnvironmentId; @@ -219,12 +222,14 @@ export const make = Effect.gen(function* () { pullRequests: true, threadSettlement: true, threadAutoSettlement: true, + threadRestartContinuation: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, threadPinning: true, threadPinReorder: true, + threadActiveReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, environmentIcon: true, @@ -249,6 +254,7 @@ export const make = Effect.gen(function* () { ...descriptor, capabilities: { ...descriptor.capabilities, agentActivityPublishing }, })), + Effect.flatMap((d) => ForkFlags.attachForkFlags(d, forkFlags)), // fork: base ), }); }); @@ -264,4 +270,6 @@ export const identityLayer = Layer.effect(ServerEnvironmentIdentity, makeIdentit export const layer = Layer.effect(ServerEnvironment, make).pipe( Layer.provideMerge(identityLayer), Layer.provide(ProcessRunner.layer), + Layer.provide(Prism.layer), // fork: prism + Layer.provide(ForkFlags.layer), // fork: base ); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts index c4318e7a9d7b..2ae0989227de 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.test.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -202,6 +202,26 @@ describe("detectServerEnvironmentMachineKind", () => { }), ); + it.effect("recognizes WSL before its Hyper-V DMI identity", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + osrelease: "5.15.153.1-microsoft-standard-WSL2\n", + chassis_type: "3\n", + sys_vendor: "Microsoft Corporation\n", + product_name: "Virtual Machine\n", + }), + ), + ), + ); + + expect(result).toBe("linux"); + }), + ); + it.effect("returns null on Linux without DMI (containers, ARM boards)", () => Effect.gen(function* () { const result = yield* detectServerEnvironmentMachineKind().pipe( diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index e23342d12c09..9d11a1ef59fd 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -12,6 +12,7 @@ import * as ProcessRunner from "../processRunner.ts"; */ const DMI_ROOT = "/sys/class/dmi/id"; +const KERNEL_RELEASE_PATH = "/proc/sys/kernel/osrelease"; // SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape // rather than a machine (docking stations, blades enclosures, IoT gateways) @@ -144,11 +145,17 @@ const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* ( }); const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { - const [chassisType, sysVendor, productName] = yield* Effect.all([ + const [kernelRelease, chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(KERNEL_RELEASE_PATH), readOptionalFile(`${DMI_ROOT}/chassis_type`), readOptionalFile(`${DMI_ROOT}/sys_vendor`), readOptionalFile(`${DMI_ROOT}/product_name`), ]); + // WSL exposes Microsoft in its kernel release on both WSL 1 and WSL 2. + // Check it before DMI because WSL 2 presents as a Hyper-V VM. + if (kernelRelease?.toLowerCase().includes("microsoft")) { + return "linux"; + } return machineKindFromDmi({ chassisType, sysVendor, productName }); }); diff --git a/apps/server/src/fork/ForkFlags.test.ts b/apps/server/src/fork/ForkFlags.test.ts new file mode 100644 index 000000000000..5a6725ce47e8 --- /dev/null +++ b/apps/server/src/fork/ForkFlags.test.ts @@ -0,0 +1,229 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_FORK_FLAGS } from "@q1code/core/flags"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ForkFlags from "./ForkFlags.ts"; + +const makeLayers = (env: Readonly> = {}) => { + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "q1code-fork-flags-test-" }), + ); + const flagsLayer = ForkFlags.layer.pipe( + Layer.provide(Layer.succeed(ForkFlagsEnvironment, env)), + Layer.provideMerge(configLayer), + ); + return flagsLayer; +}; +const { ForkFlagsEnvironment } = ForkFlags; + +const writeForkConfig = (contents: string) => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(ForkFlags.forkConfigPath(stateDir, path), contents); + }); + +const readForkConfigText = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(ForkFlags.forkConfigPath(stateDir, path)); +}); + +const listStateDir = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(stateDir); +}); + +it.layer(NodeServices.layer)("ForkFlags", (it) => { + it.effect("resolves registry defaults when fork.json is missing", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + assert.deepEqual(yield* flags.current, DEFAULT_FORK_FLAGS); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("reads flag values from fork.json", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + yield* writeForkConfig('{"flags":{"update-check":true}}'); + const reloaded = yield* flags.reload; + assert.strictEqual(reloaded["update-check"], true); + assert.strictEqual(reloaded.prism, DEFAULT_FORK_FLAGS.prism); + assert.deepEqual(yield* flags.current, reloaded); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("environment overrides beat fork.json", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + yield* writeForkConfig('{"flags":{"update-check":true,"prism":true}}'); + const reloaded = yield* flags.reload; + assert.strictEqual(reloaded["update-check"], false); + assert.strictEqual(reloaded.prism, true); + }).pipe(Effect.provide(makeLayers({ T3FORK_UPDATE_CHECK: "0" }))), + ); + + it.effect("falls back to defaults and warns once on invalid JSON", () => { + let warnings = 0; + const countingLogger = Logger.make(({ logLevel }) => { + if (logLevel === "Warn") warnings += 1; + }); + return Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + yield* writeForkConfig("{not json"); + assert.deepEqual(yield* flags.reload, DEFAULT_FORK_FLAGS); + assert.deepEqual(yield* flags.reload, DEFAULT_FORK_FLAGS); + assert.strictEqual(warnings, 1); + }).pipe( + Effect.provide( + Layer.merge(makeLayers(), Logger.layer([countingLogger], { mergeWithExisting: false })), + ), + ); + }); + + it.effect( + "warns once at start when fork.json or the environment still use the cliproxy names", + () => { + const warnings: Array = []; + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") + warnings.push(String(Array.isArray(message) ? message[0] : message)); + }); + const configLayer = Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "q1code-fork-flags-test-" }), + ); + return Effect.gen(function* () { + yield* writeForkConfig('{"flags":{"prism":true},"cliproxy":{"port":9001}}'); + yield* Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + assert.deepEqual(yield* flags.config, { flags: { prism: true } }); + yield* flags.reload; + }).pipe( + Effect.provide( + ForkFlags.layer.pipe( + Layer.provide( + Layer.succeed(ForkFlagsEnvironment, { + T3FORK_CLIPROXY: "1", + Q1CODE_CLIPROXY_SYNC_KEY: "k", + }), + ), + ), + ), + ); + assert.deepEqual(warnings, [ + 'prism: fork.json key "cliproxy" was renamed to "prism" and is ignored', + "prism: environment variable T3FORK_CLIPROXY was renamed to T3FORK_PRISM and is ignored", + "prism: environment variable Q1CODE_CLIPROXY_SYNC_KEY was renamed to Q1CODE_PRISM_SYNC_KEY and is ignored", + ]); + }).pipe( + Effect.provide( + Layer.merge(configLayer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }, + ); + + it.effect("exposes the decoded file config next to the flags", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + assert.deepEqual(yield* flags.config, {}); + yield* writeForkConfig('{"flags":{"prism":true},"prism":{"port":9001}}'); + yield* flags.reload; + assert.deepEqual(yield* flags.config, { + flags: { prism: true }, + prism: { port: 9001 }, + }); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("update rewrites one key, keeps unknown keys and formatting, and re-reads", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + yield* writeForkConfig( + '{"flags":{"prism":true},"prism":{"port":9001},"private":{"host":"x"}}', + ); + yield* flags.reload; + const config = yield* flags.update((raw) => ({ + ...raw, + prism: { ...(raw.prism as object), routingStrategy: "fill-first" }, + })); + assert.deepEqual(config, { + flags: { prism: true }, + prism: { port: 9001, routingStrategy: "fill-first" }, + }); + assert.deepEqual(yield* flags.config, config); + assert.equal( + yield* readForkConfigText, + [ + "{", + ' "flags": {', + ' "prism": true', + " },", + ' "prism": {', + ' "port": 9001,', + ' "routingStrategy": "fill-first"', + " },", + ' "private": {', + ' "host": "x"', + " }", + "}", + "", + ].join("\n"), + ); + assert.deepEqual( + (yield* listStateDir).filter((name) => name.startsWith("fork.json")), + ["fork.json"], + ); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("update creates the file when it is missing and moves flags", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + const first = yield* flags.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild); + yield* flags.update((raw) => ({ ...raw, flags: { prism: true } })); + const [emitted] = yield* Fiber.join(first); + assert.strictEqual(emitted?.prism, true); + assert.strictEqual((yield* flags.current).prism, true); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("update refuses a malformed file and an invalid result, touching nothing", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + yield* writeForkConfig("{not json"); + const malformed = yield* flags.update((raw) => raw).pipe(Effect.flip); + assert.strictEqual(malformed.reason, "malformed"); + assert.equal(yield* readForkConfigText, "{not json"); + + yield* writeForkConfig('{"prism":{"port":1}}'); + const invalid = yield* flags + .update((raw) => ({ ...raw, prism: { port: 70000 } })) + .pipe(Effect.flip); + assert.strictEqual(invalid.reason, "invalid"); + assert.equal(yield* readForkConfigText, '{"prism":{"port":1}}'); + }).pipe(Effect.provide(makeLayers())), + ); + + it.effect("publishes a change when a reload moves a value", () => + Effect.gen(function* () { + const flags = yield* ForkFlags.ForkFlagsService; + const first = yield* flags.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkChild); + yield* writeForkConfig('{"flags":{"prism":true}}'); + yield* flags.reload; + const [emitted] = yield* Fiber.join(first); + assert.strictEqual(emitted?.prism, true); + }).pipe(Effect.provide(makeLayers())), + ); +}); diff --git a/apps/server/src/fork/ForkFlags.ts b/apps/server/src/fork/ForkFlags.ts new file mode 100644 index 000000000000..db8ae1c5749e --- /dev/null +++ b/apps/server/src/fork/ForkFlags.ts @@ -0,0 +1,298 @@ +/** + * ForkFlags - Server-side resolution of the fork feature-flag registry. + * + * Reads `/fork.json`, overlays `T3FORK_*` environment variables, and + * falls back to registry defaults when the file is missing or malformed (one + * warning per breakage, never a failure). The file is watched like + * `serverSettings.ts` watches `settings.json`, so edits land without a restart. + * Clients receive the resolved values through `ExecutionEnvironmentCapabilities.forkFlags`. + * + * `update` is the one writer: it edits the raw JSON so unknown keys survive, + * validates the result against the schema, writes temp + fsync + rename, and + * re-reads, so a feature that persists a setting (prism routing) never + * clobbers what another feature or the user put in the file. + */ +import { + EMPTY_FORK_CONFIG, + FORK_CONFIG_FILENAME, + type ForkConfig, + decodeForkConfig, + decodeForkConfigJson, +} from "@q1code/core/config"; +import { + DEFAULT_FORK_FLAGS, + FORK_FLAG_KEYS, + type ForkFlagValues, + resolveForkFlags, +} from "@q1code/core/flags"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import type { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import * as ServerConfig from "../config.ts"; + +/** The file as JSON, before schema decoding: what `update` mutates so unknown keys are kept. */ +export type RawForkConfig = Readonly>; + +export class ForkConfigWriteError extends Schema.TaggedErrorClass()( + "ForkConfigWriteError", + { + path: Schema.String, + /** `malformed`: the file on disk is not a JSON object. `invalid`: the mutation broke the schema. `io`: the write failed. */ + reason: Schema.Literals(["malformed", "invalid", "io"]), + detail: Schema.String, + }, +) { + override get message(): string { + return `Failed to update ${this.path} (${this.reason}): ${this.detail}`; + } +} + +export class ForkFlagsService extends Context.Service< + ForkFlagsService, + { + /** Resolved values for every registry flag. */ + readonly current: Effect.Effect; + /** Re-read the file now; publishes to `changes` when a value moved. */ + readonly reload: Effect.Effect; + /** Emits the full flag set each time a value changes. */ + readonly changes: Stream.Stream; + /** The whole decoded `fork.json` as of the last reload (feature sections live next to `flags`). */ + readonly config: Effect.Effect; + /** + * Rewrite `fork.json` through `mutate` (raw JSON in, raw JSON out), keeping + * keys the schema does not know, then reload. Atomic: temp file, fsync, + * rename. A missing file starts from `{}`. + */ + readonly update: ( + mutate: (raw: RawForkConfig) => RawForkConfig, + ) => Effect.Effect; + } +>()("t3/fork/ForkFlags/ForkFlagsService") {} + +/** Injectable process environment so tests can override `T3FORK_*` without touching `process.env`. */ +export const ForkFlagsEnvironment = Context.Reference>>( + "t3/fork/ForkFlags/ForkFlagsEnvironment", + { defaultValue: () => process.env }, +); + +export const forkConfigPath = (stateDir: string, path: Path.Path) => + path.join(stateDir, FORK_CONFIG_FILENAME); + +const sameFlags = (left: ForkFlagValues, right: ForkFlagValues) => + FORK_FLAG_KEYS.every((key) => left[key] === right[key]); + +/** The `prism` feature was called `cliproxy`; these spellings are ignored and warned about once at start. */ +const LEGACY_PRISM_ENV_VARS = [ + "T3FORK_CLIPROXY", + "Q1CODE_CLIPROXY_SYNC_TOKEN", + "Q1CODE_CLIPROXY_SYNC_KEY", +] as const; + +const RawJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); +const decodeRawJsonObject = Schema.decodeUnknownExit(RawJsonObject); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = yield* ForkFlagsEnvironment; + const configPath = forkConfigPath(stateDir, path); + const valuesRef = yield* Ref.make(DEFAULT_FORK_FLAGS); + const configRef = yield* Ref.make(EMPTY_FORK_CONFIG); + const warnedRef = yield* Ref.make(false); + const reloadSemaphore = yield* Semaphore.make(1); + const changesPubSub = yield* PubSub.unbounded(); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + // Missing file -> no file overrides. Malformed file -> warn once, no file + // overrides. The warning re-arms after the file parses again. + const readFileConfig = Effect.gen(function* () { + if (!(yield* fs.exists(configPath))) { + return undefined; + } + const decoded = decodeForkConfigJson(yield* fs.readFileString(configPath)); + if (Exit.isSuccess(decoded)) { + yield* Ref.set(warnedRef, false); + return decoded.value; + } + if (yield* Ref.getAndSet(warnedRef, true)) { + return undefined; + } + yield* Effect.logWarning("failed to parse fork.json, using defaults", { + path: configPath, + issues: Cause.pretty(decoded.cause), + }); + return undefined; + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to read fork.json, using defaults", { + path: configPath, + cause: error, + }).pipe(Effect.as(undefined)), + ), + ); + + const reloadUnlocked = Effect.gen(function* () { + const fileConfig = yield* readFileConfig; + yield* Ref.set(configRef, fileConfig ?? EMPTY_FORK_CONFIG); + const next = resolveForkFlags({ env, file: fileConfig?.flags }); + const previous = yield* Ref.getAndSet(valuesRef, next); + if (!sameFlags(previous, next)) { + yield* PubSub.publish(changesPubSub, next); + } + return next; + }); + + const reload = reloadSemaphore.withPermits(1)(reloadUnlocked); + + const writeError = (reason: ForkConfigWriteError["reason"]) => (cause: unknown) => + new ForkConfigWriteError({ + path: configPath, + reason, + detail: cause instanceof Error ? cause.message : String(cause), + }); + + const readRaw: Effect.Effect = Effect.gen(function* () { + const exists = yield* fs.exists(configPath).pipe(Effect.mapError(writeError("io"))); + if (!exists) return {}; + const text = yield* fs.readFileString(configPath).pipe(Effect.mapError(writeError("io"))); + const decoded = decodeRawJsonObject(text); + return Exit.isSuccess(decoded) + ? decoded.value + : yield* writeError("malformed")(Cause.squash(decoded.cause)); + }); + + // Temp file in the same directory, fsync, rename: a crash mid-write leaves + // the old file intact, and the watcher only ever sees a complete file. + const writeRaw = (raw: RawForkConfig) => + Effect.gen(function* () { + const tempPath = `${configPath}.${process.pid}.tmp`; + // Formatting is the point here: the user edits this file by hand. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const contents = `${JSON.stringify(raw, null, 2)}\n`; + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(tempPath, { flag: "w" }); + yield* file.writeAll(new TextEncoder().encode(contents)); + yield* file.sync; + }), + ).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.ignore))); + yield* fs.rename(tempPath, configPath); + }).pipe(Effect.mapError(writeError("io"))); + + const update = (mutate: (raw: RawForkConfig) => RawForkConfig) => + reloadSemaphore.withPermits(1)( + Effect.gen(function* () { + const next = mutate(yield* readRaw); + const validated = decodeForkConfig(next); + if (Exit.isFailure(validated)) { + return yield* writeError("invalid")(Cause.squash(validated.cause)); + } + yield* writeRaw(next); + yield* reloadUnlocked; + return yield* Ref.get(configRef); + }), + ); + + const startWatcher = Effect.gen(function* () { + yield* fs.makeDirectory(stateDir, { recursive: true }); + const configFile = path.basename(configPath); + const configPathResolved = path.resolve(configPath); + // Same debounce as serverSettings: editors emit several events per save. + const debouncedEvents = fs.watch(stateDir).pipe( + Stream.filter( + (event) => + event.path === configFile || + event.path === configPath || + path.resolve(stateDir, event.path) === configPathResolved, + ), + Stream.debounce(Duration.millis(100)), + ); + yield* Stream.runForEach(debouncedEvents, () => reload).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + ); + }).pipe(Effect.ignoreCause({ log: true })); + + // A renamed key or env var silently does nothing, so name it once at start. + const warnLegacyPrismNames = Effect.gen(function* () { + const raw = yield* readRaw.pipe(Effect.orElseSucceed((): RawForkConfig => ({}))); + if (Object.prototype.hasOwnProperty.call(raw, "cliproxy")) { + yield* Effect.logWarning( + 'prism: fork.json key "cliproxy" was renamed to "prism" and is ignored', + { path: configPath }, + ); + } + for (const name of LEGACY_PRISM_ENV_VARS) { + if (env[name] === undefined) continue; + yield* Effect.logWarning( + `prism: environment variable ${name} was renamed to ${name.replace("CLIPROXY", "PRISM")} and is ignored`, + ); + } + }); + + yield* reload; + yield* warnLegacyPrismNames; + yield* startWatcher; + + return ForkFlagsService.of({ + current: Ref.get(valuesRef), + reload, + changes: Stream.fromPubSub(changesPubSub), + config: Ref.get(configRef), + update, + }); +}); + +export const layer = Layer.effect(ForkFlagsService, make); + +/** Stamp the current flag values onto a descriptor's capabilities (the ServerEnvironment seam). */ +export const attachForkFlags = ( + descriptor: ExecutionEnvironmentDescriptor, + flags: ForkFlagsService["Service"], +): Effect.Effect => + Effect.map(flags.current, (forkFlags) => ({ + ...descriptor, + capabilities: { ...descriptor.capabilities, forkFlags }, + })); + +/** Fixed flags and config; `update` applies the mutation in memory and answers the decoded result. */ +export const layerTest = ( + overrides: Partial = {}, + config: ForkConfig = EMPTY_FORK_CONFIG, +) => + Layer.succeed( + ForkFlagsService, + ForkFlagsService.of({ + current: Effect.succeed({ ...DEFAULT_FORK_FLAGS, ...overrides }), + reload: Effect.succeed({ ...DEFAULT_FORK_FLAGS, ...overrides }), + changes: Stream.empty, + config: Effect.succeed(config), + update: (mutate) => { + const decoded = decodeForkConfig(mutate(config as RawForkConfig)); + return Exit.isSuccess(decoded) + ? Effect.succeed(decoded.value) + : Effect.fail( + new ForkConfigWriteError({ + path: FORK_CONFIG_FILENAME, + reason: "invalid", + detail: Cause.pretty(decoded.cause), + }), + ); + }, + }), + ); diff --git a/apps/server/src/fork/cli/forkCommand.ts b/apps/server/src/fork/cli/forkCommand.ts new file mode 100644 index 000000000000..9dbffc984001 --- /dev/null +++ b/apps/server/src/fork/cli/forkCommand.ts @@ -0,0 +1,13 @@ +/** + * `q1code fork ...` - the one CLI group the fork adds to upstream's root + * command (the single seam in `bin.ts`). Features add subcommands here, not + * new seams. + */ +import { Command } from "effect/unstable/cli"; + +import { secretCommand } from "./secret.ts"; + +export const forkCommand = Command.make("fork").pipe( + Command.withDescription("q1code fork commands."), + Command.withSubcommands([secretCommand]), +); diff --git a/apps/server/src/fork/cli/prism.test.ts b/apps/server/src/fork/cli/prism.test.ts new file mode 100644 index 000000000000..1affd9201eb2 --- /dev/null +++ b/apps/server/src/fork/cli/prism.test.ts @@ -0,0 +1,456 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"; + +import { cli } from "../../bin.ts"; +import { ForkFlagsEnvironment } from "../ForkFlags.ts"; +import { PRISM_OFF_ERROR, PrismAccountReport, PrismCliHttp, PrismStatusReport } from "./prism.ts"; +import { ForkCliStdin } from "./secret.ts"; + +const MANAGEMENT_SECRET = "mgmt-s3cret-value"; + +interface GatewayCall { + readonly url: string; + readonly authorization: string | undefined; +} + +type GatewayReply = { readonly status?: number; readonly body?: unknown } | "hang" | "refuse"; + +/** A scripted gateway: `respond` maps a management path (after `/v0/management`) to a reply; `hanging` completes when a request hangs. */ +const makeGateway = ( + respond: (path: string) => GatewayReply, + hanging?: Deferred.Deferred, +) => { + const calls: Array = []; + const client = HttpClient.make((request, url) => + Effect.suspend(() => { + calls.push({ url: url.toString(), authorization: request.headers.authorization }); + const reply = respond(url.pathname.replace(/^\/v0\/management/, "")); + if (reply === "hang") { + return hanging + ? Deferred.succeed(hanging, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.never; + } + if (reply === "refuse") { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: `connect ECONNREFUSED ${url.host} (secret ${MANAGEMENT_SECRET} echoed)`, + }), + }), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json(reply.body ?? {}, { status: reply.status ?? 200 }), + ), + ); + }), + ); + return { + calls, + layer: Layer.succeed(PrismCliHttp, Layer.succeed(HttpClient.HttpClient, client)), + }; +}; + +/** Local management calls work even when the remote release check fails. */ +const healthyGateway = () => + makeGateway((path) => { + switch (path) { + case "/latest-version": + return { status: 502, body: { error: "release service unavailable" } }; + case "/auth-files": + return { + body: { + files: [ + { name: "codex-1.json", provider: "codex", weight: 2 }, + { name: "claude-1.json", type: "claude", disabled: true }, + { name: "gemini-1.json", provider: "gemini", disabled: false }, + ], + }, + }; + case "/routing/strategy": + return { body: { strategy: "round-robin" } }; + default: + return { status: 404, body: { error: "unknown" } }; + } + }); + +const runCli = ( + args: ReadonlyArray, + options: { + readonly gateway?: ReturnType; + readonly env?: Readonly>; + readonly stdin?: string; + } = {}, +) => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + TestConsole.layer, + (options.gateway ?? makeGateway(() => "refuse")).layer, + Layer.succeed(ForkFlagsEnvironment, options.env ?? {}), + Layer.succeed(ForkCliStdin, { + isTTY: false, + read: Effect.succeed(options.stdin ?? ""), + }), + ), + ), + ); + +/** The run's exit together with the last line it printed. */ +const capture = (effect: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* Effect.exit(effect); + const output = + (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? + ""; + return { exit, output }; + }).pipe(Effect.provide(TestConsole.layer)); + +const decodeReport = Schema.decodeUnknownSync(Schema.fromJsonString(PrismStatusReport)); +const decodeAccounts = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(PrismAccountReport)), +); +const decodeKeys = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "q1code-prism-cli-")); + +const writeForkConfig = (baseDir: string, json: string) => { + const stateDir = NodePath.join(baseDir, "userdata"); + NodeFS.mkdirSync(stateDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(stateDir, "fork.json"), json); +}; + +const storeSecret = (baseDir: string, name: string, value = MANAGEMENT_SECRET) => + runCli(["fork", "secret", "set", name, "--base-dir", baseDir], { stdin: value }); + +const FLAG_ON = '{"flags":{"prism":true}}'; + +describe("q1code prism status", () => { + it.effect("reports a ready sidecar when the release service is unavailable", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const gateway = healthyGateway(); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.deepEqual(Object.keys(decodeKeys(output)), [ + "mode", + "baseUrl", + "reachable", + "accounts", + "disabled", + "strategy", + ]); + assert.deepEqual(decodeReport(output), { + mode: "sidecar", + baseUrl: "http://127.0.0.1:8317", + reachable: true, + accounts: 3, + disabled: 1, + strategy: "round-robin", + }); + assert.deepEqual( + gateway.calls.map((call) => call.url), + [ + "http://127.0.0.1:8317/v0/management/routing/strategy", + "http://127.0.0.1:8317/v0/management/auth-files", + ], + ); + assert.isTrue( + gateway.calls.every((call) => call.authorization === `Bearer ${MANAGEMENT_SECRET}`), + ); + assert.notInclude(output, MANAGEMENT_SECRET); + }), + ); + + it.effect("prints one line per field without --json", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--base-dir", baseDir], { gateway: healthyGateway() }), + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.equal( + output, + [ + "mode: sidecar", + "baseUrl: http://127.0.0.1:8317", + "reachable: yes", + "accounts: 3", + "disabled: 1", + "strategy: round-robin", + ].join("\n"), + ); + }), + ); + + it.effect("exits 1 with the off hint when the flag is off, without touching the gateway", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const gateway = healthyGateway(); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.deepEqual(decodeReport(output), { + mode: "sidecar", + baseUrl: "http://127.0.0.1:8317", + reachable: false, + accounts: 0, + disabled: 0, + strategy: null, + error: PRISM_OFF_ERROR, + }); + assert.include(output, "T3FORK_PRISM"); + assert.equal(gateway.calls.length, 0); + + // The human form ends with the error line. + const human = yield* capture(runCli(["prism", "status", "--base-dir", baseDir], { gateway })); + assert.isTrue(Exit.isFailure(human.exit)); + assert.isTrue(human.output.endsWith(`error: ${PRISM_OFF_ERROR}`)); + }), + ); + + it.effect("exits 1 naming the secret command when the management secret is missing", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const gateway = healthyGateway(); + // The env override turns the flag on like it does for the server. + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { + gateway, + env: { T3FORK_PRISM: "1" }, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + const report = decodeReport(output); + assert.isFalse(report.reachable); + assert.include(report.error ?? "", "q1code fork secret set prism-management-secret"); + assert.equal(gateway.calls.length, 0); + }), + ); + + it.effect("exits 1 with a redacted transport error when the gateway refuses", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const gateway = makeGateway(() => "refuse"); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(exit)); + const report = decodeReport(output); + assert.isFalse(report.reachable); + assert.include(report.error ?? "", "GET /routing/strategy failed"); + assert.include(report.error ?? "", "ECONNREFUSED"); + assert.notInclude(output, MANAGEMENT_SECRET); + assert.equal(gateway.calls.length, 1); + }), + ); + + it.effect("exits 1 and points at the secret on a 401", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const gateway = makeGateway(() => ({ status: 401, body: { error: "unauthorized" } })); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.deepEqual(decodeReport(output), { + mode: "sidecar", + baseUrl: "http://127.0.0.1:8317", + reachable: false, + accounts: 0, + disabled: 0, + strategy: null, + error: "HTTP 401 from GET /routing/strategy; check the management secret", + }); + assert.equal(gateway.calls.length, 1); + }), + ); + + it.effect("stays reachable but exits 1 when a later call fails", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const gateway = makeGateway((path) => + path === "/routing/strategy" + ? { body: { strategy: "round-robin" } } + : { status: 500, body: { error: "boom" } }, + ); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(exit)); + const report = decodeReport(output); + assert.isTrue(report.reachable); + assert.equal(report.accounts, 0); + assert.equal(report.error, "HTTP 500 from GET /auth-files"); + }), + ); + + it.effect("gives up on a hanging gateway after the timeout", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const hanging = yield* Deferred.make(); + const gateway = makeGateway(() => "hang", hanging); + const fiber = yield* Effect.forkChild( + capture(runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway })), + ); + // The request is in flight; one yield lets the timeout's sleeper register before the clock moves. + yield* Deferred.await(hanging); + yield* Effect.yieldNow; + yield* TestClock.adjust("5 seconds"); + const { exit, output } = yield* Fiber.join(fiber); + assert.isTrue(Exit.isFailure(exit)); + const report = decodeReport(output); + assert.isFalse(report.reachable); + assert.include(report.error ?? "", "GET /routing/strategy timed out"); + }), + ); + + it.effect( + "resolves the sidecar port and the external origin plus secret name from fork.json", + () => + Effect.gen(function* () { + const sidecarDir = makeBaseDir(); + writeForkConfig(sidecarDir, '{"flags":{"prism":true},"prism":{"port":9000}}'); + yield* storeSecret(sidecarDir, "prism-management-secret"); + const sidecar = healthyGateway(); + const sidecarRun = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", sidecarDir], { gateway: sidecar }), + ); + assert.isTrue(Exit.isSuccess(sidecarRun.exit)); + assert.equal(decodeReport(sidecarRun.output).baseUrl, "http://127.0.0.1:9000"); + assert.equal(sidecar.calls[0]?.url, "http://127.0.0.1:9000/v0/management/routing/strategy"); + + const externalDir = makeBaseDir(); + writeForkConfig( + externalDir, + '{"flags":{"prism":true},"prism":{"mode":"external","external":{"baseUrl":"https://proxy.example.test:9443/","managementSecretName":"proxy-mgmt"}}}', + ); + yield* storeSecret(externalDir, "proxy-mgmt", "external-secret"); + const external = healthyGateway(); + const externalRun = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", externalDir], { gateway: external }), + ); + assert.isTrue(Exit.isSuccess(externalRun.exit)); + const report = decodeReport(externalRun.output); + assert.equal(report.mode, "external"); + assert.equal(report.baseUrl, "https://proxy.example.test:9443"); + assert.equal( + external.calls[0]?.url, + "https://proxy.example.test:9443/v0/management/routing/strategy", + ); + assert.equal(external.calls[0]?.authorization, "Bearer external-secret"); + }), + ); + + it.effect("exits 1 with a null base URL when external mode is misconfigured", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, '{"flags":{"prism":true},"prism":{"mode":"external"}}'); + const gateway = healthyGateway(); + const { exit, output } = yield* capture( + runCli(["prism", "status", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(exit)); + const report = decodeReport(output); + assert.equal(report.mode, "external"); + assert.isNull(report.baseUrl); + assert.include(report.error ?? "", "prism.external"); + assert.equal(gateway.calls.length, 0); + }), + ); +}); + +describe("q1code prism accounts", () => { + it.effect("lists id, provider, disabled, and weight as JSON or a table", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const json = yield* capture( + runCli(["prism", "accounts", "--json", "--base-dir", baseDir], { + gateway: healthyGateway(), + }), + ); + assert.isTrue(Exit.isSuccess(json.exit)); + assert.deepEqual(decodeAccounts(json.output), [ + { id: "codex-1.json", provider: "codex", disabled: false, weight: 2 }, + { id: "claude-1.json", provider: "claude", disabled: true }, + { id: "gemini-1.json", provider: "gemini", disabled: false }, + ]); + + const table = yield* capture( + runCli(["prism", "accounts", "--base-dir", baseDir], { gateway: healthyGateway() }), + ); + assert.isTrue(Exit.isSuccess(table.exit)); + assert.equal( + table.output, + [ + "id provider disabled weight", + "codex-1.json codex no 2", + "claude-1.json claude yes", + "gemini-1.json gemini no", + ].join("\n"), + ); + }), + ); + + it.effect("fails without output when the flag is off or the gateway rejects", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const gateway = healthyGateway(); + const off = yield* capture( + runCli(["prism", "accounts", "--json", "--base-dir", baseDir], { gateway }), + ); + assert.isTrue(Exit.isFailure(off.exit)); + assert.equal(off.output, ""); + assert.equal(gateway.calls.length, 0); + + writeForkConfig(baseDir, FLAG_ON); + yield* storeSecret(baseDir, "prism-management-secret"); + const rejected = yield* capture( + runCli(["prism", "accounts", "--json", "--base-dir", baseDir], { + gateway: makeGateway(() => ({ status: 403, body: {} })), + }), + ); + assert.isTrue(Exit.isFailure(rejected.exit)); + }), + ); +}); diff --git a/apps/server/src/fork/cli/prism.ts b/apps/server/src/fork/cli/prism.ts new file mode 100644 index 000000000000..af618dd01408 --- /dev/null +++ b/apps/server/src/fork/cli/prism.ts @@ -0,0 +1,413 @@ +/** + * `q1code prism status|accounts` - the Prism gateway as a fleet monitor sees + * it, without the server running. Reads `fork.json` and the secret store from + * the state directory (`--base-dir`, `--dev-url`, like `auth`), resolves the + * gateway's base URL and management secret the way `PrismService` does, and + * asks the management API directly. + * + * `status --json` prints one object with a fixed key set and exits 0 only + * when the flag is on, the secret is stored, and the gateway answered every + * call; every other outcome still prints the object, with `error`, and exits + * 1. The secret is never printed. + */ +import { + decodeForkConfigJson, + type ForkConfig, + FORK_CONFIG_FILENAME, + PRISM_DEFAULT_MANAGEMENT_SECRET_NAME, + type PrismConfig, + PrismMode, +} from "@q1code/core/config"; +import { envVarForFlag, resolveForkFlags } from "@q1code/core/flags"; +import { PRISM_DEFAULT_PORT, PRISM_MANAGEMENT_PROBE_PATH } from "@q1code/core/prism"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; +import * as Runtime from "effect/Runtime"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { authLocationFlags, type CliAuthLocationFlags } from "../../cli/config.ts"; +import { ForkFlagsEnvironment, forkConfigPath } from "../ForkFlags.ts"; +import { parsePrismBaseUrl, redactSecrets } from "../prism/PrismService.ts"; +import { runWithSecretStore } from "./secret.ts"; + +/** The HTTP layer both subcommands talk to the gateway through; tests provide a scripted client. */ +export const PrismCliHttp = Context.Reference>( + "t3/fork/cli/PrismCliHttp", + { defaultValue: () => FetchHttpClient.layer }, +); + +/** One management call may take this long before it counts as unreachable. */ +export const PRISM_CLI_TIMEOUT = Duration.seconds(5); + +export const PRISM_OFF_ERROR = `prism is off (${envVarForFlag("prism")} / ${FORK_CONFIG_FILENAME} flags.prism)`; + +/** What `status --json` prints: exactly these keys, `error` only when something is wrong. */ +export const PrismStatusReport = Schema.Struct({ + mode: PrismMode, + /** `null` only when the external section is missing or its `baseUrl` is not a bare origin. */ + baseUrl: Schema.NullOr(Schema.String), + /** The gateway answered the management probe with 200. */ + reachable: Schema.Boolean, + accounts: Schema.Int, + disabled: Schema.Int, + strategy: Schema.NullOr(Schema.String), + error: Schema.optionalKey(Schema.String), +}); +export type PrismStatusReport = typeof PrismStatusReport.Type; + +/** One row of `accounts`: the auth file as the gateway lists it. */ +export const PrismAccountReport = Schema.Struct({ + id: Schema.String, + provider: Schema.String, + disabled: Schema.Boolean, + weight: Schema.optionalKey(Schema.Number), + requiresLogin: Schema.optionalKey(Schema.Boolean), + expiresAt: Schema.optionalKey(Schema.String), + lastRefreshedAt: Schema.optionalKey(Schema.String), +}); +export type PrismAccountReport = typeof PrismAccountReport.Type; + +export const encodePrismStatusReport = Schema.encodeSync(Schema.fromJsonString(PrismStatusReport)); +export const encodePrismAccountReports = Schema.encodeSync( + Schema.fromJsonString(Schema.Array(PrismAccountReport)), +); + +const AuthFilesResponse = Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + name: Schema.String, + type: Schema.optionalKey(Schema.String), + provider: Schema.optionalKey(Schema.String), + disabled: Schema.optionalKey(Schema.Boolean), + weight: Schema.optionalKey(Schema.Number), + requires_login: Schema.optionalKey(Schema.Boolean), + expires_at: Schema.optionalKey(Schema.String), + last_refresh: Schema.optionalKey(Schema.String), + }), + ), +}); +const RoutingResponse = Schema.Struct({ strategy: Schema.String }); + +/** `cause` carries the user-facing sentence. */ +class PrismCliError extends CliError.UserError { + override get message() { + return typeof this.cause === "string" ? this.cause : "The prism command failed."; + } +} + +/** Exit 1 after the report was printed; the report already carries the message, so nothing is logged twice. */ +class PrismStatusExit extends PrismCliError { + override readonly [Runtime.errorReported] = false; +} + +/** Where the gateway lives and which secret opens its management API, resolved like `PrismService` does. */ +export interface PrismTarget { + readonly mode: PrismMode; + readonly baseUrl: string | null; + readonly managementSecretName: string; + readonly error?: string; +} + +/** Pure: sidecar mode is loopback on the configured port; external mode is the configured origin. */ +export const resolvePrismTarget = (section: PrismConfig | undefined): PrismTarget => { + const mode = section?.mode ?? "sidecar"; + if (mode === "sidecar") { + return { + mode, + baseUrl: `http://127.0.0.1:${section?.port ?? PRISM_DEFAULT_PORT}`, + managementSecretName: PRISM_DEFAULT_MANAGEMENT_SECRET_NAME, + }; + } + const external = section?.external; + const managementSecretName = + external?.managementSecretName ?? PRISM_DEFAULT_MANAGEMENT_SECRET_NAME; + if (external === undefined) { + return { + mode, + baseUrl: null, + managementSecretName, + error: 'prism.mode is "external" but fork.json has no prism.external section', + }; + } + const parsed = parsePrismBaseUrl(external.baseUrl); + if (parsed === undefined) { + return { + mode, + baseUrl: null, + managementSecretName, + error: `prism.external.baseUrl must be an absolute http(s) origin such as http://127.0.0.1:8317, got "${external.baseUrl}"`, + }; + } + return { mode, baseUrl: parsed.baseUrl, managementSecretName }; +}; + +/** A missing file is an empty config; a malformed one is an error the monitor should see. */ +const readForkConfig = Effect.fn("prism.cli.readForkConfig")(function* ( + stateDir: string, +): Effect.fn.Return< + Result.Result, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = forkConfigPath(stateDir, path); + if (!(yield* fs.exists(configPath))) return Result.succeed({}); + const decoded = decodeForkConfigJson(yield* fs.readFileString(configPath)); + if (Exit.isSuccess(decoded)) return Result.succeed(decoded.value); + const detail = Cause.pretty(decoded.cause).split("\n")[0] ?? "invalid"; + return Result.fail(`${configPath} is malformed: ${detail}`); +}); + +type Gateway = + | { + readonly _tag: "unavailable"; + readonly mode: PrismMode; + readonly baseUrl: string | null; + readonly error: string; + } + | { + readonly _tag: "ready"; + readonly mode: PrismMode; + readonly baseUrl: string; + readonly secret: string; + }; + +/** Flag, config, and secret, in that order; the first thing missing names the fix. */ +const resolveGateway = (flags: CliAuthLocationFlags) => + runWithSecretStore(flags, ({ secrets, config }) => + Effect.gen(function* () { + const env = yield* ForkFlagsEnvironment; + const read = yield* readForkConfig(config.stateDir); + const forkConfig = Result.isSuccess(read) ? read.success : {}; + const target = resolvePrismTarget(forkConfig.prism); + const unavailable = (error: string): Gateway => ({ + _tag: "unavailable", + mode: target.mode, + baseUrl: target.baseUrl, + error, + }); + if (Result.isFailure(read)) return unavailable(read.failure); + if (!resolveForkFlags({ env, file: forkConfig.flags }).prism) { + return unavailable(PRISM_OFF_ERROR); + } + if (target.error !== undefined || target.baseUrl === null) { + return unavailable(target.error ?? "prism base URL could not be resolved"); + } + const stored = yield* secrets + .get(target.managementSecretName) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(stored)) { + return unavailable( + `management secret "${target.managementSecretName}" is not stored; run q1code fork secret set ${target.managementSecretName}`, + ); + } + return { + _tag: "ready", + mode: target.mode, + baseUrl: target.baseUrl, + secret: new TextDecoder().decode(stored.value), + } satisfies Gateway; + }), + ); + +/** The gateway answered, but not with 200. */ +class GatewayRejected extends Schema.TaggedErrorClass()("GatewayRejected", { + message: Schema.String, +}) {} + +/** `GET /v0/management` with the bearer secret, bounded by the timeout; any failure becomes one redacted sentence. */ +const managementGet = >( + gateway: Extract, + path: string, + schema: S, +): Effect.Effect, never, HttpClient.HttpClient> => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .get(`${gateway.baseUrl}/v0/management${path}`, { + headers: { Authorization: `Bearer ${gateway.secret}` }, + }) + .pipe(Effect.timeout(PRISM_CLI_TIMEOUT)); + if (response.status !== 200) { + const hint = + response.status === 401 || response.status === 403 ? "; check the management secret" : ""; + return yield* new GatewayRejected({ + message: `HTTP ${response.status} from GET ${path}${hint}`, + }); + } + return yield* HttpClientResponse.schemaBodyJson(schema)(response); + }).pipe( + Effect.result, + Effect.map( + Result.mapError((error) => + error._tag === "GatewayRejected" + ? error.message + : Cause.isTimeoutError(error) + ? `GET ${path} timed out after ${Duration.format(PRISM_CLI_TIMEOUT)}` + : `GET ${path} failed: ${redactSecrets(error.message, [gateway.secret])}`, + ), + ), + ); + +const toAccountReport = ( + entry: (typeof AuthFilesResponse.Type)["files"][number], +): PrismAccountReport => ({ + id: entry.name, + provider: entry.provider?.trim() || entry.type?.trim() || "unknown", + disabled: entry.disabled ?? false, + ...(entry.weight !== undefined ? { weight: entry.weight } : {}), + ...(entry.requires_login !== undefined ? { requiresLogin: entry.requires_login } : {}), + ...(entry.expires_at !== undefined ? { expiresAt: entry.expires_at } : {}), + ...(entry.last_refresh !== undefined ? { lastRefreshedAt: entry.last_refresh } : {}), +}); + +/** Pins the success type of `collectStatus` so `error` reads as optional everywhere. */ +const asReport = (report: PrismStatusReport): PrismStatusReport => report; + +/** Probes local routing state, then counts accounts; the first failure ends the walk. */ +const collectStatus = (flags: CliAuthLocationFlags) => + Effect.gen(function* () { + const gateway = yield* resolveGateway(flags); + const base = asReport({ + mode: gateway.mode, + baseUrl: gateway.baseUrl, + reachable: false, + accounts: 0, + disabled: 0, + strategy: null, + }); + if (gateway._tag === "unavailable") return asReport({ ...base, error: gateway.error }); + const httpLayer = yield* PrismCliHttp; + return yield* Effect.gen(function* () { + const probe = yield* managementGet(gateway, PRISM_MANAGEMENT_PROBE_PATH, RoutingResponse); + if (Result.isFailure(probe)) return asReport({ ...base, error: probe.failure }); + const reachable = asReport({ ...base, reachable: true }); + const files = yield* managementGet(gateway, "/auth-files", AuthFilesResponse); + if (Result.isFailure(files)) return asReport({ ...reachable, error: files.failure }); + const accounts = files.success.files.map(toAccountReport); + const counted = asReport({ + ...reachable, + accounts: accounts.length, + disabled: accounts.filter((account) => account.disabled).length, + }); + return asReport({ ...counted, strategy: probe.success.strategy }); + }).pipe(Effect.provide(httpLayer)); + }); + +/** One `key: value` line per report key, in the JSON order. */ +export const formatPrismStatusReport = (report: PrismStatusReport): string => + [ + `mode: ${report.mode}`, + `baseUrl: ${report.baseUrl ?? "(none)"}`, + `reachable: ${report.reachable ? "yes" : "no"}`, + `accounts: ${report.accounts}`, + `disabled: ${report.disabled}`, + `strategy: ${report.strategy ?? "(none)"}`, + ...(report.error !== undefined ? [`error: ${report.error}`] : []), + ].join("\n"); + +/** Fixed-width columns; `weight` is blank when the gateway did not report one. */ +export const formatPrismAccountsTable = (accounts: ReadonlyArray): string => { + if (accounts.length === 0) return "No accounts."; + const lifecycle = accounts.some( + (account) => account.requiresLogin !== undefined || account.expiresAt !== undefined, + ); + const rows = accounts.map((account) => [ + account.id, + account.provider, + account.disabled ? "yes" : "no", + account.weight === undefined ? "" : String(account.weight), + ...(lifecycle + ? [ + account.requiresLogin ? "sign-in required" : account.disabled ? "disabled" : "enabled", + account.expiresAt ?? "unknown", + ] + : []), + ]); + const header = [ + "id", + "provider", + "disabled", + "weight", + ...(lifecycle ? ["health", "token expiry"] : []), + ]; + const widths = header.map((title, column) => + Math.max(title.length, ...rows.map((row) => row[column]?.length ?? 0)), + ); + const line = (cells: ReadonlyArray) => + cells + .map((cell, column) => cell.padEnd(widths[column] ?? 0)) + .join(" ") + .trimEnd(); + return [line(header), ...rows.map(line)].join("\n"); +}; + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const prismStatusCommand = Command.make("status", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Probe the Prism gateway: mode, base URL, reachability, account counts, and routing strategy. Exit 1 when anything is wrong.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const report = yield* collectStatus(flags); + yield* Console.log( + flags.json ? encodePrismStatusReport(report) : formatPrismStatusReport(report), + ); + if (report.error !== undefined) { + return yield* new PrismStatusExit({ cause: report.error }); + } + }), + ), +); + +const prismAccountsCommand = Command.make("accounts", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List the gateway's pooled accounts: id, provider, disabled, weight."), + Command.withHandler((flags) => + Effect.gen(function* () { + const gateway = yield* resolveGateway(flags); + if (gateway._tag === "unavailable") { + return yield* new PrismCliError({ cause: gateway.error }); + } + const httpLayer = yield* PrismCliHttp; + const files = yield* managementGet(gateway, "/auth-files", AuthFilesResponse).pipe( + Effect.provide(httpLayer), + ); + if (Result.isFailure(files)) { + return yield* new PrismCliError({ cause: files.failure }); + } + const accounts = files.success.files.map(toAccountReport); + yield* Console.log( + flags.json ? encodePrismAccountReports(accounts) : formatPrismAccountsTable(accounts), + ); + }), + ), +); + +export const prismCommand = Command.make("prism").pipe( + Command.withDescription("Inspect the Prism account gateway without a running server."), + Command.withSubcommands([prismStatusCommand, prismAccountsCommand]), +); diff --git a/apps/server/src/fork/cli/secret.test.ts b/apps/server/src/fork/cli/secret.test.ts new file mode 100644 index 000000000000..81689f89a1f6 --- /dev/null +++ b/apps/server/src/fork/cli/secret.test.ts @@ -0,0 +1,140 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "../../bin.ts"; +import { ForkCliStdin } from "./secret.ts"; + +/** `stdin` is what a piped `set` reads; a TTY stdin carries no value. */ +const runCli = (args: ReadonlyArray, stdin: string | "tty" = "") => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + TestConsole.layer, + Layer.succeed(ForkCliStdin, { + isTTY: stdin === "tty", + read: Effect.succeed(stdin === "tty" ? "" : stdin), + }), + ), + ), + ); + +const lastLine = (effect: Effect.Effect) => + Effect.gen(function* () { + yield* effect; + return ( + (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? + "" + ); + }).pipe(Effect.provide(TestConsole.layer)); + +const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "q1code-secret-cli-")); + +const secretPath = (baseDir: string, name: string) => + NodePath.join(baseDir, "userdata", "secrets", `${name}.bin`); + +describe("q1code fork secret", () => { + it.effect("set stores the piped value without its trailing newline, owner-only", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const output = yield* lastLine( + runCli(["fork", "secret", "set", "prism-sync-key", "--base-dir", baseDir], "s3cret\n"), + ); + assert.equal(output, "Stored secret prism-sync-key.\n"); + const file = secretPath(baseDir, "prism-sync-key"); + assert.equal(NodeFS.readFileSync(file, "utf8"), "s3cret"); + assert.equal(NodeFS.statSync(file).mode & 0o777, 0o600); + + // A second set overwrites. + yield* runCli(["fork", "secret", "set", "prism-sync-key", "--base-dir", baseDir], "next"); + assert.equal(NodeFS.readFileSync(file, "utf8"), "next"); + }), + ); + + it.effect("set reads --value-file instead of stdin", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const valueFile = NodePath.join(baseDir, "token.txt"); + NodeFS.writeFileSync(valueFile, "tok-1\r\n"); + yield* runCli( + [ + "fork", + "secret", + "set", + "prism-sync-token", + "--value-file", + valueFile, + "--base-dir", + baseDir, + ], + "ignored", + ); + assert.equal(NodeFS.readFileSync(secretPath(baseDir, "prism-sync-token"), "utf8"), "tok-1"); + }), + ); + + it.effect( + "set refuses an empty value, a TTY stdin, and a name that is not one path segment", + () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const empty = yield* runCli( + ["fork", "secret", "set", "k", "--base-dir", baseDir], + "\n", + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(empty)); + const tty = yield* runCli( + ["fork", "secret", "set", "k", "--base-dir", baseDir], + "tty", + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(tty)); + const traversal = yield* runCli( + ["fork", "secret", "set", "../escape", "--base-dir", baseDir], + "v", + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(traversal)); + assert.isFalse(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "secrets", "k.bin"))); + }), + ); + + it.effect("list names stored secrets and delete removes one", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + assert.equal( + yield* lastLine(runCli(["fork", "secret", "list", "--base-dir", baseDir])), + "No secrets stored.\n", + ); + yield* runCli(["fork", "secret", "set", "b-name", "--base-dir", baseDir], "1"); + yield* runCli(["fork", "secret", "set", "a-name", "--base-dir", baseDir], "2"); + assert.equal( + yield* lastLine(runCli(["fork", "secret", "list", "--base-dir", baseDir])), + "a-name\nb-name\n", + ); + assert.equal( + yield* lastLine(runCli(["fork", "secret", "delete", "a-name", "--base-dir", baseDir])), + "Deleted secret a-name.\n", + ); + assert.isFalse(NodeFS.existsSync(secretPath(baseDir, "a-name"))); + assert.equal( + yield* lastLine(runCli(["fork", "secret", "delete", "a-name", "--base-dir", baseDir])), + "No secret named a-name.\n", + ); + assert.equal( + yield* lastLine(runCli(["fork", "secret", "list", "--base-dir", baseDir])), + "b-name\n", + ); + }), + ); +}); diff --git a/apps/server/src/fork/cli/secret.ts b/apps/server/src/fork/cli/secret.ts new file mode 100644 index 000000000000..c4159cb35618 --- /dev/null +++ b/apps/server/src/fork/cli/secret.ts @@ -0,0 +1,177 @@ +/** + * `q1code fork secret set|list|delete` - arbitrary named secrets in the server + * secret store, the same store the sidecar keys and the sync token/key are + * read from. `set` reads the value from stdin or `--value-file`, never from + * argv, so it stays out of shell history and process listings. + * + * Runs against the server's state directory (`--base-dir`, `--dev-url`), like + * the `auth` commands; the server does not need to be running. + */ +import * as Console from "effect/Console"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import { + authLocationFlags, + type CliAuthLocationFlags, + resolveCliAuthConfig, +} from "../../cli/config.ts"; + +/** One path segment, no leading dot: the store keeps `.bin` under the secrets directory. */ +export const SecretName = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/)); + +const SECRET_FILE_SUFFIX = ".bin"; + +export interface ForkCliStdinReader { + /** An interactive terminal: nothing is piped, so `set` refuses instead of blocking on Ctrl-D. */ + readonly isTTY: boolean; + /** Everything up to EOF. */ + readonly read: Effect.Effect; +} + +/** Where `set` reads the value when `--value-file` is absent; tests provide a fixed string. */ +export const ForkCliStdin = Context.Reference("t3/fork/cli/ForkCliStdin", { + defaultValue: () => ({ + isTTY: process.stdin.isTTY === true, + read: Effect.promise(async () => { + const chunks: Array = []; + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk); + } + return new TextDecoder().decode(Buffer.concat(chunks)); + }), + }), +}); + +/** `cause` carries the user-facing sentence. */ +class SecretValueError extends CliError.UserError { + override get message() { + return typeof this.cause === "string" ? this.cause : "Invalid secret value."; + } +} + +/** One trailing line break is the shell's, not the secret's. */ +const normalizeSecretValue = (raw: string) => raw.replace(/\r?\n$/, ""); + +/** Resolve the state directory from `--base-dir` / `--dev-url` and open the secret store there; shared with `q1code prism`. */ +export const runWithSecretStore = ( + flags: CliAuthLocationFlags, + run: (input: { + readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; + readonly config: ServerConfig.ServerConfig["Service"]; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + return yield* run({ secrets, config }); + }).pipe( + Effect.provide( + ServerSecretStore.layer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, config.logLevel)), + ), + ), + ); + }); + +const nameArgument = Argument.string("name").pipe( + Argument.withDescription("Secret name, for example `prism-sync-key`."), + Argument.withSchema(SecretName), +); + +const valueFileFlag = Flag.string("value-file").pipe( + Flag.withDescription("Read the value from this file instead of stdin."), + Flag.optional, +); + +const secretSetCommand = Command.make("set", { + ...authLocationFlags, + name: nameArgument, + valueFile: valueFileFlag, +}).pipe( + Command.withDescription( + "Store a secret. The value comes from stdin or --value-file, never from the command line.", + ), + Command.withHandler((flags) => + runWithSecretStore(flags, ({ secrets }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stdin = yield* ForkCliStdin; + const raw = Option.isSome(flags.valueFile) + ? yield* fs.readFileString(flags.valueFile.value) + : stdin.isTTY + ? yield* new SecretValueError({ + cause: + "Pipe the value on stdin or pass --value-file; it is never read from the command line.", + }) + : yield* stdin.read; + const value = normalizeSecretValue(raw); + if (value.length === 0) { + return yield* new SecretValueError({ cause: "The secret value is empty." }); + } + yield* secrets.set(flags.name, new TextEncoder().encode(value)); + yield* Console.log(`Stored secret ${flags.name}.\n`); + }), + ), + ), +); + +const secretListCommand = Command.make("list", { + ...authLocationFlags, +}).pipe( + Command.withDescription("List stored secret names without revealing their values."), + Command.withHandler((flags) => + runWithSecretStore(flags, ({ config }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + // The store has no listing of its own; its layout is one `.bin` per secret. + const entries = yield* fs + .readDirectory(config.secretsDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const names = entries + .filter((entry) => entry.endsWith(SECRET_FILE_SUFFIX)) + .map((entry) => entry.slice(0, -SECRET_FILE_SUFFIX.length)) + .sort(); + yield* Console.log(names.length === 0 ? "No secrets stored.\n" : `${names.join("\n")}\n`); + }), + ), + ), +); + +const secretDeleteCommand = Command.make("delete", { + ...authLocationFlags, + name: nameArgument, +}).pipe( + Command.withDescription("Delete a stored secret."), + Command.withHandler((flags) => + runWithSecretStore(flags, ({ secrets }) => + Effect.gen(function* () { + const existing = yield* secrets.get(flags.name); + if (Option.isNone(existing)) { + yield* Console.log(`No secret named ${flags.name}.\n`); + return; + } + yield* secrets.remove(flags.name); + yield* Console.log(`Deleted secret ${flags.name}.\n`); + }), + ), + ), +); + +export const secretCommand = Command.make("secret").pipe( + Command.withDescription("Manage named secrets in the server secret store."), + Command.withSubcommands([secretSetCommand, secretListCommand, secretDeleteCommand]), +); diff --git a/apps/server/src/fork/prism/CodexProxyHome.test.ts b/apps/server/src/fork/prism/CodexProxyHome.test.ts new file mode 100644 index 000000000000..d2df7b04576b --- /dev/null +++ b/apps/server/src/fork/prism/CodexProxyHome.test.ts @@ -0,0 +1,83 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { materializeCodexProxyHome, renderCodexProxyConfigToml } from "./CodexProxyHome.ts"; + +it("renders the q1code model provider block with the bearer header", () => { + assert.equal( + renderCodexProxyConfigToml({ baseUrl: "http://127.0.0.1:8317", apiKey: 'k"1' }), + [ + "# Generated by q1code. Overwritten on every sidecar start.", + 'model_provider = "q1code"', + "", + "[model_providers.q1code]", + 'name = "q1code CLIProxyAPI"', + 'base_url = "http://127.0.0.1:8317/v1"', + 'wire_api = "responses"', + 'http_headers = { Authorization = "Bearer k\\"1" }', + "", + ].join("\n"), + ); +}); + +it.layer(NodeServices.layer)("CodexProxyHome", (it) => { + it.effect("writes config.toml and shares non-auth state from the real Codex home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-codex-home-" }); + const shared = path.join(root, "dot-codex"); + const home = path.join(root, "managed"); + for (const entry of ["sessions", "skills", "log", "memories"]) { + yield* fs.makeDirectory(path.join(shared, entry), { recursive: true }); + } + yield* fs.writeFileString(path.join(shared, "auth.json"), "{}"); + yield* fs.writeFileString(path.join(shared, "config.toml"), 'model = "x"'); + yield* fs.writeFileString(path.join(shared, "history.jsonl"), ""); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.makeDirectory(path.join(home, "skills")); + + const endpoint = { baseUrl: "http://127.0.0.1:9000", apiKey: "k" }; + yield* materializeCodexProxyHome({ homeDir: home, endpoint, sharedHomeDir: shared }); + + const config = yield* fs.readFileString(path.join(home, "config.toml")); + assert.equal(config, renderCodexProxyConfigToml(endpoint)); + assert.equal((yield* fs.stat(path.join(home, "config.toml"))).mode & 0o777, 0o600); + assert.equal(yield* fs.readLink(path.join(home, "sessions")), path.join(shared, "sessions")); + assert.equal( + yield* fs.readLink(path.join(home, "history.jsonl")), + path.join(shared, "history.jsonl"), + ); + // Auth and runtime-local entries stay out; an existing real directory is left alone. + for (const entry of ["auth.json", "log", "memories"]) { + assert.isFalse(yield* fs.exists(path.join(home, entry))); + } + assert.isFalse((yield* fs.stat(path.join(home, "skills"))).type === "SymbolicLink"); + + // Idempotent, and a stale link is repointed. + yield* fs.remove(path.join(home, "sessions")); + yield* fs.symlink(path.join(root, "elsewhere"), path.join(home, "sessions")); + yield* materializeCodexProxyHome({ homeDir: home, endpoint, sharedHomeDir: shared }); + assert.equal(yield* fs.readLink(path.join(home, "sessions")), path.join(shared, "sessions")); + }).pipe(Effect.scoped), + ); + + it.effect("only writes config.toml when there is no real Codex home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-codex-home-" }); + const home = path.join(root, "managed"); + yield* materializeCodexProxyHome({ + homeDir: home, + endpoint: { baseUrl: "http://127.0.0.1:9000", apiKey: "k" }, + sharedHomeDir: path.join(root, "missing"), + }); + assert.deepEqual(yield* fs.readDirectory(home), ["config.toml"]); + assert.equal((yield* fs.stat(home)).mode & 0o777, 0o700); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/fork/prism/CodexProxyHome.ts b/apps/server/src/fork/prism/CodexProxyHome.ts new file mode 100644 index 000000000000..6891f939ea59 --- /dev/null +++ b/apps/server/src/fork/prism/CodexProxyHome.ts @@ -0,0 +1,128 @@ +/** + * A managed `CODEX_HOME` for the Prism provider instance. The driver decorator + * selects this home for pooled turns; the direct instance keeps its own auth. + * + * Non-auth state (sessions, skills, sqlite, ...) is symlinked from the user's + * real `~/.codex` the way upstream's shadow-home overlay does, so history and + * skills are shared. Limitation: the user's own `~/.codex/config.toml` is not + * merged; only the proxy block is written. + */ +import * as NodeOS from "node:os"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import type { PrismEndpoint } from "./PrismEnvironment.ts"; + +/** What the Codex home needs from the proxy handoff: the origin and the client key, never the management secret. */ +export type CodexProxyEndpoint = Pick; + +export const CODEX_PROXY_PROVIDER_ID = "q1code"; + +/** Entries of the real Codex home that stay private to it or are runtime-local. Mirrors upstream's `CodexHomeLayout`. */ +const UNSHARED_ENTRY_NAMES = new Set([ + "auth.json", + "models_cache.json", + "config.toml", + "log", + "memories", + "tmp", +]); + +/** TOML basic strings accept JSON escapes for the ASCII values written here. */ +const tomlString = (value: string) => JSON.stringify(value); + +/** Pure: the same endpoint renders the same file. */ +export const renderCodexProxyConfigToml = (endpoint: CodexProxyEndpoint): string => + [ + "# Generated by q1code. Overwritten on every sidecar start.", + `model_provider = ${tomlString(CODEX_PROXY_PROVIDER_ID)}`, + "", + `[model_providers.${CODEX_PROXY_PROVIDER_ID}]`, + `name = ${tomlString("q1code CLIProxyAPI")}`, + `base_url = ${tomlString(`${endpoint.baseUrl}/v1`)}`, + 'wire_api = "responses"', + `http_headers = { Authorization = ${tomlString(`Bearer ${endpoint.apiKey}`)} }`, + "", + ].join("\n"); + +export class CodexProxyHomeError extends Schema.TaggedErrorClass()( + "CodexProxyHomeError", + { + homeDir: Schema.String, + operation: Schema.Literals(["makeDirectory", "writeConfig", "link"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Codex proxy home '${this.homeDir}' failed during ${this.operation}.`; + } +} + +export interface MaterializeCodexProxyHomeInput { + readonly homeDir: string; + readonly endpoint: CodexProxyEndpoint; + /** The user's real Codex home to share state from. Defaults to `~/.codex`; skipped when absent. */ + readonly sharedHomeDir?: string | undefined; +} + +export const materializeCodexProxyHome = Effect.fn("prism.codexHome.materialize")(function* ( + input: MaterializeCodexProxyHomeInput, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fail = (operation: CodexProxyHomeError["operation"]) => (cause: unknown) => + new CodexProxyHomeError({ homeDir: input.homeDir, operation, cause }); + + yield* fs + .makeDirectory(input.homeDir, { recursive: true }) + .pipe(Effect.mapError(fail("makeDirectory"))); + yield* fs.chmod(input.homeDir, 0o700).pipe(Effect.mapError(fail("makeDirectory"))); + + const configPath = path.join(input.homeDir, "config.toml"); + yield* fs + .writeFileString(configPath, renderCodexProxyConfigToml(input.endpoint)) + .pipe(Effect.andThen(fs.chmod(configPath, 0o600)), Effect.mapError(fail("writeConfig"))); + + const sharedHomeDir = path.resolve(input.sharedHomeDir ?? path.join(NodeOS.homedir(), ".codex")); + if (sharedHomeDir === path.resolve(input.homeDir)) return; + const sharedExists = yield* fs.exists(sharedHomeDir).pipe(Effect.orElseSucceed(() => false)); + if (!sharedExists) return; + + const entries = yield* fs.readDirectory(sharedHomeDir).pipe(Effect.mapError(fail("link"))); + yield* Effect.forEach( + entries.filter((entry) => !UNSHARED_ENTRY_NAMES.has(entry)), + (entry) => + ensureLink(fs, path, path.join(sharedHomeDir, entry), path.join(input.homeDir, entry)), + { discard: true }, + ).pipe(Effect.mapError(fail("link"))); +}); + +type LinkState = + | { readonly kind: "missing" } + | { readonly kind: "other" } + | { readonly kind: "symlink"; readonly target: string }; + +/** Symlink `target` at `link`; replace a stale symlink, leave a real file or directory alone. */ +const ensureLink = Effect.fn("prism.codexHome.ensureLink")(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + target: string, + link: string, +): Effect.fn.Return { + const existing = yield* fs.readLink(link).pipe( + Effect.map((value): LinkState => ({ kind: "symlink", target: value })), + Effect.catch((error): Effect.Effect => + Effect.succeed(error.reason._tag === "NotFound" ? { kind: "missing" } : { kind: "other" }), + ), + ); + if (existing.kind === "other") return; + if (existing.kind === "symlink") { + if (path.resolve(path.dirname(link), existing.target) === target) return; + yield* fs.remove(link); + } + yield* fs.symlink(target, link); +}); diff --git a/apps/server/src/fork/prism/PrismBinary.test.ts b/apps/server/src/fork/prism/PrismBinary.test.ts new file mode 100644 index 000000000000..d91f4d5fc0f1 --- /dev/null +++ b/apps/server/src/fork/prism/PrismBinary.test.ts @@ -0,0 +1,269 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PRISM_PIN, prismAssetName, prismChecksumsUrl, prismReleaseUrl } from "@q1code/core/prism"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../../config.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import { ReleaseDownloader, ReleaseDownloadError, sha256Hex } from "../releaseTarball.ts"; +import { + PrismBinary, + PrismBinaryDownloadError, + PrismBinaryUnsupported, + PrismBundledRoots, + layer as binaryLayer, +} from "./PrismBinary.ts"; + +const isDownloadError = Schema.is(PrismBinaryDownloadError); +const isUnsupported = Schema.is(PrismBinaryUnsupported); + +const PLATFORM = "linux" as const; +const ARCHITECTURE = "arm64" as const; +const VERSION = PRISM_PIN.version; +const ASSET = prismAssetName(PLATFORM, ARCHITECTURE, VERSION)!; + +/** A real tar.gz holding a shell script named like the upstream binary. */ +const makeArchive = Effect.fn("test.makeArchive")(function* (directory: string, dotPrefix = false) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const staging = path.join(directory, "archive-src"); + yield* fs.makeDirectory(staging, { recursive: true }); + yield* fs.writeFileString(path.join(staging, "cli-proxy-api"), "#!/bin/sh\necho fake\n"); + const archivePath = path.join(directory, ASSET); + const result = yield* runner.run({ + command: "tar", + args: ["-czf", archivePath, "-C", staging, dotPrefix ? "./cli-proxy-api" : "cli-proxy-api"], + }); + assert.equal(result.code, 0, result.stderr); + return yield* fs.readFile(archivePath); +}); + +const makeDownloader = ( + assets: ReadonlyMap, +): { readonly layer: Layer.Layer; readonly urls: Array } => { + const urls: Array = []; + return { + urls, + layer: Layer.succeed(ReleaseDownloader, { + download: (url) => { + urls.push(url); + const bytes = assets.get(url); + return bytes === undefined + ? Effect.fail(new ReleaseDownloadError({ url })) + : Effect.succeed(bytes); + }, + }), + }; +}; + +const makeLayers = (input: { + readonly baseDir: string; + readonly bundledRoot: string; + readonly downloader: Layer.Layer; + readonly platform?: NodeJS.Platform; + readonly architecture?: NodeJS.Architecture; +}) => + binaryLayer.pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(input.downloader), + Layer.provide(Layer.succeed(PrismBundledRoots, [input.bundledRoot])), + Layer.provide(Layer.succeed(HostProcessPlatform, input.platform ?? PLATFORM)), + Layer.provide(Layer.succeed(HostProcessArchitecture, input.architecture ?? ARCHITECTURE)), + Layer.provide(ServerConfig.layerTest(process.cwd(), input.baseDir)), + Layer.provideMerge(NodeServices.layer), + ); + +it.layer(NodeServices.layer)("PrismBinary", (it) => { + it.effect("downloads, verifies, extracts, then serves the cache without downloading again", () => + Effect.gen(function* () { + for (const dotPrefix of [false, true]) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-binary-" }); + const archive = yield* makeArchive(root, dotPrefix).pipe( + Effect.provide(ProcessRunner.layer), + ); + const downloader = makeDownloader( + new Map([ + [ + prismChecksumsUrl(VERSION), + new TextEncoder().encode(`${sha256Hex(archive)} ${ASSET}\n`), + ], + [prismReleaseUrl(PLATFORM, ARCHITECTURE, VERSION)!, archive], + ]), + ); + const layers = makeLayers({ + baseDir: path.join(root, "base"), + bundledRoot: path.join(root, "bundled"), + downloader: downloader.layer, + }); + + const first = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + return yield* binary.resolve(); + }).pipe(Effect.provide(layers)); + assert.equal(first.source, "download"); + assert.equal(first.version, VERSION); + assert.equal(first.path, path.join(root, "base", "prism", "bin", VERSION, "cli-proxy-api")); + assert.equal((yield* fs.stat(first.path)).mode & 0o111, 0o111); + assert.equal(yield* fs.readFileString(first.path), "#!/bin/sh\necho fake\n"); + assert.deepEqual(yield* fs.readDirectory(path.join(root, "base", "prism", "bin")), [ + VERSION, + ]); + assert.equal(downloader.urls.length, 2); + + const second = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + return yield* binary.resolve(); + }).pipe(Effect.provide(layers)); + assert.equal(second.source, "cache"); + assert.equal(second.path, first.path); + assert.equal(downloader.urls.length, 2); + } + }).pipe(Effect.scoped), + ); + + it.effect("refuses a checksum mismatch and leaves no binary behind", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-binary-" }); + const archive = yield* makeArchive(root).pipe(Effect.provide(ProcessRunner.layer)); + const downloader = makeDownloader( + new Map([ + [prismChecksumsUrl(VERSION), new TextEncoder().encode(`${"0".repeat(64)} ${ASSET}\n`)], + [prismReleaseUrl(PLATFORM, ARCHITECTURE, VERSION)!, archive], + ]), + ); + const exit = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + return yield* binary.resolve(); + }).pipe( + Effect.provide( + makeLayers({ + baseDir: path.join(root, "base"), + bundledRoot: path.join(root, "bundled"), + downloader: downloader.layer, + }), + ), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + assert.isTrue(isDownloadError(error)); + assert.equal(isDownloadError(error) ? error.reason : undefined, "checksum-mismatch"); + } + assert.isFalse(yield* fs.exists(path.join(root, "base", "prism", "bin", VERSION))); + }).pipe(Effect.scoped), + ); + + it.effect( + "prefers an explicit binaryPath, then the bundled copy, and never downloads for them", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-binary-" }); + const bundledRoot = path.join(root, "bundled"); + const bundled = path.join(bundledRoot, `${PLATFORM}-${ARCHITECTURE}`, "cli-proxy-api"); + yield* fs.makeDirectory(path.dirname(bundled), { recursive: true }); + yield* fs.writeFileString(bundled, "#!/bin/sh\n"); + yield* fs.chmod(bundled, 0o755); + const override = path.join(root, "custom-proxy"); + yield* fs.writeFileString(override, "#!/bin/sh\n"); + yield* fs.chmod(override, 0o755); + const downloader = makeDownloader(new Map()); + const layers = makeLayers({ + baseDir: path.join(root, "base"), + bundledRoot, + downloader: downloader.layer, + }); + + const resolved = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + const fromOverride = yield* binary.resolve({ binaryPath: override }); + const fromBundle = yield* binary.resolve(); + const missing = yield* binary + .resolve({ binaryPath: path.join(root, "nope") }) + .pipe(Effect.exit); + return { fromOverride, fromBundle, missing }; + }).pipe(Effect.provide(layers)); + assert.deepEqual(resolved.fromOverride, { + path: override, + version: "custom", + source: "override", + }); + assert.deepEqual(resolved.fromBundle, { + path: bundled, + version: VERSION, + source: "bundled", + }); + assert.isTrue(Exit.isFailure(resolved.missing)); + assert.deepEqual(downloader.urls, []); + }).pipe(Effect.scoped), + ); + + it.effect("restores the executable bit on a bundled copy that lost it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-binary-" }); + const bundledRoot = path.join(root, "bundled"); + const bundled = path.join(bundledRoot, `${PLATFORM}-${ARCHITECTURE}`, "cli-proxy-api"); + yield* fs.makeDirectory(path.dirname(bundled), { recursive: true }); + yield* fs.writeFileString(bundled, "#!/bin/sh\n"); + yield* fs.chmod(bundled, 0o644); + const downloader = makeDownloader(new Map()); + const layers = makeLayers({ + baseDir: path.join(root, "base"), + bundledRoot, + downloader: downloader.layer, + }); + + const resolved = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + return yield* binary.resolve(); + }).pipe(Effect.provide(layers)); + assert.equal(resolved.path, bundled); + const mode = (yield* fs.stat(bundled)).mode; + assert.notEqual(mode & 0o111, 0); + assert.deepEqual(downloader.urls, []); + }).pipe(Effect.scoped), + ); + + it.effect("fails fast on a platform without a release", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-binary-" }); + const exit = yield* Effect.gen(function* () { + const binary = yield* PrismBinary; + return yield* binary.resolve(); + }).pipe( + Effect.provide( + makeLayers({ + baseDir: path.join(root, "base"), + bundledRoot: path.join(root, "bundled"), + downloader: makeDownloader(new Map()).layer, + platform: "freebsd", + architecture: "x64", + }), + ), + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.isTrue(isUnsupported(Cause.squash(exit.cause))); + } + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/fork/prism/PrismBinary.ts b/apps/server/src/fork/prism/PrismBinary.ts new file mode 100644 index 000000000000..c84c707aa644 --- /dev/null +++ b/apps/server/src/fork/prism/PrismBinary.ts @@ -0,0 +1,286 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Where the CLIProxyAPI executable comes from, in order: an explicit + * `fork.json.prism.binaryPath`, the copy `fork-release.yml` bundles next to + * the server entry (`dist/prism//cli-proxy-api`), a cached + * download under `/prism/bin//`, and finally a fresh + * download of the pinned GitHub release verified against its `checksums.txt`. + */ +import { + PRISM_PIN, + prismArchiveKind, + prismAssetName, + prismChecksumsUrl, + prismExecutableName, + prismPlatformKey, + prismReleaseUrl, +} from "@q1code/core/prism"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as NodePath from "node:path"; + +import * as ServerConfig from "../../config.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import { + ReleaseDownloader, + fetchReleaseDownloader, + parseChecksums, + sha256Hex, +} from "../releaseTarball.ts"; +import { prismDirectories } from "./PrismConfig.ts"; + +export class PrismBinaryUnsupported extends Schema.TaggedErrorClass()( + "PrismBinaryUnsupported", + { + platform: Schema.String, + architecture: Schema.String, + }, +) { + override get message(): string { + return `CLIProxyAPI has no release for ${this.platform}/${this.architecture}.`; + } +} + +export class PrismBinaryNotFound extends Schema.TaggedErrorClass()( + "PrismBinaryNotFound", + { + path: Schema.String, + }, +) { + override get message(): string { + return `CLIProxyAPI binary was not found at '${this.path}'.`; + } +} + +export class PrismBinaryNotExecutable extends Schema.TaggedErrorClass()( + "PrismBinaryNotExecutable", + { + path: Schema.String, + mode: Schema.Number, + }, +) { + override get message(): string { + return `CLIProxyAPI binary at '${this.path}' is not executable.`; + } +} + +export class PrismBinaryDownloadError extends Schema.TaggedErrorClass()( + "PrismBinaryDownloadError", + { + reason: Schema.Literals([ + "download-failed", + "checksum-missing", + "checksum-mismatch", + "extract-failed", + "write-failed", + "unsupported-archive", + ]), + version: Schema.String, + asset: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `CLIProxyAPI ${this.version} download failed (${this.reason}) for ${this.asset}.`; + } +} + +const isDownloadError = Schema.is(PrismBinaryDownloadError); + +export type PrismBinaryError = + | PrismBinaryUnsupported + | PrismBinaryNotFound + | PrismBinaryNotExecutable + | PrismBinaryDownloadError; + +export interface ResolvedPrismBinary { + readonly path: string; + /** Upstream release version, or `custom` for an explicit `binaryPath`. */ + readonly version: string; + readonly source: "override" | "bundled" | "cache" | "download"; +} + +export interface PrismBinaryOptions { + readonly binaryPath?: string | undefined; + readonly version?: string | undefined; +} + +/** Directories that may hold `/cli-proxy-api`; tests point this at a temp dir. */ +export const PrismBundledRoots = Context.Reference>( + "t3/fork/prism/PrismBinary/PrismBundledRoots", + { + // `import.meta.dirname` is `dist/` in the packed server and this source + // directory in dev; both resolve to `apps/server/dist/prism/`. + defaultValue: () => [ + NodePath.resolve(import.meta.dirname, "prism"), + NodePath.resolve(import.meta.dirname, "../../../dist/prism"), + ], + }, +); + +export class PrismBinary extends Context.Service< + PrismBinary, + { + readonly resolve: ( + options?: PrismBinaryOptions, + ) => Effect.Effect; + } +>()("t3/fork/prism/PrismBinary") {} + +export const make = Effect.fn("prism.binary.make")(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const downloader = Option.getOrElse( + yield* Effect.serviceOption(ReleaseDownloader), + () => fetchReleaseDownloader, + ); + const executableName = prismExecutableName(platform); + const platformKey = prismPlatformKey(platform, architecture); + const directories = prismDirectories(config.baseDir, path); + const bundledRoots = yield* PrismBundledRoots; + const bundledCandidates = + platformKey === undefined + ? [] + : bundledRoots.map((root) => path.join(root, platformKey, executableName)); + + const executableAt = Effect.fn("prism.binary.executableAt")(function* (candidate: string) { + const exists = yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return Option.none(); + if (platform !== "win32") { + const stat = yield* fs.stat(candidate).pipe(Effect.option); + if (Option.isSome(stat) && (stat.value.mode & 0o111) === 0) { + // npm installs can drop the executable bit from bundled binaries; restore it once. + const restored = yield* fs + .chmod(candidate, 0o755) + .pipe(Effect.andThen(fs.stat(candidate)), Effect.option); + if (Option.isNone(restored) || (restored.value.mode & 0o111) === 0) { + return yield* new PrismBinaryNotExecutable({ path: candidate, mode: stat.value.mode }); + } + } + } + return Option.some(candidate); + }); + + const download = Effect.fn("prism.binary.download")(function* ( + version: string, + target: string, + ): Effect.fn.Return { + const asset = prismAssetName(platform, architecture, version); + if (asset === undefined) { + return yield* new PrismBinaryUnsupported({ platform, architecture }); + } + const fail = (reason: PrismBinaryDownloadError["reason"], cause?: unknown) => + new PrismBinaryDownloadError({ reason, version, asset, cause }); + if (prismArchiveKind(platform) !== "tar.gz") { + return yield* fail("unsupported-archive"); + } + const checksums = yield* downloader.download(prismChecksumsUrl(version)).pipe( + Effect.map((bytes) => parseChecksums(new TextDecoder().decode(bytes))), + Effect.mapError((cause) => fail("download-failed", cause)), + ); + const expected = checksums.get(asset); + if (expected === undefined) { + return yield* fail("checksum-missing"); + } + const url = prismReleaseUrl(platform, architecture, version); + if (url === undefined) { + return yield* new PrismBinaryUnsupported({ platform, architecture }); + } + const archive = yield* downloader + .download(url) + .pipe(Effect.mapError((cause) => fail("download-failed", cause))); + if (sha256Hex(archive) !== expected) { + return yield* fail("checksum-mismatch"); + } + + return yield* Effect.scoped( + Effect.gen(function* () { + yield* fs.makeDirectory(directories.binDir, { recursive: true }); + const staging = yield* fs.makeTempDirectoryScoped({ + directory: directories.binDir, + prefix: `${version}.download-`, + }); + const archivePath = path.join(staging, asset); + yield* fs.writeFile(archivePath, archive); + const listing = yield* runner.run({ + command: "tar", + args: ["-tzf", archivePath], + cwd: staging, + timeout: "2 minutes", + }); + const member = listing.stdout + .split("\n") + .find((entry) => entry === executableName || entry === `./${executableName}`); + if (listing.code !== 0 || member === undefined) + return yield* fail( + "extract-failed", + new Error("Release archive has no root executable."), + ); + const extracted = yield* runner.run({ + command: "tar", + args: ["-xzf", archivePath, "-C", staging, member], + cwd: staging, + timeout: "2 minutes", + }); + if (extracted.code !== 0) { + return yield* fail("extract-failed", new Error(extracted.stderr.trim())); + } + const extractedPath = path.join(staging, executableName); + yield* fs.chmod(extractedPath, 0o755); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.rename(extractedPath, target); + return target; + }), + ).pipe( + Effect.catchIf( + (error): error is Exclude => + !isDownloadError(error), + (cause) => fail("write-failed", cause), + ), + ); + }); + + const resolve: PrismBinary["Service"]["resolve"] = Effect.fn("prism.binary.resolve")(function* ( + options = {}, + ) { + const override = options.binaryPath?.trim(); + if (override !== undefined && override.length > 0) { + const found = yield* executableAt(override); + if (Option.isNone(found)) { + return yield* new PrismBinaryNotFound({ path: override }); + } + return { path: found.value, version: "custom", source: "override" } as const; + } + if (platformKey === undefined) { + return yield* new PrismBinaryUnsupported({ platform, architecture }); + } + for (const candidate of bundledCandidates) { + const found = yield* executableAt(candidate); + if (Option.isSome(found)) { + return { path: found.value, version: PRISM_PIN.version, source: "bundled" } as const; + } + } + const version = options.version?.trim() || PRISM_PIN.version; + const cached = path.join(directories.binDir, version, executableName); + const found = yield* executableAt(cached); + if (Option.isSome(found)) { + return { path: found.value, version, source: "cache" } as const; + } + const downloaded = yield* download(version, cached); + return { path: downloaded, version, source: "download" } as const; + }); + + return PrismBinary.of({ resolve }); +}); + +export const layer = Layer.effect(PrismBinary, make()); diff --git a/apps/server/src/fork/prism/PrismConfig.test.ts b/apps/server/src/fork/prism/PrismConfig.test.ts new file mode 100644 index 000000000000..79e2432d1434 --- /dev/null +++ b/apps/server/src/fork/prism/PrismConfig.test.ts @@ -0,0 +1,89 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { prismDirectories, renderPrismConfig, writePrismConfig } from "./PrismConfig.ts"; + +it("renders a deterministic loopback-only config with everything q1code fixes", () => { + const text = renderPrismConfig({ + port: 8317, + authDir: "/home/mic/.q1code/prism/auths", + apiKey: "key-1", + managementSecret: 'sec"ret', + routingStrategy: "fill-first", + }); + assert.equal( + text, + [ + "# Generated by q1code. Overwritten on every sidecar start; edit fork.json instead.", + 'host: "127.0.0.1"', + "port: 8317", + 'auth-dir: "/home/mic/.q1code/prism/auths"', + "api-keys:", + ' - "key-1"', + "remote-management:", + " allow-remote: false", + ' secret-key: "sec\\"ret"', + " disable-control-panel: true", + " disable-auto-update-panel: true", + "routing:", + ' strategy: "fill-first"', + " session-affinity: false", + "quota-exceeded:", + " switch-project: true", + " switch-preview-model: true", + "request-retry: 3", + "debug: false", + "usage-statistics-enabled: false", + "logging-to-file: false", + "request-log: false", + "ws-auth: true", + "plugins:", + " enabled: false", + "", + ].join("\n"), + ); + assert.equal( + renderPrismConfig({ authDir: "/a", apiKey: "k", managementSecret: "s" }), + renderPrismConfig({ authDir: "/a", apiKey: "k", managementSecret: "s" }), + ); +}); + +it("defaults the port and routing strategy", () => { + const text = renderPrismConfig({ authDir: "/a", apiKey: "k", managementSecret: "s" }); + assert.include(text, "\nport: 8317\n"); + assert.include(text, '\n strategy: "round-robin"\n'); +}); + +it.layer(NodeServices.layer)("prism config files", (it) => { + it.effect("lays the sidecar out under /prism", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const directories = prismDirectories("/base", path); + assert.equal(directories.rootDir, path.join("/base", "prism")); + assert.equal(directories.authsDir, path.join("/base", "prism", "auths")); + assert.equal(directories.binDir, path.join("/base", "prism", "bin")); + assert.equal(directories.codexHomeDir, path.join("/base", "prism", "codex-home")); + assert.equal(directories.configPath, path.join("/base", "prism", "config.yaml")); + assert.equal(directories.tombstonesPath, path.join("/base", "prism", "tombstones.json")); + }), + ); + + it.effect("writes the config atomically with owner-only permissions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-prism-config-" }); + const configPath = path.join(directory, "nested", "config.yaml"); + yield* writePrismConfig(configPath, "port: 1\n"); + yield* writePrismConfig(configPath, "port: 2\n"); + assert.equal(yield* fs.readFileString(configPath), "port: 2\n"); + const stat = yield* fs.stat(configPath); + assert.equal(stat.mode & 0o777, 0o600); + const leftovers = yield* fs.readDirectory(path.dirname(configPath)); + assert.deepEqual(leftovers, ["config.yaml"]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/fork/prism/PrismConfig.ts b/apps/server/src/fork/prism/PrismConfig.ts new file mode 100644 index 000000000000..7f442b3ba394 --- /dev/null +++ b/apps/server/src/fork/prism/PrismConfig.ts @@ -0,0 +1,143 @@ +/** + * The sidecar's on-disk layout under `/prism/` and the generated + * `config.yaml`. q1code owns the file: it is rendered from `fork.json` plus the + * two secrets on every sidecar start, so nothing in it is hand-edited. The + * proxy bcrypt-hashes `remote-management.secret-key` back into the file on + * load; the next start simply writes the plaintext again. + */ +import { PRISM_DEFAULT_PORT } from "@q1code/core/prism"; +import type { PrismConfig, PrismRoutingStrategy } from "@q1code/core/config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +export interface PrismDirectories { + /** `/prism`, mode 0700. */ + readonly rootDir: string; + /** Account files, one JSON per credential. */ + readonly authsDir: string; + /** Downloaded binaries, `//cli-proxy-api`. */ + readonly binDir: string; + /** Managed `CODEX_HOME` that points Codex at the proxy. */ + readonly codexHomeDir: string; + readonly configPath: string; + /** Sync deletions still to propagate, `{ "": "" }`. */ + readonly tombstonesPath: string; +} + +export const prismDirectories = (baseDir: string, path: Path.Path): PrismDirectories => { + const rootDir = path.join(baseDir, "prism"); + return { + rootDir, + authsDir: path.join(rootDir, "auths"), + binDir: path.join(rootDir, "bin"), + codexHomeDir: path.join(rootDir, "codex-home"), + configPath: path.join(rootDir, "config.yaml"), + tombstonesPath: path.join(rootDir, "tombstones.json"), + }; +}; + +/** + * Where the proxy's auth files live for sync and mtime lookups: the external + * proxy's `authDir` when configured, otherwise the managed `auths/`. + */ +export const prismAuthsDir = ( + section: PrismConfig | undefined, + directories: PrismDirectories, + path: Path.Path, +): string => + section?.mode === "external" && section.external?.authDir !== undefined + ? path.resolve(section.external.authDir) + : directories.authsDir; + +export const DEFAULT_PRISM_ROUTING_STRATEGY: PrismRoutingStrategy = "round-robin"; + +export interface PrismConfigInput { + readonly port?: number | undefined; + readonly authDir: string; + readonly apiKey: string; + readonly managementSecret: string; + readonly routingStrategy?: PrismRoutingStrategy | undefined; +} + +/** YAML double-quoted scalars accept JSON string syntax, so this is a safe quoter for paths and secrets. */ +const yamlString = (value: string) => JSON.stringify(value); + +/** Pure: the same input renders the same text. Everything not exposed in `fork.json` is fixed here. */ +export const renderPrismConfig = (input: PrismConfigInput): string => + [ + "# Generated by q1code. Overwritten on every sidecar start; edit fork.json instead.", + 'host: "127.0.0.1"', + `port: ${input.port ?? PRISM_DEFAULT_PORT}`, + `auth-dir: ${yamlString(input.authDir)}`, + "api-keys:", + ` - ${yamlString(input.apiKey)}`, + "remote-management:", + " allow-remote: false", + ` secret-key: ${yamlString(input.managementSecret)}`, + " disable-control-panel: true", + " disable-auto-update-panel: true", + "routing:", + ` strategy: ${yamlString(input.routingStrategy ?? DEFAULT_PRISM_ROUTING_STRATEGY)}`, + " session-affinity: false", + "quota-exceeded:", + " switch-project: true", + " switch-preview-model: true", + "request-retry: 3", + "debug: false", + "usage-statistics-enabled: false", + "logging-to-file: false", + "request-log: false", + "ws-auth: true", + "plugins:", + " enabled: false", + "", + ].join("\n"); + +export class PrismConfigWriteError extends Schema.TaggedErrorClass()( + "PrismConfigWriteError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to write the Prism config at '${this.path}'.`; + } +} + +/** + * Write `contents` to `filePath` atomically: a 0600 temp file in the same + * directory, fsync, rename. The file holds the API key and management secret. + */ +export const writePrismConfig = Effect.fn("prism.writeConfig")(function* ( + filePath: string, + contents: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.dirname(filePath); + const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.tmp`); + yield* Effect.gen(function* () { + yield* fs.makeDirectory(directory, { recursive: true }); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(tempPath, { flag: "w", mode: 0o600 }); + yield* file.writeAll(new TextEncoder().encode(contents)); + yield* file.sync; + }), + ); + yield* fs.chmod(tempPath, 0o600); + yield* fs.rename(tempPath, filePath); + }).pipe( + Effect.catch((cause) => + fs + .remove(tempPath) + .pipe( + Effect.ignore, + Effect.andThen(Effect.fail(new PrismConfigWriteError({ path: filePath, cause }))), + ), + ), + ); +}); diff --git a/apps/server/src/fork/prism/PrismEnvironment.test.ts b/apps/server/src/fork/prism/PrismEnvironment.test.ts new file mode 100644 index 000000000000..e04df1bec0d0 --- /dev/null +++ b/apps/server/src/fork/prism/PrismEnvironment.test.ts @@ -0,0 +1,134 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { type UsageLimitSourceConfig, UsageLimitSourceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Stream from "effect/Stream"; + +import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; +import { + type PrismEndpoint, + prismUsageLimitSource, + prismUsageSourceChanges, + publishPrismEndpoint, + withPrismUsageLimitSource, +} from "./PrismEnvironment.ts"; + +const endpoint: PrismEndpoint = { + baseUrl: "http://127.0.0.1:8317", + apiKey: "k", + managementSecret: "s", + usageSource: true, +}; + +const hub = (url: string): readonly [string, UsageLimitSourceConfig] => [ + `hub-${url}`, + { kind: "cliproxy", url, managementKey: "h", enabled: true }, +]; + +const prismEntry = { + kind: "cliproxy", + label: "Prism", + url: "http://127.0.0.1:8317", + managementKey: "s", + enabled: true, +} as const; + +it("publishes no usage-limit source while off or while the toggle is off", () => { + publishPrismEndpoint(undefined); + const entries = [hub("https://hub.example")]; + assert.isUndefined(prismUsageLimitSource()); + assert.strictEqual(withPrismUsageLimitSource(entries), entries); + publishPrismEndpoint({ ...endpoint, usageSource: false }); + try { + assert.isUndefined(prismUsageLimitSource()); + assert.strictEqual(withPrismUsageLimitSource(entries), entries); + assert.strictEqual(withPrismUsageLimitSource([]).length, 0); + } finally { + publishPrismEndpoint(undefined); + } +}); + +it("appends the Prism source after the user's hubs while the toggle is on", () => { + publishPrismEndpoint(endpoint); + try { + const prismId = UsageLimitSourceId.make("prism"); + assert.deepEqual(prismUsageLimitSource(), [prismId, prismEntry]); + const entries = [hub("https://hub.example")]; + const result = withPrismUsageLimitSource(entries); + assert.notStrictEqual(result, entries); + assert.deepEqual(result, [entries[0]!, [prismId, prismEntry]]); + assert.deepEqual(withPrismUsageLimitSource([]), [[prismId, prismEntry]]); + // The secret never leaks into the entry beyond the management key the reader needs. + assert.notInclude(JSON.stringify(result), '"apiKey"'); + } finally { + publishPrismEndpoint(undefined); + } +}); + +it("leaves the list alone when a hub already targets the proxy's origin or reuses its id", () => { + publishPrismEndpoint(endpoint); + try { + const sameOrigin = [hub("http://127.0.0.1:8317/")]; + assert.strictEqual(withPrismUsageLimitSource(sameOrigin), sameOrigin); + const sameId: ReadonlyArray = [ + ["prism", hub("https://elsewhere.example")[1]], + ]; + assert.strictEqual(withPrismUsageLimitSource(sameId), sameId); + const otherPort = [hub("http://127.0.0.1:9000")]; + assert.equal(withPrismUsageLimitSource(otherPort).length, 2); + const unparsable = [hub("not a url")]; + assert.equal(withPrismUsageLimitSource(unparsable).length, 2); + } finally { + publishPrismEndpoint(undefined); + } +}); + +it.effect("emits when the usage source appears, moves, and disappears, never on a repeat", () => + Effect.gen(function* () { + publishPrismEndpoint(undefined); + const collected = yield* prismUsageSourceChanges.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + try { + publishPrismEndpoint(endpoint); + // Same origin and secret: a republish with only the client key changed is not a change. + publishPrismEndpoint({ ...endpoint, apiKey: "k2" }); + publishPrismEndpoint({ ...endpoint, baseUrl: "http://127.0.0.1:9000" }); + publishPrismEndpoint({ ...endpoint, baseUrl: "http://127.0.0.1:9000", usageSource: false }); + } finally { + publishPrismEndpoint(undefined); + } + const emitted = yield* Fiber.join(collected); + assert.deepEqual( + emitted.map((entry) => entry?.[1].url), + ["http://127.0.0.1:8317", "http://127.0.0.1:9000", undefined], + ); + }), +); + +it.layer(NodeServices.layer)("direct Claude environment", (it) => { + it.effect("keeps local authentication when Prism is published", () => + Effect.gen(function* () { + publishPrismEndpoint(endpoint); + try { + const plain = yield* makeClaudeEnvironment({ homePath: "" }, { PATH: "/bin" }); + assert.isUndefined(plain.ANTHROPIC_BASE_URL); + assert.isUndefined(plain.ANTHROPIC_AUTH_TOKEN); + const isolated = yield* makeClaudeEnvironment( + { homePath: "~/.claude-x" }, + { PATH: "/bin" }, + ); + assert.isUndefined(isolated.ANTHROPIC_AUTH_TOKEN); + assert.isString(isolated.CLAUDE_CONFIG_DIR); + } finally { + publishPrismEndpoint(undefined); + } + const base = { PATH: "/bin" }; + assert.strictEqual(yield* makeClaudeEnvironment({ homePath: "" }, base), base); + }), + ); +}); diff --git a/apps/server/src/fork/prism/PrismEnvironment.ts b/apps/server/src/fork/prism/PrismEnvironment.ts new file mode 100644 index 000000000000..a1a546f8d0ff --- /dev/null +++ b/apps/server/src/fork/prism/PrismEnvironment.ts @@ -0,0 +1,124 @@ +/** + * Process-wide handoff between the proxy service and the provider seams. There + * is exactly one proxy per server process (the sidecar or the configured + * external one), and the seams run in fibers whose context predates it + * (provider adapters are built before it is ready), so the endpoint is + * published here instead of through the Effect context. Set only while the + * proxy is ready; cleared on every other state. + * + * The same handoff feeds upstream's usage-limit sources: while the proxy is + * ready and `prism.usageSource` is on, `withPrismUsageLimitSource` adds one + * `cliproxy` entry pointing at the proxy with its management secret, so the + * pooled accounts show on the Limits view like a hub the user added by hand. + */ +import { PRISM_USAGE_SOURCE_ID, PRISM_USAGE_SOURCE_LABEL } from "@q1code/core/prismApi"; +import { type UsageLimitSourceConfig, UsageLimitSourceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +export interface PrismEndpoint { + /** The proxy origin: `http://127.0.0.1:` for the sidecar, `prism.external.baseUrl` otherwise. No trailing slash. */ + readonly baseUrl: string; + readonly apiKey: string; + /** Server-only: the usage-limit source reads quota with it. Never crosses the wire. */ + readonly managementSecret: string; + /** `prism.usageSource`: whether the pooled accounts are published to the Limits view. */ + readonly usageSource: boolean; +} + +export type PrismUsageLimitSourceEntry = readonly [UsageLimitSourceId, UsageLimitSourceConfig]; + +let published: PrismEndpoint | undefined; +let enabled = false; +const endpointEvents = Effect.runSync(PubSub.unbounded()); +export const prismEndpointChanges = Stream.fromPubSub(endpointEvents); +export const isPrismEnabled = () => enabled; +export const publishPrismEnabled = (value: boolean) => { + if (enabled === value) return; + enabled = value; + PubSub.publishUnsafe(endpointEvents, undefined); +}; + +/** Emits the usage-limit source entry (or its absence) each time it changes with the published endpoint. */ +const usageSourcePubSub = Effect.runSync( + PubSub.unbounded(), +); + +const sameEntry = ( + left: PrismUsageLimitSourceEntry | undefined, + right: PrismUsageLimitSourceEntry | undefined, +): boolean => + left === right || + (left !== undefined && + right !== undefined && + left[1].url === right[1].url && + left[1].managementKey === right[1].managementKey); + +export const publishPrismEndpoint = (endpoint: PrismEndpoint | undefined): void => { + const before = prismUsageLimitSource(); + published = endpoint; + PubSub.publishUnsafe(endpointEvents, undefined); + const after = prismUsageLimitSource(); + if (!sameEntry(before, after)) PubSub.publishUnsafe(usageSourcePubSub, after); +}; + +export const currentPrismEndpoint = (): PrismEndpoint | undefined => published; + +/** + * Fires whenever the Prism usage-limit source appears, disappears, or points + * somewhere else (a republished endpoint or the `prism.usageSource` toggle). + * `UsageLimitSources` re-reads its sources on each emission. + */ +export const prismUsageSourceChanges: Stream.Stream = + Stream.fromPubSub(usageSourcePubSub); + +/** The `UsageLimitSources.ts` seam: run `refresh` on every emission of `prismUsageSourceChanges` for the life of the scope. */ +export const refreshOnPrismUsageSourceChange = ( + refresh: Effect.Effect, +): Effect.Effect => + Stream.runForEach(prismUsageSourceChanges, () => refresh).pipe(Effect.forkScoped, Effect.asVoid); + +/** The `usageLimitSources` entry for the proxy; defined only while it is ready and `prism.usageSource` is on. */ +export const prismUsageLimitSource = (): PrismUsageLimitSourceEntry | undefined => { + const endpoint = published; + if (endpoint === undefined || !endpoint.usageSource) return undefined; + return [ + UsageLimitSourceId.make(PRISM_USAGE_SOURCE_ID), + { + kind: "cliproxy", + label: PRISM_USAGE_SOURCE_LABEL, + url: endpoint.baseUrl, + managementKey: endpoint.managementSecret, + enabled: true, + }, + ]; +}; + +const originOf = (url: string): string | undefined => { + try { + return new URL(url).origin; + } catch { + return undefined; + } +}; + +/** + * The `UsageLimitSources.ts` seam. Appends the Prism entry to the user's + * configured sources, unless one of them already targets the same origin (the + * user added the proxy as a hub by hand) or reuses the Prism id. Returns + * `entries` itself (same array) whenever nothing is added, so with the flag + * off upstream's refresh sees exactly what it computed. + */ +export const withPrismUsageLimitSource = ( + entries: ReadonlyArray, +): ReadonlyArray => { + const prism = prismUsageLimitSource(); + if (prism === undefined) return entries; + const origin = originOf(prism[1].url); + const duplicate = entries.some( + ([id, config]) => id === prism[0] || (origin !== undefined && originOf(config.url) === origin), + ); + return duplicate ? entries : [...entries, prism]; +}; diff --git a/apps/server/src/fork/prism/PrismHttpApi.test.ts b/apps/server/src/fork/prism/PrismHttpApi.test.ts new file mode 100644 index 000000000000..2c26a87be837 --- /dev/null +++ b/apps/server/src/fork/prism/PrismHttpApi.test.ts @@ -0,0 +1,652 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PrismHttpApi, type PrismSyncEntry } from "@q1code/core/prismApi"; +import { DEFAULT_FORK_FLAGS, type ForkFlagValues } from "@q1code/core/flags"; +import { + AuthAdministrativeScopes, + type AuthEnvironmentScope, + AuthSessionId, + AuthStandardClientScopes, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentAuthInvalidError, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { decodeForkConfig } from "@q1code/core/config"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { + Etag, + HttpClientRequest, + HttpClientResponse, + HttpPlatform, + HttpServerRequest, +} from "effect/unstable/http"; +import * as HttpApiTest from "effect/unstable/httpapi/HttpApiTest"; + +import * as ServerConfig from "../../config.ts"; +import { ForkConfigWriteError, ForkFlagsService, type RawForkConfig } from "../ForkFlags.ts"; +import { prismHttpApiLayer } from "./PrismHttpApi.ts"; +import { PrismService, type PrismStatus } from "./PrismService.ts"; +import { PrismSyncNotConfigured, PrismSyncService } from "./PrismSync.ts"; + +interface SidecarCall { + readonly method: string; + readonly path: string; + readonly body: unknown; +} + +/** A scripted sidecar: `respond` maps ` ` to a JSON body and status. */ +const makeSidecar = ( + status: PrismStatus, + respond: (method: string, path: string) => { readonly status?: number; readonly body: unknown }, + overrides: Partial = {}, +) => { + const calls: Array = []; + const layer = Layer.succeed( + PrismService, + PrismService.of({ + status: Effect.succeed(status), + changes: Stream.empty, + endpoint: Effect.succeed(Option.none()), + // Answers with the status a restart would have settled on. + restart: Effect.succeed({ ...status, restarts: status.restarts + 1 }), + reloadUsageSource: Effect.succeed(status), + management: { + request: (path, options) => + Effect.sync(() => { + const method = options?.method ?? "GET"; + // The body is inspected as wire JSON, not decoded into a domain value. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const body = options?.body === undefined ? undefined : JSON.parse(options.body); + calls.push({ method, path, body }); + const reply = respond(method, path); + return HttpClientResponse.fromWeb( + HttpClientRequest.get(`http://sidecar${path}`), + Response.json(reply.body, { status: reply.status ?? 200 }), + ); + }), + }, + codexProxyHomePath: "/unused", + ...overrides, + }), + ); + return { layer, calls }; +}; + +/** Sync that is not configured; `tombstones` collects what `DELETE accounts/:id` records. */ +const tombstones: Array = []; +const makeSyncLayer = (role: "standalone" | "replica" = "standalone") => + Layer.succeed( + PrismSyncService, + PrismSyncService.of({ + status: Effect.succeed({ role }), + changes: Stream.empty, + exportBundle: Effect.fail(new PrismSyncNotConfigured({ message: "no sync" })), + applyPush: (_entries: ReadonlyArray) => + Effect.fail(new PrismSyncNotConfigured({ message: "no sync" })), + syncNow: Effect.void, + recordTombstone: (id) => + Effect.sync(() => { + tombstones.push(id); + }), + }), + ); + +/** Flags with an in-memory `fork.json`: `raw` is what `update` would have written. */ +const makeFlags = (prism: boolean, initial: RawForkConfig = {}) => { + const values: ForkFlagValues = { ...DEFAULT_FORK_FLAGS, prism }; + const file = { raw: initial }; + const layer = Layer.succeed( + ForkFlagsService, + ForkFlagsService.of({ + current: Effect.succeed(values), + reload: Effect.succeed(values), + changes: Stream.empty, + config: Effect.succeed({}), + update: (mutate) => + Effect.suspend(() => { + const next = mutate(file.raw); + const decoded = decodeForkConfig(next); + if (Exit.isFailure(decoded)) { + return Effect.fail( + new ForkConfigWriteError({ path: "fork.json", reason: "invalid", detail: "test" }), + ); + } + file.raw = next; + return Effect.succeed(decoded.value); + }), + }), + ); + return { layer, file }; +}; + +/** `Bearer read` carries the standard scopes, `Bearer admin` the administrative ones, anything else is 401. */ +const authLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const scopes: ReadonlyArray | undefined = + request.headers.authorization === "Bearer admin" + ? AuthAdministrativeScopes + : request.headers.authorization === "Bearer read" + ? AuthStandardClientScopes + : undefined; + if (scopes === undefined) { + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "test", + }); + } + return yield* httpEffect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("session-1"), + subject: "test", + method: "bearer-access-token", + scopes: new Set(scopes), + }), + ); + }), +); + +const SINCE = "2026-09-02T09:00:00.000Z"; +const READY: PrismStatus = { + state: "ready", + mode: "sidecar", + port: 8317, + since: SINCE, + restarts: 0, + version: "7.2.147", + pid: 42, + baseUrl: "http://127.0.0.1:8317", + usageSource: true, +}; +const OFF: PrismStatus = { + state: "off", + mode: "sidecar", + port: 8317, + since: SINCE, + restarts: 0, + usageSource: true, +}; + +const listing = [ + { + id: "codex-a@example.com.json", + name: "codex-a@example.com.json", + type: "codex", + provider: "codex", + label: "", + email: "a@example.com", + disabled: false, + weight: 3, + updated_at: "2026-09-02T10:00:00.123456789Z", + success: 12, + failed: 1, + quota: { observed_at: "2026-09-02T09:30:00Z", signals: { "5h": "ok" } }, + }, + { + name: "claude-b.json", + type: "claude", + disabled: true, + modtime: "2026-09-02T11:00:00Z", + success: 0, + failed: 0, + quota: { signals: {} }, + }, + { name: ".oauth-anthropic-x.oauth", type: "" }, +]; + +const makeClient = ( + sidecar: ReturnType, + options: { + readonly role?: "standalone" | "replica"; + readonly flag?: boolean; + readonly flags?: ReturnType; + } = {}, +) => + HttpApiTest.groups(PrismHttpApi, ["prism"]).pipe( + Effect.provide( + prismHttpApiLayer.pipe( + Layer.provide(sidecar.layer), + Layer.provide(makeSyncLayer(options.role)), + Layer.provide((options.flags ?? makeFlags(options.flag ?? true)).layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "q1code-prism-http-" })), + Layer.provideMerge(authLayer), + Layer.provideMerge(Layer.mergeAll(HttpPlatform.layer, Etag.layerWeak)), + ), + ), + ); + +const admin = { headers: { authorization: "Bearer admin" } }; +const read = { headers: { authorization: "Bearer read" } }; + +it.layer(NodeServices.layer, { excludeTestServices: true })("PrismHttpApi", (it) => { + it.effect("replicas reject sign-in and account mutations before calling the engine", () => + Effect.gen(function* () { + const sidecar = makeSidecar(READY, () => ({ body: {} })); + const client = yield* makeClient(sidecar, { role: "replica" }); + const login = yield* client.prism + .startLogin({ ...admin, payload: { provider: "codex" } }) + .pipe(Effect.flip); + const patch = yield* client.prism + .patchAccount({ ...admin, params: { id: "a.json" }, payload: { disabled: true } }) + .pipe(Effect.flip); + const removed = yield* client.prism + .deleteAccount({ ...admin, params: { id: "a.json" } }) + .pipe(Effect.flip); + for (const error of [login, patch, removed]) { + assert.equal(error._tag, "PrismUnavailableError"); + if (error._tag === "PrismUnavailableError") assert.equal(error.reason, "replica-read-only"); + } + assert.deepEqual(sidecar.calls, []); + }), + ); + + it.effect("status answers without the sidecar and rejects a missing credential", () => + Effect.gen(function* () { + const client = yield* makeClient(makeSidecar(OFF, () => ({ body: {} }))); + const status = yield* client.prism.status(read); + assert.deepEqual(status, { + state: "off", + port: 8317, + role: "standalone", + mode: "sidecar", + restarts: 0, + since: SINCE, + usageSource: true, + }); + const unauthenticated = yield* client.prism.status({ headers: {} }).pipe(Effect.flip); + assert.equal(unauthenticated._tag, "EnvironmentAuthInvalidError"); + }), + ); + + it.effect("every proxied endpoint is 503 with the state while the sidecar is not ready", () => + Effect.gen(function* () { + const sidecar = makeSidecar(OFF, () => ({ body: {} })); + const client = yield* makeClient(sidecar, { flag: false }); + const exit = yield* client.prism.listAccounts(read).pipe(Effect.flip); + assert.equal(exit._tag, "PrismUnavailableError"); + assert.isTrue(exit._tag === "PrismUnavailableError" && exit.reason === "flag-off"); + assert.isTrue(exit._tag === "PrismUnavailableError" && exit.state === "off"); + const sync = yield* client.prism.syncExport(admin).pipe(Effect.flip); + assert.equal(sync._tag, "PrismUnavailableError"); + assert.deepEqual(sidecar.calls, []); + }), + ); + + it.effect("status carries the proxy mode, origin, restarts, and last error", () => + Effect.gen(function* () { + const external: PrismStatus = { + state: "failed", + mode: "external", + port: 8317, + since: SINCE, + restarts: 2, + lastError: "connect ECONNREFUSED", + usageSource: true, + }; + const client = yield* makeClient(makeSidecar(external, () => ({ body: {} }))); + const status = yield* client.prism.status(read); + assert.deepEqual(status, { + state: "failed", + port: 8317, + role: "standalone", + mode: "external", + lastError: "connect ECONNREFUSED", + restarts: 2, + since: SINCE, + usageSource: true, + }); + const ready = yield* makeClient(makeSidecar(READY, () => ({ body: {} }))); + assert.equal((yield* ready.prism.status(read)).baseUrl, "http://127.0.0.1:8317"); + }), + ); + + it.effect("restart needs the flag and access:write, then answers with the settled status", () => + Effect.gen(function* () { + const off = yield* makeClient( + makeSidecar(OFF, () => ({ body: {} })), + { flag: false }, + ); + const unavailable = yield* off.prism.restart(admin).pipe(Effect.flip); + assert.equal(unavailable._tag, "PrismUnavailableError"); + assert.isTrue( + unavailable._tag === "PrismUnavailableError" && unavailable.reason === "flag-off", + ); + + const client = yield* makeClient(makeSidecar(READY, () => ({ body: {} }))); + const forbidden = yield* client.prism.restart(read).pipe(Effect.flip); + assert.equal(forbidden._tag, "EnvironmentScopeRequiredError"); + + const status = yield* client.prism.restart(admin); + assert.equal(status.state, "ready"); + assert.equal(status.mode, "sidecar"); + assert.equal(status.restarts, 1); + assert.equal(status.role, "standalone"); + }), + ); + + it.effect("lists accounts mapped from the sidecar's auth files", () => + Effect.gen(function* () { + const client = yield* makeClient(makeSidecar(READY, () => ({ body: { files: listing } }))); + const { accounts } = yield* client.prism.listAccounts(read); + assert.deepEqual(accounts, [ + { + id: "codex-a@example.com.json", + provider: "codex", + label: "a@example.com", + email: "a@example.com", + disabled: false, + weight: 3, + updatedAt: "2026-09-02T10:00:00.123Z", + usage: { + success: 12, + failed: 1, + quota: { observedAt: "2026-09-02T09:30:00.000Z", signals: { "5h": "ok" } }, + }, + }, + { + id: "claude-b.json", + provider: "claude", + label: "claude-b", + disabled: true, + updatedAt: "2026-09-02T11:00:00.000Z", + usage: { success: 0, failed: 0 }, + }, + ]); + }), + ); + + it.effect("exposes safe lifecycle observations and omits invalid or unknown expiry", () => + Effect.gen(function* () { + const files = [ + { + name: "codex.json", + provider: "codex", + updated_at: "2026-09-04T10:00:00Z", + status: "error", + unavailable: true, + last_error_status: 401, + requires_login: true, + expires_at: "2026-09-04T11:00:00Z", + last_refresh: "2026-09-04T09:00:00Z", + next_refresh_after: "2026-09-04T12:00:00Z", + access_token: "private-access", + refresh_token: "private-refresh", + status_message: "private-error", + }, + { + name: "claude.json", + provider: "claude", + updated_at: "2026-09-04T10:00:00Z", + expires_at: "invalid", + }, + ]; + const client = yield* makeClient(makeSidecar(READY, () => ({ body: { files } }))); + const { accounts } = yield* client.prism.listAccounts(read); + assert.deepEqual(accounts[0]?.lifecycle, { + status: "error", + unavailable: true, + lastErrorStatus: 401, + requiresLogin: true, + expiresAt: "2026-09-04T11:00:00.000Z", + lastRefreshedAt: "2026-09-04T09:00:00.000Z", + refreshNotBefore: "2026-09-04T12:00:00.000Z", + }); + assert.isUndefined(accounts[1]?.lifecycle); + assert.notProperty(accounts[0], "access_token"); + assert.notProperty(accounts[0], "refresh_token"); + assert.notProperty(accounts[0], "status_message"); + }), + ); + + it.effect("mutations need access:write and map to the sidecar's patch endpoints", () => + Effect.gen(function* () { + const sidecar = makeSidecar(READY, (method, path) => + path === "/auth-files" ? { body: { files: listing } } : { body: { status: "ok" } }, + ); + const client = yield* makeClient(sidecar); + const forbidden = yield* client.prism + .patchAccount({ ...read, params: { id: "claude-b.json" }, payload: { disabled: false } }) + .pipe(Effect.flip); + assert.equal(forbidden._tag, "EnvironmentScopeRequiredError"); + assert.deepEqual(sidecar.calls, []); + + const account = yield* client.prism.patchAccount({ + ...admin, + params: { id: "claude-b.json" }, + payload: { disabled: false, weight: 2 }, + }); + assert.equal(account.id, "claude-b.json"); + assert.deepEqual( + sidecar.calls.map((call) => [call.method, call.path, call.body]), + [ + ["PATCH", "/auth-files/status", { name: "claude-b.json", disabled: false }], + ["PATCH", "/auth-files/fields", { name: "claude-b.json", weight: 2 }], + ["GET", "/auth-files", undefined], + ], + ); + + sidecar.calls.length = 0; + tombstones.length = 0; + yield* client.prism.deleteAccount({ ...admin, params: { id: "claude-b.json" } }); + assert.deepEqual(sidecar.calls, [ + { method: "DELETE", path: "/auth-files?name=claude-b.json", body: undefined }, + ]); + assert.deepEqual(tombstones, ["claude-b.json"]); + }), + ); + + it.effect("turns the sidecar's 404 into a not-found error", () => + Effect.gen(function* () { + const client = yield* makeClient( + makeSidecar(READY, () => ({ status: 404, body: { error: "auth file not found" } })), + ); + tombstones.length = 0; + const missing = yield* client.prism + .deleteAccount({ ...admin, params: { id: "gone.json" } }) + .pipe(Effect.flip); + assert.equal(missing._tag, "PrismNotFoundError"); + assert.deepEqual(tombstones, []); + }), + ); + + it.effect("runs a login: start, poll, complete with the new account, cancel", () => + Effect.gen(function* () { + let files = listing.slice(0, 1); + let authStatus = "wait"; + const sidecar = makeSidecar(READY, (method, path) => { + if (path === "/auth-files") return { body: { files } }; + if (path.startsWith("/anthropic-auth-url")) + return { body: { status: "ok", url: "https://claude.ai/oauth?x", state: "state-1" } }; + if (path.startsWith("/get-auth-status")) + return { body: authStatus === "wait" ? { status: "wait" } : { status: authStatus } }; + if (path.startsWith("/oauth-session")) return { body: { status: "ok", cancelled: true } }; + return { body: { status: "ok" } }; + }); + const client = yield* makeClient(sidecar); + const started = yield* client.prism.startLogin({ + ...admin, + payload: { provider: "anthropic" }, + }); + assert.deepEqual(started, { + sessionId: "state-1", + authUrl: "https://claude.ai/oauth?x", + flow: "redirect", + }); + assert.equal(sidecar.calls.at(-1)?.path, "/anthropic-auth-url?is_webui=true"); + + const pending = yield* client.prism.loginStatus({ + ...read, + params: { sessionId: "state-1" }, + }); + assert.deepEqual(pending, { sessionId: "state-1", status: "pending" }); + + files = listing.slice(0, 2); + authStatus = "ok"; + const completed = yield* client.prism.loginStatus({ + ...read, + params: { sessionId: "state-1" }, + }); + assert.deepEqual(completed, { + sessionId: "state-1", + status: "completed", + accountId: "claude-b.json", + }); + + const cancelled = yield* client.prism.cancelLogin({ + ...admin, + params: { sessionId: "state-1" }, + }); + assert.deepEqual(cancelled, { sessionId: "state-1", status: "cancelled" }); + assert.equal(sidecar.calls.at(-1)?.path, "/oauth-session?state=state-1"); + const afterCancel = yield* client.prism.loginStatus({ + ...read, + params: { sessionId: "state-1" }, + }); + assert.equal(afterCancel.status, "cancelled"); + }), + ); + + it.effect("sets the routing strategy through PUT, persists it, and reads it back", () => + Effect.gen(function* () { + let strategy = "round-robin"; + const sidecar = makeSidecar(READY, (method, path) => { + if (path === "/routing/strategy" && method === "PUT") { + strategy = "fill-first"; + return { body: {} }; + } + return { body: { strategy } }; + }); + // Keys the schema does not know, next to the one that moves. + const flags = makeFlags(true, { + flags: { prism: true }, + prism: { port: 9001, routingStrategy: "round-robin" }, + somethingElse: { nested: true }, + }); + const client = yield* makeClient(sidecar, { flags }); + assert.deepEqual(yield* client.prism.getRouting(read), { strategy: "round-robin" }); + const updated = yield* client.prism.setRouting({ + ...admin, + payload: { strategy: "fill-first" }, + }); + assert.deepEqual(updated, { strategy: "fill-first" }); + assert.deepEqual(sidecar.calls[1], { + method: "PUT", + path: "/routing/strategy", + body: { value: "fill-first" }, + }); + assert.deepEqual(flags.file.raw, { + flags: { prism: true }, + prism: { port: 9001, routingStrategy: "fill-first" }, + somethingElse: { nested: true }, + }); + }), + ); + + it.effect("answers 500 when the routing strategy cannot be persisted", () => + Effect.gen(function* () { + const sidecar = makeSidecar(READY, () => ({ body: { strategy: "fill-first" } })); + // A `prism` section the schema rejects makes the write fail validation. + const flags = makeFlags(true, { prism: { port: "not-a-port" } }); + const client = yield* makeClient(sidecar, { flags }); + const failure = yield* client.prism + .setRouting({ ...admin, payload: { strategy: "fill-first" } }) + .pipe(Effect.flip); + assert.equal(failure._tag, "PrismConfigError"); + assert.deepEqual(flags.file.raw, { prism: { port: "not-a-port" } }); + }), + ); + + it.effect("toggles the usage source through PUT, persists it, and answers the status", () => + Effect.gen(function* () { + const flags = makeFlags(true, { + flags: { prism: true }, + prism: { port: 9001, routingStrategy: "round-robin" }, + somethingElse: { nested: true }, + }); + // The fake reload reads the toggle back from the file, as the real service does. + const sidecar = makeSidecar(READY, () => ({ body: {} }), { + reloadUsageSource: Effect.sync(() => { + const prism = flags.file.raw.prism as { usageSource?: boolean } | undefined; + return { ...READY, usageSource: prism?.usageSource ?? true }; + }), + }); + const client = yield* makeClient(sidecar, { flags }); + const off = yield* client.prism.setUsageSource({ ...admin, payload: { enabled: false } }); + assert.equal(off.usageSource, false); + assert.equal(off.state, "ready"); + assert.deepEqual(flags.file.raw, { + flags: { prism: true }, + prism: { port: 9001, routingStrategy: "round-robin", usageSource: false }, + somethingElse: { nested: true }, + }); + const on = yield* client.prism.setUsageSource({ ...admin, payload: { enabled: true } }); + assert.equal(on.usageSource, true); + assert.deepEqual((flags.file.raw.prism as { usageSource?: boolean }).usageSource, true); + // Nothing goes to the sidecar: the toggle is a server-side setting. + assert.deepEqual(sidecar.calls, []); + }), + ); + + it.effect("usage-source PUT needs the flag and access:write", () => + Effect.gen(function* () { + const flagsOff = makeFlags(false); + const off = yield* makeClient( + makeSidecar(OFF, () => ({ body: {} })), + { flags: flagsOff }, + ); + const unavailable = yield* off.prism + .setUsageSource({ ...admin, payload: { enabled: false } }) + .pipe(Effect.flip); + assert.equal(unavailable._tag, "PrismUnavailableError"); + assert.isTrue( + unavailable._tag === "PrismUnavailableError" && unavailable.reason === "flag-off", + ); + assert.deepEqual(flagsOff.file.raw, {}); + + const flags = makeFlags(true); + const client = yield* makeClient( + makeSidecar(READY, () => ({ body: {} })), + { flags }, + ); + const forbidden = yield* client.prism + .setUsageSource({ ...read, payload: { enabled: false } }) + .pipe(Effect.flip); + assert.equal(forbidden._tag, "EnvironmentScopeRequiredError"); + assert.deepEqual(flags.file.raw, {}); + }), + ); + + it.effect("answers 500 when the usage source cannot be persisted", () => + Effect.gen(function* () { + const flags = makeFlags(true, { prism: { port: "not-a-port" } }); + const client = yield* makeClient( + makeSidecar(READY, () => ({ body: {} })), + { flags }, + ); + const failure = yield* client.prism + .setUsageSource({ ...admin, payload: { enabled: false } }) + .pipe(Effect.flip); + assert.equal(failure._tag, "PrismConfigError"); + assert.deepEqual(flags.file.raw, { prism: { port: "not-a-port" } }); + }), + ); + + it.effect("relays sidecar failures as 502 with the sidecar's message", () => + Effect.gen(function* () { + const client = yield* makeClient( + makeSidecar(READY, () => ({ + status: 500, + body: { error: "core auth manager unavailable" }, + })), + ); + const failure = yield* client.prism.getRouting(read).pipe(Effect.flip); + assert.equal(failure._tag, "PrismUpstreamError"); + assert.include(String(failure), "core auth manager unavailable"); + }), + ); +}); diff --git a/apps/server/src/fork/prism/PrismHttpApi.ts b/apps/server/src/fork/prism/PrismHttpApi.ts new file mode 100644 index 000000000000..5a7d860671bf --- /dev/null +++ b/apps/server/src/fork/prism/PrismHttpApi.ts @@ -0,0 +1,613 @@ +/** + * The q1code accounts API over the sidecar's management API. Every handler + * proxies through `PrismService.management.request`, so the management + * secret never leaves the server; clients authenticate with the same + * environment auth as every other `/api` endpoint. + * + * Reads need `orchestration:read`; mutations, `restart`, and both sync + * endpoints need `access:write`. With the flag off or the proxy not ready every + * endpoint except `status` and `sync/status` answers 503 with the proxy state + * (`restart` only needs the flag: it is how a `failed` proxy is retried). + * + * `PUT routing` also writes `prism.routingStrategy` into `fork.json`, so the + * strategy the sidecar is told now is the one it is started with next time. + * `PUT usage-source` writes `prism.usageSource` the same way and republishes + * the endpoint, so the Limits view follows the toggle at once. + */ +import { + type PrismAccount, + type PrismAccountUsage, + PrismConfigError, + PrismHttpApi, + type PrismLoginStatus, + PrismNotFoundError, + type PrismStatus, + PrismUnavailableError, + PrismUpstreamError, + type PrismUsage, +} from "@q1code/core/prismApi"; +import { PrismRoutingStrategy } from "@q1code/core/config"; +import { AuthAccessWriteScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { HttpClientResponse } from "effect/unstable/http"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { + annotateEnvironmentRequest, + environmentAuthenticatedAuthLayer, + requireEnvironmentScope, +} from "../../auth/http.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as ForkFlags from "../ForkFlags.ts"; +import { prismAuthsDir, prismDirectories } from "./PrismConfig.ts"; +import * as Prism from "./PrismService.ts"; +import * as PrismSync from "./PrismSync.ts"; + +/** What we read from the sidecar; everything else it returns is ignored. */ +const AuthFileEntry = Schema.Struct({ + name: Schema.String, + type: Schema.optionalKey(Schema.String), + provider: Schema.optionalKey(Schema.String), + label: Schema.optionalKey(Schema.String), + email: Schema.optionalKey(Schema.String), + disabled: Schema.optionalKey(Schema.Boolean), + weight: Schema.optionalKey(Schema.Number), + modtime: Schema.optionalKey(Schema.String), + updated_at: Schema.optionalKey(Schema.String), + status: Schema.optionalKey(Schema.String), + unavailable: Schema.optionalKey(Schema.Boolean), + expires_at: Schema.optionalKey(Schema.String), + last_refresh: Schema.optionalKey(Schema.String), + next_refresh_after: Schema.optionalKey(Schema.String), + next_retry_after: Schema.optionalKey(Schema.String), + requires_login: Schema.optionalKey(Schema.Boolean), + last_error_status: Schema.optionalKey(Schema.Number), + // Counters and quota observations (`buildAuthFileEntry` in the sidecar); absent from disk-only listings. + success: Schema.optionalKey(Schema.Number), + failed: Schema.optionalKey(Schema.Number), + quota: Schema.optionalKey( + Schema.Struct({ + observed_at: Schema.optionalKey(Schema.String), + signals: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }), + ), +}); +const AuthFilesResponse = Schema.Struct({ files: Schema.Array(AuthFileEntry) }); +const OAuthStartResponse = Schema.Struct({ + url: Schema.String, + state: Schema.String, + flow: Schema.optionalKey(Schema.String), + user_code: Schema.optionalKey(Schema.String), +}); +const OAuthStatusResponse = Schema.Struct({ + status: Schema.String, + error: Schema.optionalKey(Schema.String), +}); +const RoutingResponse = Schema.Struct({ strategy: Schema.String }); +const UsageResponse = Schema.Record( + Schema.String, + Schema.Record( + Schema.String, + Schema.Struct({ + success: Schema.Number, + failed: Schema.Number, + recent_requests: Schema.optionalKey( + Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), + ), + }), + ), +); +const ErrorResponse = Schema.Struct({ error: Schema.optionalKey(Schema.String) }); +const Ignored = Schema.Unknown; + +const isRoutingStrategy = Schema.is(PrismRoutingStrategy); + +/** Sidecar timestamps arrive as Go `time.Time` (RFC 3339 with nanoseconds); normalize to millisecond ISO. */ +const toIso = (value: string | undefined): string | undefined => { + if (value === undefined) return undefined; + const millis = Date.parse(value); + return Number.isFinite(millis) ? DateTime.formatIso(DateTime.makeUnsafe(millis)) : undefined; +}; + +/** Counters only when the sidecar reports them; quota only when it observed something. */ +const toUsage = (entry: typeof AuthFileEntry.Type): PrismAccountUsage | undefined => { + if (entry.success === undefined || entry.failed === undefined) return undefined; + const observedAt = toIso(entry.quota?.observed_at); + const signals = entry.quota?.signals ?? {}; + const quota = + observedAt !== undefined || Object.keys(signals).length > 0 + ? { ...(observedAt !== undefined ? { observedAt } : {}), signals } + : undefined; + return { + success: entry.success, + failed: entry.failed, + ...(quota !== undefined ? { quota } : {}), + }; +}; + +interface LoginSession { + readonly before: ReadonlySet; + readonly cancelled: boolean; +} + +type ProxyError = PrismUnavailableError | PrismUpstreamError; + +export const prismHttpApiLayer = HttpApiBuilder.group( + PrismHttpApi, + "prism", + Effect.fnUntraced(function* (handlers) { + const proxy = yield* Prism.PrismService; + const sync = yield* PrismSync.PrismSyncService; + const flags = yield* ForkFlags.ForkFlagsService; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = prismDirectories(config.baseDir, path); + const currentAuthsDir = flags.config.pipe( + Effect.map((forkConfig) => prismAuthsDir(forkConfig.prism, directories, path)), + ); + const loginSessions = yield* Ref.make>(new Map()); + + const unavailable = ( + reason: PrismUnavailableError["reason"], + ): Effect.Effect => + proxy.status.pipe( + Effect.flatMap((status) => + Effect.fail(new PrismUnavailableError({ reason, state: status.state })), + ), + ); + + const requireAccountOwner = Effect.gen(function* () { + if ((yield* sync.status).role === "replica") return yield* unavailable("replica-read-only"); + }); + + const requireReady: Effect.Effect = proxy.status.pipe( + Effect.flatMap((status) => + status.state === "ready" + ? Effect.succeed(status) + : Effect.fail( + new PrismUnavailableError({ + reason: status.state === "off" ? "flag-off" : "sidecar-not-ready", + state: status.state, + }), + ), + ), + ); + + const requireFlag: Effect.Effect = flags.current.pipe( + Effect.flatMap((values) => (values.prism ? Effect.void : unavailable("flag-off"))), + ); + + const upstreamMessage = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(ErrorResponse)(response).pipe( + Effect.map((body) => body.error ?? `HTTP ${response.status}`), + Effect.orElseSucceed(() => `HTTP ${response.status}`), + ); + + /** One management call, decoded. Sidecar errors become 502 with the sidecar's message and status. */ + const call = >( + schema: S, + requestPath: string, + options?: Prism.PrismManagementRequestOptions, + ): Effect.Effect => + proxy.management.request(requestPath, options).pipe( + Effect.catch((error): Effect.Effect => + error.reason === "not-ready" + ? unavailable("sidecar-not-ready") + : Effect.fail(new PrismUpstreamError({ status: 0, message: error.message })), + ), + Effect.flatMap((response): Effect.Effect => + response.status >= 400 + ? upstreamMessage(response).pipe( + Effect.flatMap((message) => + Effect.fail(new PrismUpstreamError({ status: response.status, message })), + ), + ) + : HttpClientResponse.schemaBodyJson(schema)(response).pipe( + Effect.mapError( + () => + new PrismUpstreamError({ + status: response.status, + message: `unexpected response from ${requestPath}`, + }), + ), + ), + ), + ); + + const json = ( + method: "POST" | "PUT" | "PATCH", + body: unknown, + ): Prism.PrismManagementRequestOptions => ({ + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + const notFoundAs = (id: string) => (error: ProxyError) => + error._tag === "PrismUpstreamError" && error.status === 404 + ? new PrismNotFoundError({ id }) + : error; + + const fileMtime = (name: string) => + currentAuthsDir.pipe( + Effect.flatMap((authsDir) => fs.stat(path.join(authsDir, name))), + Effect.map((info) => Option.map(info.mtime, (date) => date.toISOString())), + Effect.orElseSucceed(() => Option.none()), + ); + + const toAccount = (entry: typeof AuthFileEntry.Type) => + Effect.gen(function* () { + const provider = entry.provider?.trim() || entry.type?.trim() || "unknown"; + const email = entry.email?.trim() || undefined; + const label = entry.label?.trim() || email || entry.name.replace(/\.json$/, ""); + const updatedAt = + toIso(entry.updated_at) ?? + toIso(entry.modtime) ?? + Option.getOrUndefined(yield* fileMtime(entry.name)) ?? + DateTime.formatIso(yield* DateTime.now); + const usage = toUsage(entry); + const expiresAt = toIso(entry.expires_at); + const lastRefreshedAt = toIso(entry.last_refresh); + const refreshNotBefore = toIso(entry.next_refresh_after); + const retryAt = toIso(entry.next_retry_after); + const lifecycle = { + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.unavailable !== undefined ? { unavailable: entry.unavailable } : {}), + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(lastRefreshedAt !== undefined ? { lastRefreshedAt } : {}), + ...(refreshNotBefore !== undefined ? { refreshNotBefore } : {}), + ...(retryAt !== undefined ? { retryAt } : {}), + ...(entry.requires_login !== undefined ? { requiresLogin: entry.requires_login } : {}), + ...(entry.last_error_status !== undefined + ? { lastErrorStatus: entry.last_error_status } + : {}), + }; + return { + id: entry.name, + provider, + label, + ...(email !== undefined ? { email } : {}), + disabled: entry.disabled ?? false, + ...(entry.weight !== undefined ? { weight: entry.weight } : {}), + updatedAt, + ...(usage !== undefined ? { usage } : {}), + ...(Object.keys(lifecycle).length > 0 ? { lifecycle } : {}), + } satisfies PrismAccount; + }); + + const listAccounts = call(AuthFilesResponse, "/auth-files").pipe( + Effect.flatMap((response) => + Effect.forEach( + response.files.filter((entry) => entry.name.endsWith(".json")), + toAccount, + ), + ), + ); + + const loginStatus = (sessionId: string) => + Effect.gen(function* () { + const session = (yield* Ref.get(loginSessions)).get(sessionId); + if (session?.cancelled) { + return { sessionId, status: "cancelled" } satisfies PrismLoginStatus; + } + const response = yield* call( + OAuthStatusResponse, + `/get-auth-status?state=${encodeURIComponent(sessionId)}`, + ); + switch (response.status) { + case "ok": { + const accounts = yield* listAccounts; + const created = accounts.find((account) => !(session?.before.has(account.id) ?? false)); + return { + sessionId, + status: "completed", + ...(created !== undefined ? { accountId: created.id } : {}), + } satisfies PrismLoginStatus; + } + case "wait": + return { sessionId, status: "pending" } satisfies PrismLoginStatus; + default: + return { + sessionId, + status: "failed", + error: response.error ?? response.status, + } satisfies PrismLoginStatus; + } + }); + + /** Persist keys of the `prism` section next to whatever else the file holds; only the given keys move. */ + const persistPrismSection = (patch: Readonly>) => + flags + .update((raw) => ({ + ...raw, + prism: { + ...(Predicate.isObject(raw.prism) && !Array.isArray(raw.prism) ? raw.prism : {}), + ...patch, + }, + })) + .pipe(Effect.mapError((error) => new PrismConfigError({ message: error.message }))); + + const persistRoutingStrategy = (strategy: PrismRoutingStrategy) => + persistPrismSection({ routingStrategy: strategy }); + + const getRouting = call(RoutingResponse, "/routing/strategy").pipe( + Effect.flatMap(({ strategy }) => + isRoutingStrategy(strategy) + ? Effect.succeed({ strategy }) + : Effect.fail( + new PrismUpstreamError({ + status: 200, + message: `unknown routing strategy '${strategy}'`, + }), + ), + ), + ); + + const withRead = (name: string, body: Effect.Effect) => + annotateEnvironmentRequest(name).pipe( + Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), + Effect.andThen(body), + ); + + const withWrite = (name: string, body: Effect.Effect) => + annotateEnvironmentRequest(name).pipe( + Effect.andThen(requireEnvironmentScope(AuthAccessWriteScope)), + Effect.andThen(body), + ); + + /** The proxy status plus the sync status, in the wire shape. */ + const fullStatus = (status: Prism.PrismStatus) => + sync.status.pipe( + Effect.map( + (syncStatus) => + ({ + state: status.state, + port: status.port, + ...(status.version !== undefined ? { version: status.version } : {}), + role: syncStatus.role, + ...(syncStatus.lastSyncAt !== undefined ? { lastSyncAt: syncStatus.lastSyncAt } : {}), + ...(syncStatus.lastSyncError !== undefined + ? { lastSyncError: syncStatus.lastSyncError } + : {}), + mode: status.mode, + ...(status.baseUrl !== undefined ? { baseUrl: status.baseUrl } : {}), + ...(status.lastError !== undefined ? { lastError: status.lastError } : {}), + restarts: status.restarts, + since: status.since, + usageSource: status.usageSource, + }) satisfies PrismStatus, + ), + ); + + return handlers + .handle("status", (args) => + withRead(args.endpoint.name, proxy.status.pipe(Effect.flatMap(fullStatus))), + ) + .handle("restart", (args) => + withWrite( + args.endpoint.name, + requireFlag.pipe(Effect.andThen(proxy.restart), Effect.flatMap(fullStatus)), + ), + ) + .handle("setUsageSource", (args) => + withWrite( + args.endpoint.name, + requireFlag.pipe( + Effect.andThen(persistPrismSection({ usageSource: args.payload.enabled })), + Effect.andThen(proxy.reloadUsageSource), + Effect.flatMap(fullStatus), + ), + ), + ) + .handle("listAccounts", (args) => + withRead( + args.endpoint.name, + requireReady.pipe( + Effect.andThen(listAccounts), + Effect.map((accounts) => ({ accounts })), + ), + ), + ) + .handle("startLogin", (args) => + withWrite( + args.endpoint.name, + Effect.gen(function* () { + yield* requireReady; + yield* requireAccountOwner; + const before = new Set((yield* listAccounts).map((account) => account.id)); + const started = yield* call( + OAuthStartResponse, + `/${args.payload.provider}-auth-url?is_webui=true`, + ); + yield* Ref.update(loginSessions, (sessions) => + new Map(sessions).set(started.state, { before, cancelled: false }), + ); + const userCode = started.user_code?.trim() || undefined; + return { + sessionId: started.state, + authUrl: started.url, + flow: started.flow === "device" ? ("device" as const) : ("redirect" as const), + ...(userCode !== undefined ? { userCode } : {}), + }; + }), + ), + ) + .handle("loginStatus", (args) => + withRead( + args.endpoint.name, + requireReady.pipe(Effect.andThen(loginStatus(args.params.sessionId))), + ), + ) + .handle("loginCallback", (args) => + withWrite( + args.endpoint.name, + Effect.gen(function* () { + yield* requireReady; + yield* requireAccountOwner; + yield* call( + Ignored, + "/oauth-callback", + json("POST", { + state: args.params.sessionId, + redirect_url: args.payload.redirectUrl, + }), + ); + return yield* loginStatus(args.params.sessionId); + }), + ), + ) + .handle("cancelLogin", (args) => + withWrite( + args.endpoint.name, + Effect.gen(function* () { + yield* requireReady; + yield* requireAccountOwner; + const sessionId = args.params.sessionId; + yield* call(Ignored, `/oauth-session?state=${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }); + yield* Ref.update(loginSessions, (sessions) => + new Map(sessions).set(sessionId, { + before: sessions.get(sessionId)?.before ?? new Set(), + cancelled: true, + }), + ); + return { sessionId, status: "cancelled" as const }; + }), + ), + ) + .handle("patchAccount", (args) => + withWrite( + args.endpoint.name, + Effect.gen(function* () { + yield* requireReady; + yield* requireAccountOwner; + const id = args.params.id; + if (args.payload.disabled !== undefined) { + yield* call( + Ignored, + "/auth-files/status", + json("PATCH", { name: id, disabled: args.payload.disabled }), + ).pipe(Effect.mapError(notFoundAs(id))); + } + if (args.payload.weight !== undefined) { + yield* call( + Ignored, + "/auth-files/fields", + json("PATCH", { name: id, weight: args.payload.weight }), + ).pipe(Effect.mapError(notFoundAs(id))); + } + const account = (yield* listAccounts).find((candidate) => candidate.id === id); + return account ?? (yield* new PrismNotFoundError({ id })); + }), + ), + ) + .handle("deleteAccount", (args) => + withWrite( + args.endpoint.name, + Effect.gen(function* () { + yield* requireReady; + yield* requireAccountOwner; + const id = args.params.id; + yield* call(Ignored, `/auth-files?name=${encodeURIComponent(id)}`, { + method: "DELETE", + }).pipe(Effect.mapError(notFoundAs(id))); + // The sidecar removed the file; the tombstone carries the deletion to the other environments. + yield* sync.recordTombstone(id).pipe( + Effect.catch((error) => + Effect.logWarning("prism: deletion not recorded for sync", { + id, + cause: error.message, + }), + ), + ); + return { ok: true as const }; + }), + ), + ) + .handle("getRouting", (args) => + withRead(args.endpoint.name, requireReady.pipe(Effect.andThen(getRouting))), + ) + .handle("setRouting", (args) => + withWrite( + args.endpoint.name, + requireReady.pipe( + Effect.andThen( + call(Ignored, "/routing/strategy", json("PUT", { value: args.payload.strategy })), + ), + Effect.andThen(persistRoutingStrategy(args.payload.strategy)), + Effect.andThen(getRouting), + ), + ), + ) + .handle("getUsage", (args) => + withRead( + args.endpoint.name, + requireReady.pipe( + Effect.andThen(call(UsageResponse, "/api-key-usage")), + Effect.map((usage): PrismUsage => + Object.fromEntries( + Object.entries(usage).map(([provider, keys]) => [ + provider, + Object.fromEntries( + Object.entries(keys).map(([key, entry]) => [ + key, + { + success: entry.success, + failed: entry.failed, + recentRequests: entry.recent_requests ?? [], + }, + ]), + ), + ]), + ), + ), + ), + ), + ) + .handle("syncExport", (args) => + withWrite( + args.endpoint.name, + requireFlag.pipe( + Effect.andThen(sync.exportBundle), + Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), + ), + ), + ) + .handle("syncPush", (args) => + withWrite( + args.endpoint.name, + requireFlag.pipe( + Effect.andThen(sync.applyPush(args.payload.entries, args.payload.tombstones ?? [])), + Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), + ), + ), + ) + .handle("syncStatus", (args) => withRead(args.endpoint.name, sync.status)); + }), +); + +/** + * The routes for `server.ts`. Every service below is memoized with the + * instance `ServerEnvironment.layer` already built, so no second sidecar spawns. + */ +export const prismRoutesLayer = HttpApiBuilder.layer(PrismHttpApi).pipe( + Layer.provide(prismHttpApiLayer), + Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provide(PrismSync.layer), + Layer.provide(Prism.layer), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ForkFlags.layer), + Layer.provide(ServerSecretStore.layer), +); diff --git a/apps/server/src/fork/prism/PrismProviderDriver.ts b/apps/server/src/fork/prism/PrismProviderDriver.ts new file mode 100644 index 000000000000..eff0309055fd --- /dev/null +++ b/apps/server/src/fork/prism/PrismProviderDriver.ts @@ -0,0 +1,123 @@ +import { expandHomePath } from "../../pathExpansion.ts"; +import type { ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; +import type { ProviderDriver } from "../../provider/ProviderDriver.ts"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import { ServerConfig } from "../../config.ts"; +import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; +import { currentPrismEndpoint, isPrismEnabled, prismEndpointChanges } from "./PrismEnvironment.ts"; +import { withPrismRouteOption } from "./PrismRouting.ts"; +import { makePrismRoutedAdapter } from "./PrismRoutedAdapter.ts"; + +/** Decorate existing Claude/Codex drivers; retain their native auth and maintenance paths. */ +export const withPrismProvider = ( + driver: ProviderDriver, +): ProviderDriver< + Config, + R | Path.Path | FileSystem.FileSystem | ServerConfig | Crypto.Crypto +> => ({ + ...driver, + create: (input) => + Effect.gen(function* () { + const context = yield* Effect.context< + R | Path.Path | FileSystem.FileSystem | ServerConfig | Scope.Scope + >(); + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig; + const direct = yield* driver.create(input); + const lock = yield* Semaphore.make(1); + let cached: { baseUrl: string; apiKey: string; instance: typeof direct } | undefined; + const proxy = () => + lock.withPermits(1)( + Effect.gen(function* () { + const endpoint = currentPrismEndpoint(); + if (!endpoint) return undefined; + if (cached?.baseUrl === endpoint.baseUrl && cached.apiKey === endpoint.apiKey) + return cached.instance.adapter; + let config = input.config; + const environment = [...input.environment]; + if (driver.driverKind === "claudeAgent") { + // Environment values are process-local and never persisted in provider settings. + environment.push( + { name: "ANTHROPIC_BASE_URL", value: endpoint.baseUrl, sensitive: false }, + { name: "ANTHROPIC_AUTH_TOKEN", value: endpoint.apiKey, sensitive: true }, + { name: "ANTHROPIC_API_KEY", value: "", sensitive: true }, + { name: "CLAUDE_CODE_OAUTH_TOKEN", value: "", sensitive: true }, + ); + } else { + // A separate home per instance preserves its selected direct account and native history. + const homeDir = path.join( + baseDir, + "prism", + "providers", + encodeURIComponent(input.instanceId), + "codex-home", + ); + yield* materializeCodexProxyHome({ + homeDir, + endpoint, + ...(input.config.homePath + ? { sharedHomeDir: path.resolve(expandHomePath(input.config.homePath)) } + : {}), + }); + config = { ...config, homePath: homeDir, shadowHomePath: "" }; + } + const instance = yield* driver.create({ ...input, config, environment }); + cached = { baseUrl: endpoint.baseUrl, apiKey: endpoint.apiKey, instance }; + return instance.adapter; + }).pipe( + Effect.provide(context), + Effect.mapError( + () => + new ProviderAdapterRequestError({ + provider: driver.driverKind, + method: "prism.setup", + detail: "Could not prepare the Prism provider connection.", + }), + ), + ), + ); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: isPrismEnabled, + proxy, + }); + const decorate = (snapshot: ServerProvider) => + withPrismRouteOption( + isPrismEnabled() && currentPrismEndpoint() && snapshot.enabled && snapshot.installed + ? { + ...snapshot, + status: "ready", + auth: { status: "authenticated", type: "prism", label: "Prism pool" }, + message: "Prism pool with local direct-provider fallback", + } + : snapshot, + isPrismEnabled(), + ); + return { + ...direct, + adapter, + snapshot: { + ...direct.snapshot, + getSnapshot: direct.snapshot.getSnapshot.pipe(Effect.map(decorate)), + refresh: direct.snapshot.refresh.pipe(Effect.map(decorate)), + streamChanges: Stream.merge( + direct.snapshot.streamChanges, + prismEndpointChanges.pipe(Stream.mapEffect(() => direct.snapshot.getSnapshot)), + ).pipe(Stream.map(decorate)), + }, + ...(direct.snapshotForCwd + ? { + snapshotForCwd: (cwd: string) => + direct.snapshotForCwd!(cwd).pipe(Effect.map(decorate)), + } + : {}), + }; + }), +}); diff --git a/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts b/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts new file mode 100644 index 000000000000..f94cdd7c1e64 --- /dev/null +++ b/apps/server/src/fork/prism/PrismRoutedAdapter.test.ts @@ -0,0 +1,309 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + EventId, + ProviderDriverKind, + ThreadId, + TurnId, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; +import type { ProviderAdapterShape } from "../../provider/Services/ProviderAdapter.ts"; +import { makePrismRoutedAdapter } from "./PrismRoutedAdapter.ts"; +import { PRISM_ROUTE_OPTION } from "./PrismRouting.ts"; + +const threadId = ThreadId.make("thread-1"); +const provider = ProviderDriverKind.make("codex"); +const at = "2026-09-04T00:00:00.000Z"; +const start: ProviderSessionStartInput = { + threadId, + provider, + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", +}; +const failure = new ProviderAdapterRequestError({ + provider, + method: "turn", + detail: "test failure", +}); +const fake = Effect.fn("test.fakeAdapter")(function* (name: string, reject = false) { + const events = yield* PubSub.unbounded(); + const sent = yield* Queue.unbounded(); + const starts: ProviderSessionStartInput[] = []; + const turns: ProviderSendTurnInput[] = []; + const active = new Map(); + let stops = 0; + const adapter: ProviderAdapterShape = { + provider, + capabilities: { sessionModelSwitch: "in-session" }, + streamEvents: Stream.fromPubSub(events), + startSession: (input) => + Effect.sync(() => { + starts.push(input); + const session: ProviderSession = { + ...start, + threadId: input.threadId, + provider, + status: "ready", + createdAt: at, + updatedAt: at, + resumeCursor: input.resumeCursor ?? { sessionId: "native-history" }, + }; + active.set(input.threadId, session); + return session; + }), + sendTurn: (input) => + Effect.gen(function* () { + turns.push(input); + yield* Queue.offer(sent, input); + if (reject) return yield* failure; + return { threadId: input.threadId, turnId: TurnId.make(name) }; + }), + stopSession: (id) => + Effect.sync(() => { + stops++; + active.delete(id); + }), + stopAll: () => Effect.sync(() => active.clear()), + listSessions: () => Effect.sync(() => [...active.values()]), + hasSession: (id) => Effect.sync(() => active.has(id)), + interruptTurn: () => Effect.void, + respondToRequest: () => Effect.void, + respondToUserInput: () => Effect.void, + readThread: (id) => Effect.succeed({ threadId: id, turns: [] }), + rollbackThread: (id) => Effect.succeed({ threadId: id, turns: [] }), + }; + const complete = (state: "completed" | "failed" | "cancelled" = "completed") => + PubSub.publish(events, { + eventId: EventId.make(`${name}-${state}`), + provider, + threadId, + createdAt: at, + turnId: TurnId.make(name), + type: "turn.completed", + payload: { state }, + }); + return { adapter, starts, turns, sent, complete, stops: () => stops }; +}); + +const observe = Effect.fn("test.observe")(function* ( + adapter: Pick, "streamEvents">, +) { + const queue = yield* Queue.unbounded(); + yield* Stream.runForEach(adapter.streamEvents, (event) => Queue.offer(queue, event)).pipe( + Effect.forkScoped({ startImmediately: true }), + ); + return queue; +}); + +it.layer(NodeServices.layer)("Prism routed adapter", (it) => { + it.effect( + "flags off and explicit direct both avoid creating a proxy and strip the routing option", + () => + Effect.gen(function* () { + for (const enabled of [false, true]) { + const direct = yield* fake("direct"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => enabled, + proxy: () => Effect.die("proxy must not be created"), + }); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "test-model", + options: [{ id: PRISM_ROUTE_OPTION, value: "direct" }], + }; + yield* adapter.startSession({ ...start, ...(enabled ? { modelSelection } : {}) }); + yield* adapter.sendTurn({ threadId, input: "hello", modelSelection }); + assert.equal(direct.turns.length, 1); + assert.deepEqual(direct.turns[0]?.modelSelection?.options, []); + yield* adapter.stopAll(); + assert.deepEqual(yield* adapter.listSessions(), []); + } + }), + ); + + it.effect("an unavailable gateway starts directly with local credentials", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(undefined), + }); + yield* adapter.startSession(start); + const result = yield* adapter.sendTurn({ threadId, input: "hello" }); + assert.equal(result.turnId, "direct"); + }), + ); + + it.effect( + "a failed Prism turn resumes native history and emits one logical completion after a direct retry", + () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + const result = yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.complete("failed"); + yield* Queue.take(direct.sent); + yield* direct.complete(); + const warning = yield* Queue.take(events); + assert.equal(warning.type, "runtime.warning"); + const completion = yield* Queue.take(events); + assert.equal(completion.type, "turn.completed"); + assert.equal(completion.turnId, result.turnId); + assert.equal(proxy.stops(), 1); + assert.deepEqual(direct.starts[0]?.resumeCursor, { sessionId: "native-history" }); + assert.equal(direct.turns.length, 1); + }), + ); + + it.effect("direct failure terminates the turn without looping back to Prism", () => + Effect.gen(function* () { + const direct = yield* fake("direct", true); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.complete("failed"); + yield* Queue.take(events); + const completion = yield* Queue.take(events); + assert.equal(completion.type, "turn.completed"); + if (completion.type === "turn.completed") assert.equal(completion.payload.state, "failed"); + assert.equal(direct.turns.length, 1); + assert.equal(proxy.turns.length, 1); + }), + ); + + it.effect("cancellation never retries and a later turn may choose direct", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* adapter.interruptTurn(threadId); + yield* proxy.complete("failed"); + yield* Queue.take(events); + assert.equal(direct.turns.length, 0); + yield* adapter.sendTurn({ + threadId, + input: "next", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "test-model", + options: [{ id: PRISM_ROUTE_OPTION, value: "direct" }], + }, + }); + assert.equal(direct.turns.length, 1); + assert.equal(proxy.stops(), 1); + }), + ); + + it.effect("a synchronous Prism failure retries directly once", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy", true); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + yield* adapter.startSession(start); + const result = yield* adapter.sendTurn({ threadId, input: "hello" }); + assert.equal(result.turnId, "direct"); + assert.equal(direct.turns.length, 1); + assert.equal(proxy.turns.length, 1); + }), + ); + it.effect("an early failed event and send rejection share one direct retry", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const release = yield* Deferred.make(); + const originalSend = proxy.adapter.sendTurn; + const earlyProxy = { + ...proxy.adapter, + sendTurn: (input: ProviderSendTurnInput) => + originalSend(input).pipe( + Effect.andThen(proxy.complete("failed")), + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.fail(failure)), + ), + }; + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(earlyProxy), + }); + yield* adapter.startSession(start); + const sending = yield* adapter.sendTurn({ threadId, input: "hello" }).pipe(Effect.forkChild); + yield* Queue.take(direct.sent); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(sending); + assert.equal(result.turnId, "proxy"); + assert.equal(direct.turns.length, 1); + }), + ); + + it.effect("stopAll during fallback startup cannot launch another turn or orphan a session", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy", true); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const originalStart = direct.adapter.startSession; + const delayedDirect = { + ...direct.adapter, + startSession: (input: ProviderSessionStartInput) => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(originalStart(input)), + ), + }; + const adapter = yield* makePrismRoutedAdapter({ + direct: delayedDirect, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + yield* adapter.startSession(start); + const sending = yield* adapter + .sendTurn({ threadId, input: "hello" }) + .pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(entered); + yield* adapter.stopAll(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(sending); + assert.equal(direct.turns.length, 0); + assert.deepEqual(yield* direct.adapter.listSessions(), []); + assert.isFalse(yield* adapter.hasSession(threadId)); + }), + ); +}); diff --git a/apps/server/src/fork/prism/PrismRoutedAdapter.ts b/apps/server/src/fork/prism/PrismRoutedAdapter.ts new file mode 100644 index 000000000000..e23dd325cbc3 --- /dev/null +++ b/apps/server/src/fork/prism/PrismRoutedAdapter.ts @@ -0,0 +1,335 @@ +import { + EventId, + type ThreadId, + type TurnId, + type ProviderRuntimeEvent, + type ProviderSessionStartInput, + type ProviderSendTurnInput, + type ProviderTurnStartResult, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { + ProviderAdapterSessionNotFoundError, + ProviderAdapterRequestError, + type ProviderAdapterError, +} from "../../provider/Errors.ts"; +import type { ProviderAdapterShape } from "../../provider/Services/ProviderAdapter.ts"; +import { prismRoute, withoutPrismRoute } from "./PrismRouting.ts"; + +type Adapter = ProviderAdapterShape; +interface SessionRoute { + adapter: Adapter; + start: ProviderSessionStartInput; + turn?: { + input: ProviderSendTurnInput; + logicalId?: TurnId; + nativeId?: TurnId; + retried: boolean; + cancelled: boolean; + retryDone: Deferred.Deferred; + }; +} + +/** One provider instance, with routing kept at the adapter boundary and one logical turn across fallback. */ +export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* (input: { + direct: Adapter; + enabled: () => boolean; + proxy: () => Effect.Effect; +}) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const nextEventId = crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(EventId.make)); + const events = yield* PubSub.unbounded(); + const sessions = new Map(); + const subscribed = new Set(); + const publish = (event: ProviderRuntimeEvent) => + PubSub.publish(events, event).pipe(Effect.asVoid); + const cleanStart = (start: ProviderSessionStartInput) => ({ + ...start, + modelSelection: withoutPrismRoute(start.modelSelection), + }); + const cleanTurn = (turn: ProviderSendTurnInput) => ({ + ...turn, + modelSelection: withoutPrismRoute(turn.modelSelection), + }); + const routeFor = (threadId: ThreadId) => + Effect.suspend(() => { + const route = sessions.get(threadId); + return route + ? Effect.succeed(route) + : Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: input.direct.provider, threadId }), + ); + }); + + const move = Effect.fn("prism.moveSession")(function* (route: SessionRoute, adapter: Adapter) { + if (route.adapter === adapter) return; + const old = route.adapter; + const current = (yield* old.listSessions()).find( + (session) => session.threadId === route.start.threadId, + ); + // Select the new source before stopping the old one, so its exit cannot end the retry. + route.adapter = adapter; + yield* old.stopSession(route.start.threadId); + yield* adapter.startSession( + cleanStart({ + ...route.start, + ...(current?.resumeCursor !== undefined ? { resumeCursor: current.resumeCursor } : {}), + }), + ); + if (sessions.get(route.start.threadId) !== route) + yield* adapter.stopSession(route.start.threadId); + }); + + const warning = Effect.fn("prism.warning")(function* (route: SessionRoute, message: string) { + yield* publish({ + type: "runtime.warning", + eventId: yield* nextEventId, + provider: input.direct.provider, + threadId: route.start.threadId, + ...(route.start.providerInstanceId + ? { providerInstanceId: route.start.providerInstanceId } + : {}), + ...(route.turn?.logicalId ? { turnId: route.turn.logicalId } : {}), + createdAt: DateTime.formatIso(yield* DateTime.now), + payload: { message }, + }); + }); + + const retryDirect = Effect.fn("prism.retryDirect")(function* (route: SessionRoute) { + const turn = route.turn; + if (!turn || turn.cancelled) return undefined; + if (turn.retried) return yield* Deferred.await(turn.retryDone); + if (route.adapter === input.direct) return undefined; + turn.retried = true; + return yield* Effect.gen(function* () { + yield* warning(route, "Prism failed. Retrying once with local direct-provider credentials."); + yield* move(route, input.direct); + if (turn.cancelled || sessions.get(route.start.threadId) !== route) return undefined; + delete turn.nativeId; + const result = yield* input.direct.sendTurn(cleanTurn(turn.input)); + turn.nativeId = result.turnId; + turn.logicalId ??= result.turnId; + return { ...result, turnId: turn.logicalId }; + }).pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.done(turn.retryDone, exit)), + Effect.andThen(Deferred.await(turn.retryDone)), + ); + }); + + const receive = Effect.fn("prism.receive")(function* ( + source: Adapter, + event: ProviderRuntimeEvent, + ) { + const route = sessions.get(event.threadId); + if (!route || route.adapter !== source) return; + const turn = route.turn; + if (turn && event.turnId !== undefined) { + if (turn.nativeId !== undefined && turn.nativeId !== event.turnId) return; + turn.logicalId ??= event.turnId; + turn.nativeId = event.turnId; + } + const failed = + event.type === "runtime.error" || + event.type === "session.exited" || + (event.type === "turn.completed" && + (event.payload.state === "failed" || event.payload.state === "interrupted")); + if (failed && turn && !turn.cancelled && !turn.retried && source !== input.direct) { + const retried = yield* retryDirect(route).pipe(Effect.catch(() => Effect.succeed(undefined))); + if (retried !== undefined) return; + // A failed direct start still produces a terminal event for the original turn. + yield* publish({ + type: "turn.completed", + eventId: yield* nextEventId, + provider: event.provider, + threadId: event.threadId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), + ...(turn.logicalId ? { turnId: turn.logicalId } : {}), + payload: { + state: turn.cancelled ? "cancelled" : "failed", + errorMessage: "Prism and local direct-provider fallback did not complete the turn.", + }, + }); + delete route.turn; + return; + } + const mapped = turn?.logicalId && event.turnId ? { ...event, turnId: turn.logicalId } : event; + yield* publish(mapped); + if (event.type === "turn.completed" || event.type === "turn.aborted") delete route.turn; + }); + + const subscribe = Effect.fn("prism.subscribeAdapter")(function* (adapter: Adapter) { + if (subscribed.has(adapter)) return; + subscribed.add(adapter); + yield* Stream.runForEach(adapter.streamEvents, (event) => receive(adapter, event)).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ); + }); + yield* subscribe(input.direct); + const select = Effect.fn("prism.selectAdapter")(function* ( + selection: ProviderSessionStartInput["modelSelection"], + ) { + if (!input.enabled() || prismRoute(selection) === "direct") return input.direct; + const proxy = yield* input.proxy(); + if (!proxy) return input.direct; + yield* subscribe(proxy); + return proxy; + }); + + const adapter: Adapter = { + ...input.direct, + streamEvents: Stream.fromPubSub(events), + startSession: Effect.fn("prism.startSession")(function* (start) { + const selected = yield* select(start.modelSelection).pipe( + Effect.catch(() => Effect.succeed(input.direct)), + ); + // Install before starting: native adapters can emit their first event during startSession. + const route: SessionRoute = { adapter: selected, start }; + sessions.set(start.threadId, route); + if ( + selected === input.direct && + input.enabled() && + prismRoute(start.modelSelection) === "prism" + ) { + yield* warning(route, "Prism is unavailable. Using local direct-provider credentials."); + } + const result = yield* selected.startSession(cleanStart(start)).pipe( + Effect.catch((error) => { + if (selected === input.direct) return Effect.fail(error); + route.adapter = input.direct; + return selected + .stopSession(start.threadId) + .pipe(Effect.ignore, Effect.andThen(input.direct.startSession(cleanStart(start)))); + }), + Effect.tapError(() => + Effect.sync(() => { + sessions.delete(start.threadId); + }), + ), + ); + return result; + }), + sendTurn: Effect.fn("prism.sendTurn")(function* (turnInput) { + const route = yield* routeFor(turnInput.threadId); + const selection = turnInput.modelSelection ?? route.start.modelSelection; + route.start = { ...route.start, modelSelection: selection }; + const selected = yield* select(selection).pipe( + Effect.catch(() => Effect.succeed(input.direct)), + ); + yield* move(route, selected).pipe( + Effect.catch((error) => { + if (selected === input.direct) return Effect.fail(error); + return warning( + route, + "Prism could not start. Using local direct-provider credentials.", + ).pipe(Effect.andThen(move(route, input.direct))); + }), + ); + const turn: NonNullable = { + input: turnInput, + retried: false, + cancelled: false, + retryDone: yield* Deferred.make< + ProviderTurnStartResult | undefined, + ProviderAdapterError + >(), + }; + route.turn = turn; + const result = yield* route.adapter + .sendTurn(cleanTurn(turnInput)) + .pipe( + Effect.catch((error) => + retryDirect(route).pipe( + Effect.flatMap((result) => (result ? Effect.succeed(result) : Effect.fail(error))), + ), + ), + ); + const completedStart = turn.retried + ? ((yield* Deferred.await(turn.retryDone)) ?? result) + : result; + turn.logicalId ??= completedStart.turnId; + if (!turn.retried) turn.nativeId = result.turnId; + return { ...completedStart, turnId: turn.logicalId }; + }), + interruptTurn: (id) => + routeFor(id).pipe( + Effect.flatMap((route) => { + if (route.turn) route.turn.cancelled = true; + return route.adapter.interruptTurn(id, route.turn?.nativeId); + }), + ), + stopSession: (id) => + routeFor(id).pipe( + Effect.flatMap((route) => { + if (route.turn) route.turn.cancelled = true; + sessions.delete(id); + return route.adapter.stopSession(id); + }), + ), + stopAll: () => + Effect.gen(function* () { + for (const route of sessions.values()) { + if (route.turn) route.turn.cancelled = true; + } + sessions.clear(); + yield* Effect.forEach(subscribed, (adapter) => adapter.stopAll(), { discard: true }); + }), + listSessions: () => + Effect.forEach(subscribed, (adapter) => adapter.listSessions()).pipe( + Effect.map((groups) => groups.flat().filter((session) => sessions.has(session.threadId))), + ), + hasSession: (id) => Effect.sync(() => sessions.has(id)), + readThread: (id) => routeFor(id).pipe(Effect.flatMap((route) => route.adapter.readThread(id))), + rollbackThread: (id, count) => + routeFor(id).pipe(Effect.flatMap((route) => route.adapter.rollbackThread(id, count))), + respondToRequest: (id, requestId, decision) => + routeFor(id).pipe( + Effect.flatMap((route) => route.adapter.respondToRequest(id, requestId, decision)), + ), + respondToUserInput: (id, requestId, answers) => + routeFor(id).pipe( + Effect.flatMap((route) => route.adapter.respondToUserInput(id, requestId, answers)), + ), + ...(input.direct.compaction + ? { + compaction: + input.direct.compaction.type === "slash-command" + ? input.direct.compaction + : { + type: "native" as const, + start: (id: ThreadId, selection?: ProviderSendTurnInput["modelSelection"]) => + routeFor(id).pipe( + Effect.flatMap((route) => + route.adapter.compaction?.type === "native" + ? route.adapter.compaction.start(id, withoutPrismRoute(selection)) + : Effect.fail( + new ProviderAdapterRequestError({ + provider: route.adapter.provider, + method: "thread/compact", + detail: "The routed provider does not support native compaction.", + }), + ), + ), + ), + }, + } + : {}), + ...(input.direct.uploadFeedback + ? { + uploadFeedback: (feedback: Parameters>[0]) => + routeFor(feedback.threadId).pipe( + Effect.flatMap((route) => route.adapter.uploadFeedback!(feedback)), + ), + } + : {}), + }; + return adapter; +}); diff --git a/apps/server/src/fork/prism/PrismRouting.ts b/apps/server/src/fork/prism/PrismRouting.ts new file mode 100644 index 000000000000..e05831631840 --- /dev/null +++ b/apps/server/src/fork/prism/PrismRouting.ts @@ -0,0 +1,47 @@ +import type { ModelSelection, ProviderOptionDescriptor, ServerProvider } from "@t3tools/contracts"; + +export const PRISM_ROUTE_OPTION = "prism-route"; +export const PRISM_ROUTE_DESCRIPTOR = { + id: PRISM_ROUTE_OPTION, + label: "Connection", + type: "select", + options: [ + { id: "prism", label: "Prism pool", isDefault: true }, + { id: "direct", label: "Direct provider" }, + ], +} satisfies ProviderOptionDescriptor; + +export const prismRoute = (selection: ModelSelection | undefined): "prism" | "direct" => + selection?.options?.find((option) => option.id === PRISM_ROUTE_OPTION)?.value === "direct" + ? "direct" + : "prism"; + +/** This option controls q1code routing and must never be sent to a provider CLI. */ +export const withoutPrismRoute = ( + selection: ModelSelection | undefined, +): ModelSelection | undefined => + selection?.options?.some((option) => option.id === PRISM_ROUTE_OPTION) + ? { + ...selection, + options: selection.options.filter((option) => option.id !== PRISM_ROUTE_OPTION), + } + : selection; + +export const withPrismRouteOption = (snapshot: ServerProvider, enabled: boolean): ServerProvider => + !enabled + ? snapshot + : { + ...snapshot, + models: snapshot.models.map((model) => ({ + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: [ + ...(model.capabilities?.optionDescriptors ?? []).filter( + (option) => option.id !== PRISM_ROUTE_OPTION, + ), + PRISM_ROUTE_DESCRIPTOR, + ], + }, + })), + }; diff --git a/apps/server/src/fork/prism/PrismService.test.ts b/apps/server/src/fork/prism/PrismService.test.ts new file mode 100644 index 000000000000..9f6a773b7948 --- /dev/null +++ b/apps/server/src/fork/prism/PrismService.test.ts @@ -0,0 +1,689 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_FORK_FLAGS, type ForkFlagValues } from "@q1code/core/flags"; +import type { PrismExternalConfig, ForkConfig } from "@q1code/core/config"; +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/unstable/http"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import { ForkFlagsService } from "../ForkFlags.ts"; +import { PrismBinary } from "./PrismBinary.ts"; +import { prismDirectories } from "./PrismConfig.ts"; +import { + currentPrismEndpoint, + prismUsageLimitSource, + prismUsageSourceChanges, +} from "./PrismEnvironment.ts"; +import { + type PrismChild, + PrismHealthInterval, + PrismLauncher, + PrismNotReady, + PrismProbeFailed, + PrismReadiness, + PrismRestartSchedule, + PrismService, + type PrismStatus, + layerWithoutRuntime, + parsePrismBaseUrl, + readinessLayer, + redactSecrets, +} from "./PrismService.ts"; + +it.effect("readiness uses authenticated local state when the release service is unavailable", () => + Effect.gen(function* () { + const requests: Array<{ url: string; authorization: string | null }> = []; + const fetch: typeof globalThis.fetch = Object.assign( + async (input: Parameters[0], init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + requests.push({ url, authorization: new Headers(init?.headers).get("authorization") }); + return new URL(url).pathname === "/v0/management/routing/strategy" + ? Response.json({ strategy: "round-robin" }) + : Response.json({ error: "release service unavailable" }, { status: 502 }); + }, + { preconnect: () => {} }, + ); + yield* Effect.gen(function* () { + const readiness = yield* PrismReadiness; + yield* readiness.probe({ + baseUrl: "https://proxy.example.test", + managementSecret: "test-secret", + }); + }).pipe(Effect.provide(readinessLayer), Effect.provideService(FetchHttpClient.Fetch, fetch)); + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, "https://proxy.example.test/v0/management/routing/strategy"); + assert.equal(requests[0]?.authorization, "Bearer test-secret"); + }), +); + +it.effect("readiness rejects an invalid management secret", () => + Effect.gen(function* () { + const readiness = yield* PrismReadiness; + const result = yield* readiness + .probe({ + baseUrl: "https://proxy.example.test", + managementSecret: "test-secret", + }) + .pipe(Effect.flip); + assert.include(result.detail, "HTTP 401"); + assert.include(result.detail, "check the management secret"); + assert.notInclude(result.detail, "test-secret"); + }).pipe( + Effect.provide(readinessLayer), + Effect.provideService( + FetchHttpClient.Fetch, + Object.assign(async () => new Response(null, { status: 401 }), { preconnect: () => {} }), + ), + ), +); + +interface FakeLaunch { + readonly pid: number; + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly exit: Deferred.Deferred; + killed: boolean; +} + +/** A ForkFlagsService whose `changes` the test drives through `set`. */ +const makeFlags = (initial: boolean, config: ForkConfig = {}) => { + let current: ForkFlagValues = { ...DEFAULT_FORK_FLAGS, prism: initial }; + let currentConfig = config; + let publish: (values: ForkFlagValues) => Effect.Effect = () => Effect.void; + const layer = Layer.effect( + ForkFlagsService, + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + publish = (values) => PubSub.publish(pubsub, values).pipe(Effect.asVoid); + return ForkFlagsService.of({ + current: Effect.sync(() => current), + reload: Effect.sync(() => current), + changes: Stream.fromPubSub(pubsub), + config: Effect.sync(() => currentConfig), + update: () => Effect.die("unexpected fork.json update"), + }); + }), + ); + const set = (prism: boolean) => + Effect.suspend(() => { + current = { ...current, prism }; + return publish(current); + }); + /** A hand edit of fork.json: the config moves without a flag change, so `changes` stays quiet. */ + const setConfig = (next: ForkConfig) => + Effect.sync(() => { + currentConfig = next; + }); + return { layer, set, setConfig }; +}; + +const makeHarness = (options: { + readonly flag: boolean; + readonly config?: ForkConfig; + /** Consumed one per readiness call; `Effect.void` once exhausted. */ + readonly readiness?: Array>; + /** Consumed one per external probe; `Effect.void` once exhausted. */ + readonly probes?: Array>; + /** Stored before the service starts, as external mode expects. */ + readonly secrets?: Record; + readonly output?: ReadonlyArray; +}) => { + const launches: Array = []; + const probeInputs: Array<{ baseUrl: string; managementSecret: string }> = []; + const flags = makeFlags(options.flag, options.config); + const readinessScript = [...(options.readiness ?? [])]; + const probeScript = [...(options.probes ?? [])]; + const launcher = Layer.succeed( + PrismLauncher, + PrismLauncher.of({ + launch: (input) => + Effect.gen(function* () { + const exit = yield* Deferred.make(); + const record: FakeLaunch = { + pid: 1000 + launches.length, + binaryPath: input.binaryPath, + args: input.args, + exit, + killed: false, + }; + launches.push(record); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + record.killed = true; + }).pipe(Effect.andThen(Deferred.succeed(exit, 143)), Effect.asVoid), + ); + return { + pid: record.pid, + output: Stream.fromIterable(options.output ?? []), + exit: Deferred.await(exit), + } satisfies PrismChild; + }), + }), + ); + const readiness = Layer.succeed( + PrismReadiness, + PrismReadiness.of({ + awaitReady: () => readinessScript.shift() ?? Effect.void, + probe: (input) => + Effect.suspend(() => { + probeInputs.push(input); + return probeScript.shift() ?? Effect.void; + }), + }), + ); + const seededSecrets = Layer.effectDiscard( + Effect.gen(function* () { + const store = yield* ServerSecretStore.ServerSecretStore; + for (const [name, value] of Object.entries(options.secrets ?? {})) { + yield* store.set(name, new TextEncoder().encode(value)); + } + }), + ); + const binary = Layer.succeed( + PrismBinary, + PrismBinary.of({ + resolve: () => + Effect.succeed({ path: "/fake/cli-proxy-api", version: "7.2.147", source: "override" }), + }), + ); + const layer = layerWithoutRuntime.pipe( + Layer.provide(seededSecrets), + Layer.provide(flags.layer), + Layer.provide(binary), + Layer.provide(launcher), + Layer.provide(readiness), + Layer.provide(Layer.succeed(PrismRestartSchedule, Schedule.recurs(5))), + Layer.provide(Layer.succeed(PrismHealthInterval, Duration.millis(10))), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "q1code-prism-service-" })), + ), + ); + return { layer, launches, probeInputs, setFlag: flags.set, setConfig: flags.setConfig }; +}; + +const isIsoTimestamp = (value: string | undefined) => + value !== undefined && Number.isFinite(Date.parse(value)); + +const EXTERNAL_BASE_URL = "http://127.0.0.1:9317"; +const EXTERNAL_SECRETS = { + "prism-management-secret": "mgmt-secret\n", + "prism-api-key": "client-key", +}; +const externalConfig = (external: PrismExternalConfig | undefined): ForkConfig => ({ + prism: { mode: "external", ...(external === undefined ? {} : { external }) }, +}); + +const awaitStatus = ( + service: PrismService["Service"], + predicate: (status: PrismStatus) => boolean, +) => + Effect.gen(function* () { + const now = yield* service.status; + if (predicate(now)) return now; + const [next] = yield* service.changes.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runCollect, + ); + return next!; + }); + +/** + * Fork an `awaitStatus` and let the child run up to its PubSub subscription + * before returning, so a transition triggered right after cannot be missed. + */ +const subscribeStatus = ( + service: PrismService["Service"], + predicate: (status: PrismStatus) => boolean, +) => + awaitStatus(service, predicate).pipe( + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + +const readApiKey = Effect.fn("test.readApiKey")(function* (configPath: string) { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(configPath); + const match = /^ {2}- "([^"]+)"$/m.exec(text); + return { text, apiKey: match?.[1] }; +}); + +it("redacts every secret it is given", () => { + assert.equal( + redactSecrets("key=abc token=xyz", ["abc", "xyz", ""]), + "key=[redacted] token=[redacted]", + ); +}); + +it("parses an external base URL as a bare http(s) origin with its effective port", () => { + assert.deepEqual(parsePrismBaseUrl(" http://127.0.0.1:8317/ "), { + baseUrl: "http://127.0.0.1:8317", + port: 8317, + }); + assert.deepEqual(parsePrismBaseUrl("https://proxy.internal"), { + baseUrl: "https://proxy.internal", + port: 443, + }); + assert.deepEqual(parsePrismBaseUrl("http://proxy"), { baseUrl: "http://proxy", port: 80 }); + for (const bad of [ + "127.0.0.1:8317", + "http://127.0.0.1:8317/v1", + "http://127.0.0.1:8317?x=1", + "http://user:pw@127.0.0.1:8317", + "ws://127.0.0.1:8317", + "", + ]) { + assert.isUndefined(parsePrismBaseUrl(bad), bad); + } +}); + +// Restarts go through `Effect.retry`, which sleeps on the clock even for a zero +// delay; the schedule here has no delay, so run against the live clock. +it.layer(NodeServices.layer, { excludeTestServices: true })("PrismService", (it) => { + it.effect("spawns nothing and writes nothing while the flag is off", () => + Effect.gen(function* () { + const harness = makeHarness({ flag: false }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + assert.equal((yield* service.status).state, "off"); + assert.isTrue(Option.isNone(yield* service.endpoint)); + assert.equal(currentPrismEndpoint(), undefined); + assert.isFalse(yield* fs.exists(prismDirectories(baseDir, path).rootDir)); + const request = yield* service.management.request("/latest-version").pipe(Effect.exit); + assert.isTrue(Exit.isFailure(request)); + }).pipe(Effect.provide(harness.layer)); + assert.deepEqual(harness.launches, []); + }), + ); + + it.effect("starts the sidecar, waits for readiness, and publishes the endpoint", () => + Effect.gen(function* () { + const gate = Deferred.makeUnsafe(); + const harness = makeHarness({ + flag: true, + config: { prism: { port: 9123, routingStrategy: "fill-first" } }, + readiness: [Deferred.await(gate)], + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + const directories = prismDirectories(baseDir, path); + + const starting = yield* awaitStatus( + service, + (s) => s.state === "starting" && s.pid !== undefined, + ); + assert.equal(starting.port, 9123); + assert.equal(starting.version, "7.2.147"); + assert.equal(starting.mode, "sidecar"); + assert.equal(starting.restarts, 0); + assert.isUndefined(starting.baseUrl); + assert.isTrue(isIsoTimestamp(starting.since)); + assert.equal(harness.launches.length, 1); + assert.deepEqual(harness.launches[0]?.args, ["-config", directories.configPath]); + assert.equal(harness.launches[0]?.binaryPath, "/fake/cli-proxy-api"); + assert.isTrue(Option.isNone(yield* service.endpoint)); + + const config = yield* readApiKey(directories.configPath); + assert.include(config.text, "\nport: 9123\n"); + assert.include(config.text, '\n strategy: "fill-first"\n'); + assert.include(config.text, `\nauth-dir: "${directories.authsDir}"\n`); + assert.equal((yield* fs.stat(directories.configPath)).mode & 0o777, 0o600); + assert.equal((yield* fs.stat(directories.rootDir)).mode & 0o777, 0o700); + assert.equal((yield* fs.stat(directories.authsDir)).mode & 0o777, 0o700); + assert.isString(config.apiKey); + + const ready = yield* subscribeStatus(service, (s) => s.state === "ready"); + yield* Deferred.succeed(gate, undefined); + const status = yield* Fiber.join(ready); + assert.equal(status.pid, harness.launches[0]?.pid); + assert.equal(status.baseUrl, "http://127.0.0.1:9123"); + assert.isUndefined(status.lastError); + assert.isTrue(status.usageSource); + const endpoint = Option.getOrUndefined(yield* service.endpoint); + assert.isDefined(endpoint); + assert.equal(endpoint.baseUrl, "http://127.0.0.1:9123"); + assert.equal(endpoint.apiKey, config.apiKey); + assert.isTrue(endpoint.usageSource); + // The management secret the sidecar was configured with rides along, for the usage-limit source. + assert.include(config.text, `\n secret-key: "${endpoint.managementSecret}"\n`); + assert.deepEqual(currentPrismEndpoint(), endpoint); + assert.deepEqual(prismUsageLimitSource()?.[1], { + kind: "cliproxy", + label: "Prism", + url: "http://127.0.0.1:9123", + managementKey: endpoint.managementSecret, + enabled: true, + }); + assert.equal(service.codexProxyHomePath, directories.codexHomeDir); + const codexConfig = yield* fs.readFileString( + path.join(directories.codexHomeDir, "config.toml"), + ); + assert.include(codexConfig, 'base_url = "http://127.0.0.1:9123/v1"'); + assert.include(codexConfig, `Bearer ${config.apiKey}`); + }).pipe(Effect.provide(harness.layer)); + // Layer teardown killed the child and unpublished the endpoint. + assert.isTrue(harness.launches[0]?.killed); + assert.equal(currentPrismEndpoint(), undefined); + }), + ); + + it.effect("restarts after the child exits and after a failed readiness probe", () => + Effect.gen(function* () { + const harness = makeHarness({ + flag: true, + readiness: [Effect.fail(new PrismNotReady({ port: 8317, stage: "tcp" }))], + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + // Attempt 1 fails readiness; attempt 2 is ready. + const ready = yield* awaitStatus(service, (s) => s.state === "ready" && s.pid === 1001); + assert.equal(harness.launches.length, 2); + assert.isTrue(harness.launches[0]?.killed); + assert.equal(ready.pid, 1001); + assert.equal(ready.restarts, 1); + // The failed first attempt is remembered until the proxy is ready. + assert.isUndefined(ready.lastError); + + const failed = yield* subscribeStatus(service, (s) => s.state === "failed"); + yield* Deferred.succeed(harness.launches[1]!.exit, 2); + const failure = yield* Fiber.join(failed); + assert.include(failure.lastError, "exited with code 2"); + assert.equal(failure.pid, undefined); + assert.isUndefined(failure.baseUrl); + assert.isTrue(Option.isNone(yield* service.endpoint)); + + const again = yield* awaitStatus(service, (s) => s.state === "ready" && s.pid === 1002); + assert.equal(again.pid, 1002); + assert.equal(again.restarts, 2); + assert.equal(harness.launches.length, 3); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("stops on flag off, clears the endpoint, and starts again on flag on", () => + Effect.gen(function* () { + const harness = makeHarness({ flag: true }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + yield* awaitStatus(service, (s) => s.state === "ready"); + assert.isDefined(currentPrismEndpoint()); + + const off = yield* subscribeStatus(service, (s) => s.state === "off"); + yield* harness.setFlag(false); + yield* Fiber.join(off); + assert.isTrue(harness.launches[0]?.killed); + assert.isTrue(Option.isNone(yield* service.endpoint)); + assert.equal(currentPrismEndpoint(), undefined); + assert.equal(harness.launches.length, 1); + + const ready = yield* subscribeStatus(service, (s) => s.state === "ready" && s.pid === 1001); + yield* harness.setFlag(true); + yield* Fiber.join(ready); + assert.equal(harness.launches.length, 2); + assert.isFalse(harness.launches[1]?.killed); + }).pipe(Effect.provide(harness.layer)); + assert.isTrue(harness.launches[1]?.killed); + }), + ); + it.effect("restart respawns the sidecar now and counts it", () => + Effect.gen(function* () { + const harness = makeHarness({ flag: true }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const first = yield* awaitStatus(service, (s) => s.state === "ready"); + assert.equal(first.pid, 1000); + assert.equal(first.restarts, 0); + + const status = yield* service.restart; + assert.equal(status.state, "ready"); + assert.equal(status.pid, 1001); + assert.equal(status.restarts, 1); + assert.isTrue(isIsoTimestamp(status.since)); + assert.isTrue(harness.launches[0]?.killed); + assert.equal(harness.launches.length, 2); + assert.isFalse(harness.launches[1]?.killed); + assert.deepEqual(yield* service.status, status); + assert.isDefined(currentPrismEndpoint()); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("carries the usage-source toggle from fork.json and republishes it on reload", () => + Effect.gen(function* () { + const harness = makeHarness({ flag: true, config: { prism: { usageSource: false } } }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const ready = yield* awaitStatus(service, (s) => s.state === "ready"); + assert.isFalse(ready.usageSource); + assert.isFalse(currentPrismEndpoint()?.usageSource); + assert.isUndefined(prismUsageLimitSource()); + + const emitted = yield* prismUsageSourceChanges.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + yield* harness.setConfig({ prism: { usageSource: true } }); + const status = yield* service.reloadUsageSource; + assert.isTrue(status.usageSource); + assert.equal(status.state, "ready"); + assert.isTrue(currentPrismEndpoint()?.usageSource); + assert.equal(prismUsageLimitSource()?.[1].url, "http://127.0.0.1:8317"); + assert.equal( + prismUsageLimitSource()?.[1].managementKey, + currentPrismEndpoint()?.managementSecret, + ); + const [entry] = yield* Fiber.join(emitted); + assert.equal(entry?.[0], "prism"); + assert.deepEqual(yield* service.status, status); + + // The same proxy, restarted, keeps the toggle it read from the file. + const restarted = yield* service.restart; + assert.isTrue(restarted.usageSource); + assert.isTrue(currentPrismEndpoint()?.usageSource); + }).pipe(Effect.provide(harness.layer)); + assert.isUndefined(prismUsageLimitSource()); + }), + ); + + it.effect("external mode probes the configured proxy and publishes it without spawning", () => + Effect.gen(function* () { + const harness = makeHarness({ + flag: true, + config: externalConfig({ baseUrl: `${EXTERNAL_BASE_URL}/` }), + secrets: EXTERNAL_SECRETS, + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + const directories = prismDirectories(baseDir, path); + + const ready = yield* awaitStatus(service, (s) => s.state === "ready"); + assert.equal(ready.mode, "external"); + assert.equal(ready.port, 9317); + assert.equal(ready.baseUrl, EXTERNAL_BASE_URL); + assert.equal(ready.restarts, 0); + assert.isUndefined(ready.pid); + assert.isUndefined(ready.version); + assert.isUndefined(ready.lastError); + assert.isTrue(isIsoTimestamp(ready.since)); + assert.deepEqual(harness.probeInputs[0], { + baseUrl: EXTERNAL_BASE_URL, + managementSecret: "mgmt-secret", + }); + + const endpoint = yield* service.endpoint; + assert.deepEqual( + endpoint, + Option.some({ + baseUrl: EXTERNAL_BASE_URL, + apiKey: "client-key", + managementSecret: "mgmt-secret", + usageSource: true, + }), + ); + assert.deepEqual(currentPrismEndpoint(), Option.getOrUndefined(endpoint)); + + // Nothing of the sidecar's: no binary, no config.yaml, no managed auths dir. + assert.deepEqual(harness.launches, []); + assert.isFalse(yield* fs.exists(directories.configPath)); + assert.isFalse(yield* fs.exists(directories.authsDir)); + const codexConfig = yield* fs.readFileString( + path.join(directories.codexHomeDir, "config.toml"), + ); + assert.include(codexConfig, `base_url = "${EXTERNAL_BASE_URL}/v1"`); + assert.include(codexConfig, "Bearer client-key"); + }).pipe(Effect.provide(harness.layer)); + assert.equal(currentPrismEndpoint(), undefined); + }), + ); + + it.effect( + "external mode fails with the secret hint until the secret is stored, then restarts", + () => + Effect.gen(function* () { + const harness = makeHarness({ + flag: true, + config: externalConfig({ baseUrl: EXTERNAL_BASE_URL }), + secrets: { "prism-api-key": "client-key" }, + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const failed = yield* awaitStatus(service, (s) => s.state === "failed"); + assert.equal( + failed.lastError, + 'secret "prism-management-secret" is not set; store it with: q1code fork secret set prism-management-secret', + ); + assert.equal(failed.mode, "external"); + assert.isTrue(Option.isNone(yield* service.endpoint)); + assert.deepEqual(harness.probeInputs, []); + + yield* secrets.set("prism-management-secret", new TextEncoder().encode("late")); + const status = yield* service.restart; + assert.equal(status.state, "ready"); + assert.isUndefined(status.lastError); + assert.equal(harness.probeInputs.at(-1)?.managementSecret, "late"); + assert.isDefined(currentPrismEndpoint()); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect( + "external mode drops to failed on a bad probe and reconnects on the next good one", + () => + Effect.gen(function* () { + const recover = Deferred.makeUnsafe(); + const harness = makeHarness({ + flag: true, + config: externalConfig({ baseUrl: EXTERNAL_BASE_URL }), + secrets: EXTERNAL_SECRETS, + probes: [ + Effect.void, + Effect.fail( + new PrismProbeFailed({ + baseUrl: EXTERNAL_BASE_URL, + detail: "connect ECONNREFUSED", + }), + ), + Deferred.await(recover), + ], + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + yield* awaitStatus(service, (s) => s.state === "ready"); + const failed = yield* awaitStatus(service, (s) => s.state === "failed"); + assert.include(failed.lastError, "connect ECONNREFUSED"); + assert.isUndefined(failed.baseUrl); + assert.isTrue(Option.isNone(yield* service.endpoint)); + assert.equal(currentPrismEndpoint(), undefined); + + const ready = yield* subscribeStatus(service, (s) => s.state === "ready"); + yield* Deferred.succeed(recover, undefined); + const again = yield* Fiber.join(ready); + assert.equal(again.restarts, 1); + assert.equal(again.baseUrl, EXTERNAL_BASE_URL); + assert.isUndefined(again.lastError); + assert.isDefined(currentPrismEndpoint()); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("external mode rejects a missing section or a base URL that is not an origin", () => + Effect.gen(function* () { + const noSection = makeHarness({ + flag: true, + config: externalConfig(undefined), + secrets: EXTERNAL_SECRETS, + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const failed = yield* awaitStatus(service, (s) => s.state === "failed"); + assert.include(failed.lastError, "no prism.external section"); + }).pipe(Effect.provide(noSection.layer)); + + const badUrl = makeHarness({ + flag: true, + config: externalConfig({ baseUrl: "127.0.0.1:8317/v1" }), + secrets: EXTERNAL_SECRETS, + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + const failed = yield* awaitStatus(service, (s) => s.state === "failed"); + assert.include(failed.lastError, "absolute http(s) origin"); + assert.include(failed.lastError, "127.0.0.1:8317/v1"); + }).pipe(Effect.provide(badUrl.layer)); + assert.deepEqual(badUrl.probeInputs, []); + }), + ); + + it.effect("flag off in external mode stops the monitor and clears the endpoint", () => + Effect.gen(function* () { + const harness = makeHarness({ + flag: true, + config: externalConfig({ baseUrl: EXTERNAL_BASE_URL }), + secrets: EXTERNAL_SECRETS, + }); + yield* Effect.gen(function* () { + const service = yield* PrismService; + yield* awaitStatus(service, (s) => s.state === "ready"); + const off = yield* subscribeStatus(service, (s) => s.state === "off"); + yield* harness.setFlag(false); + const status = yield* Fiber.join(off); + assert.equal(status.mode, "external"); + assert.isUndefined(status.baseUrl); + assert.isUndefined(status.lastError); + assert.equal(status.restarts, 0); + assert.isTrue(Option.isNone(yield* service.endpoint)); + assert.equal(currentPrismEndpoint(), undefined); + // With the flag off there is nothing to restart; the status is reported as is. + assert.equal((yield* service.restart).state, "off"); + const probes = harness.probeInputs.length; + yield* Effect.sleep(Duration.millis(30)); + assert.equal(harness.probeInputs.length, probes); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/apps/server/src/fork/prism/PrismService.ts b/apps/server/src/fork/prism/PrismService.ts new file mode 100644 index 000000000000..146cccef693b --- /dev/null +++ b/apps/server/src/fork/prism/PrismService.ts @@ -0,0 +1,836 @@ +/** + * PrismService - CLIProxyAPI as a supervised child of the server, or as an + * externally run proxy the server only manages. + * + * Off by default: nothing is spawned, written, or published unless the + * `prism` fork flag is on. When it is, `fork.json` `prism.mode` decides: + * + * - `sidecar` (default): render `config.yaml`, resolve the binary, spawn + * `cli-proxy-api -config `, wait for the port and the management API, + * publish the endpoint for the provider seams. Exits and failed starts + * restart with capped backoff. + * - `external`: read the management secret and client API key from the secret + * store, probe the configured `baseUrl`'s management API, publish the + * endpoint, and keep probing on a timer; a failed probe drops to `failed` and + * unpublishes, a later success comes back as `ready` (one reconnect). + * + * The flag turning off (or server shutdown) interrupts the supervisor, which + * kills the child or stops the monitor. `restart` interrupts the current run + * so the supervisor starts over right away; in external mode that is a fresh + * secret read and an immediate probe. + * + * The launcher, readiness probe, restart schedule, and health interval are + * services so tests drive the state machine without a binary or a clock. + */ +import * as NodeNet from "node:net"; + +import { PRISM_DEFAULT_PORT, PRISM_MANAGEMENT_PROBE_PATH } from "@q1code/core/prism"; +import { + PRISM_DEFAULT_API_KEY_SECRET_NAME, + PRISM_DEFAULT_MANAGEMENT_SECRET_NAME, + type PrismConfig, + type PrismMode, +} from "@q1code/core/config"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ProcessRunner from "../../processRunner.ts"; +import * as ForkFlags from "../ForkFlags.ts"; +import * as PrismBinary from "./PrismBinary.ts"; +import { prismDirectories, renderPrismConfig, writePrismConfig } from "./PrismConfig.ts"; +import { + type PrismEndpoint, + publishPrismEndpoint, + publishPrismEnabled, +} from "./PrismEnvironment.ts"; +import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; + +export type PrismState = "off" | "starting" | "ready" | "failed"; + +export interface PrismStatus { + readonly state: PrismState; + readonly mode: PrismMode; + /** Sidecar: the loopback port it listens on. External: the port of `baseUrl`. */ + readonly port: number; + /** When the current `state` was entered. */ + readonly since: string; + /** Runs beyond the first since the flag turned on (supervisor restarts, manual restarts) plus external reconnects. */ + readonly restarts: number; + /** Sidecar binary version; unknown for an external proxy. */ + readonly version?: string | undefined; + readonly pid?: number | undefined; + /** The proxy origin provider CLIs are pointed at, only while `ready`. */ + readonly baseUrl?: string | undefined; + /** Last failure message; never carries a secret. Cleared once the proxy is ready. */ + readonly lastError?: string | undefined; + /** `prism.usageSource` (default true): the pooled accounts are published to the Limits view. */ + readonly usageSource: boolean; +} + +export class PrismSpawnError extends Schema.TaggedErrorClass()("PrismSpawnError", { + path: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to spawn CLIProxyAPI at '${this.path}'.`; + } +} + +export class PrismNotReady extends Schema.TaggedErrorClass()("PrismNotReady", { + port: Schema.Number, + stage: Schema.Literals(["tcp", "management"]), +}) { + override get message(): string { + return `CLIProxyAPI on port ${this.port} did not become ready (${this.stage}).`; + } +} + +/** One management probe against an external proxy failed; `detail` is transport or HTTP text with the secret redacted. */ +export class PrismProbeFailed extends Schema.TaggedErrorClass()( + "PrismProbeFailed", + { + baseUrl: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `CLIProxyAPI at ${this.baseUrl} did not answer the management probe: ${this.detail}`; + } +} + +export class PrismExited extends Schema.TaggedErrorClass()("PrismExited", { + code: Schema.Number, +}) { + override get message(): string { + return `CLIProxyAPI exited with code ${this.code}.`; + } +} + +export class PrismSecretError extends Schema.TaggedErrorClass()( + "PrismSecretError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load the Prism secrets."; + } +} + +/** External mode needs a secret the store does not hold; the message says how to add it. */ +export class PrismSecretMissing extends Schema.TaggedErrorClass()( + "PrismSecretMissing", + { + name: Schema.String, + }, +) { + override get message(): string { + return `secret "${this.name}" is not set; store it with: q1code fork secret set ${this.name}`; + } +} + +export class PrismExternalConfigError extends Schema.TaggedErrorClass()( + "PrismExternalConfigError", + { + detail: Schema.String, + }, +) { + override get message(): string { + return this.detail; + } +} + +export class PrismManagementError extends Schema.TaggedErrorClass()( + "PrismManagementError", + { + reason: Schema.Literals(["not-ready", "request-failed"]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "not-ready" + ? `CLIProxyAPI management request to '${this.path}' needs a ready proxy.` + : `CLIProxyAPI management request to '${this.path}' failed.`; + } +} + +export interface PrismManagementRequestOptions { + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined; + readonly headers?: Record | undefined; + /** Sent verbatim; set `content-type` in `headers` for JSON. */ + readonly body?: string | undefined; +} + +export interface PrismChild { + readonly pid: number; + /** Interleaved stdout/stderr lines. */ + readonly output: Stream.Stream; + /** Resolves with the exit code once the process is gone. */ + readonly exit: Effect.Effect; +} + +/** Spawns the binary; the child dies with the scope. Tests provide a fake. */ +export class PrismLauncher extends Context.Service< + PrismLauncher, + { + readonly launch: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly cwd: string; + }) => Effect.Effect; + } +>()("t3/fork/prism/PrismService/PrismLauncher") {} + +/** + * `awaitReady` blocks until a freshly spawned sidecar answers on its port and + * its management API; `probe` is one management call against any proxy origin. + * Tests provide a fake. + */ +export class PrismReadiness extends Context.Service< + PrismReadiness, + { + readonly awaitReady: (input: { + readonly port: number; + readonly managementSecret: string; + }) => Effect.Effect; + readonly probe: (input: { + readonly baseUrl: string; + readonly managementSecret: string; + }) => Effect.Effect; + } +>()("t3/fork/prism/PrismService/PrismReadiness") {} + +/** Delay between restarts after a failed start or an exit. Tests swap in a delay-free schedule. */ +export const PrismRestartSchedule = Context.Reference>( + "t3/fork/prism/PrismRestartSchedule", + { + defaultValue: () => + Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }, +); + +/** How often external mode re-probes the proxy. Tests shrink it. */ +export const PrismHealthInterval = Context.Reference( + "t3/fork/prism/PrismHealthInterval", + { defaultValue: () => Duration.seconds(30) }, +); + +export class PrismService extends Context.Service< + PrismService, + { + readonly status: Effect.Effect; + /** Emits every status change. */ + readonly changes: Stream.Stream; + /** Base URL and API key for provider wiring; `none` unless ready. */ + readonly endpoint: Effect.Effect>; + /** + * Start the current run over now (sidecar: kill and respawn; external: + * re-read the secrets and probe) and answer once the state settles as + * `ready` or `failed`, or after `READY_TIMEOUT`. With the flag off it only + * reports the status. + */ + readonly restart: Effect.Effect; + /** + * Re-read `prism.usageSource` from `fork.json` and republish the endpoint + * with it, so the usage-limit source follows the toggle at once. Answers + * the status. + */ + readonly reloadUsageSource: Effect.Effect; + /** Server-side only: calls `/v0/management` with the management secret attached. */ + readonly management: { + readonly request: ( + path: string, + options?: PrismManagementRequestOptions, + ) => Effect.Effect; + }; + /** Managed `CODEX_HOME` for a Codex instance that should talk to the proxy. */ + readonly codexProxyHomePath: string; + } +>()("t3/fork/prism/PrismService") {} + +const SECRET_BYTES = 32; +const MANAGEMENT_PROBE_PATH = `/v0/management${PRISM_MANAGEMENT_PROBE_PATH}`; + +const toHex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +/** Blank out secrets before a sidecar line reaches the server log. */ +export const redactSecrets = (line: string, secrets: ReadonlyArray): string => + secrets.reduce( + (text, secret) => (secret.length === 0 ? text : text.split(secret).join("[redacted]")), + line, + ); + +/** Pure: `prism.external.baseUrl` as an origin plus its effective port, or undefined when it is not a bare http(s) origin. */ +export const parsePrismBaseUrl = ( + raw: string, +): { readonly baseUrl: string; readonly port: number } | undefined => { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return undefined; + } + const isHttp = url.protocol === "http:" || url.protocol === "https:"; + const bare = + (url.pathname === "/" || url.pathname === "") && + url.search === "" && + url.hash === "" && + url.username === "" && + url.password === ""; + if (!isHttp || !bare) return undefined; + const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port); + return { baseUrl: url.origin, port }; +}; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const flags = yield* ForkFlags.ForkFlagsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const binaries = yield* PrismBinary.PrismBinary; + const launcher = yield* PrismLauncher; + const readiness = yield* PrismReadiness; + const restartSchedule = yield* PrismRestartSchedule; + const healthInterval = yield* PrismHealthInterval; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = prismDirectories(config.baseDir, path); + + const usageSourceEnabled = flags.config.pipe( + Effect.map((forkConfig) => forkConfig.prism?.usageSource ?? true), + ); + + const statusRef = yield* Ref.make({ + state: "off", + mode: "sidecar", + port: PRISM_DEFAULT_PORT, + since: yield* nowIso, + restarts: 0, + usageSource: yield* usageSourceEnabled, + }); + const endpointRef = yield* Ref.make>(Option.none()); + const managementRef = yield* Ref.make>( + Option.none(), + ); + const statusPubSub = yield* PubSub.unbounded(); + const supervisorRef = yield* Ref.make>>(Option.none()); + // Completing it makes the supervisor abandon the current run and start over. + const restartRef = yield* Ref.make>>(Option.none()); + const runsRef = yield* Ref.make(0); + const lifecycle = yield* Semaphore.make(1); + const runScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => + Effect.sync(() => publishPrismEnabled(false)).pipe( + Effect.andThen(Scope.close(runScope, Exit.void)), + ), + ); + + /** Apply a patch derived from the current status; entering a new state stamps `since`. */ + const updateStatus = (update: (current: PrismStatus) => Partial) => + Effect.gen(function* () { + const current = yield* Ref.get(statusRef); + const patch = update(current); + const entered = patch.state !== undefined && patch.state !== current.state; + const next: PrismStatus = { + ...current, + ...patch, + since: entered ? yield* nowIso : current.since, + }; + yield* Ref.set(statusRef, next); + yield* PubSub.publish(statusPubSub, next); + }); + const patchStatus = (patch: Partial) => updateStatus(() => patch); + + const publish = (endpoint: Option.Option) => + Ref.set(endpointRef, endpoint).pipe( + Effect.andThen(Effect.sync(() => publishPrismEndpoint(Option.getOrUndefined(endpoint)))), + ); + + const setManagement = (management: Option.Option<{ baseUrl: string; secret: string }>) => + Ref.set(managementRef, management); + + const loadSecret = (name: string) => + secrets.getOrCreateRandom(name, SECRET_BYTES).pipe( + Effect.map(toHex), + Effect.mapError((cause) => new PrismSecretError({ cause })), + ); + + /** External mode never generates: a missing or blank secret is a configuration error with the fix in its message. */ + const readStoredSecret = (name: string) => + secrets.get(name).pipe( + Effect.mapError((cause) => new PrismSecretError({ cause })), + Effect.flatMap((stored) => { + const value = Option.isSome(stored) ? new TextDecoder().decode(stored.value).trim() : ""; + return value === "" ? Effect.fail(new PrismSecretMissing({ name })) : Effect.succeed(value); + }), + ); + + const ensureDirectory = (directory: string) => + fs + .makeDirectory(directory, { recursive: true }) + .pipe(Effect.andThen(fs.chmod(directory, 0o700))); + + const materializeCodexHome = (endpoint: PrismEndpoint) => + materializeCodexProxyHome({ homeDir: directories.codexHomeDir, endpoint }).pipe( + Effect.catch((error) => + Effect.logWarning("prism: codex proxy home not materialized", { + cause: error.message, + }), + ), + ); + + const becomeReady = ( + endpoint: PrismEndpoint, + managementSecret: string, + patch: (current: PrismStatus) => Partial, + ) => + setManagement(Option.some({ baseUrl: endpoint.baseUrl, secret: managementSecret })).pipe( + Effect.andThen(publish(Option.some(endpoint))), + Effect.andThen( + updateStatus((current) => ({ + ...patch(current), + state: "ready", + baseUrl: endpoint.baseUrl, + lastError: undefined, + })), + ), + ); + + const unpublish = publish(Option.none()).pipe(Effect.andThen(setManagement(Option.none()))); + + type Begin = (port: number) => Effect.Effect; + + // Spawn, wait for readiness, publish, then fail on the child's exit so the + // supervisor's retry drives the restart. + const runSidecar = Effect.fn("prism.runSidecar")(function* (section: PrismConfig, begin: Begin) { + const port = section.port ?? PRISM_DEFAULT_PORT; + yield* begin(port); + + const binary = yield* binaries.resolve({ + binaryPath: section.binaryPath, + version: section.releaseVersion, + }); + yield* patchStatus({ version: binary.version }); + + yield* ensureDirectory(directories.rootDir); + yield* ensureDirectory(directories.authsDir); + const apiKey = yield* loadSecret(PRISM_DEFAULT_API_KEY_SECRET_NAME); + const managementSecret = yield* loadSecret(PRISM_DEFAULT_MANAGEMENT_SECRET_NAME); + const secretValues = [apiKey, managementSecret]; + yield* writePrismConfig( + directories.configPath, + renderPrismConfig({ + port, + authDir: directories.authsDir, + apiKey, + managementSecret, + routingStrategy: section.routingStrategy, + }), + ); + const endpoint: PrismEndpoint = { + baseUrl: `http://127.0.0.1:${port}`, + apiKey, + managementSecret, + usageSource: section.usageSource ?? true, + }; + yield* materializeCodexHome(endpoint); + + const child = yield* launcher.launch({ + binaryPath: binary.path, + args: ["-config", directories.configPath], + cwd: directories.rootDir, + }); + yield* patchStatus({ pid: child.pid }); + yield* child.output.pipe( + Stream.runForEach((line) => Effect.logDebug(`prism: ${redactSecrets(line, secretValues)}`)), + Effect.ignoreCause(), + Effect.forkScoped, + ); + + const exited = child.exit.pipe( + Effect.flatMap((code) => Effect.fail(new PrismExited({ code }))), + ); + yield* Effect.raceFirst(readiness.awaitReady({ port, managementSecret }), exited); + + yield* becomeReady(endpoint, managementSecret, () => ({})); + yield* Effect.logInfo("prism: ready", { port, version: binary.version, pid: child.pid }); + return yield* exited; + }); + + // Validate the section and the secrets once, then probe on the health + // interval until interrupted. Only the setup can fail; a probe failure is a + // `failed` status the next probe can undo. + const runExternal = Effect.fn("prism.runExternal")(function* ( + section: PrismConfig, + begin: Begin, + ) { + const external = section.external; + const parsed = external === undefined ? undefined : parsePrismBaseUrl(external.baseUrl); + yield* begin(parsed?.port ?? PRISM_DEFAULT_PORT); + if (external === undefined) { + return yield* new PrismExternalConfigError({ + detail: 'prism.mode is "external" but fork.json has no prism.external section', + }); + } + if (parsed === undefined) { + return yield* new PrismExternalConfigError({ + detail: `prism.external.baseUrl must be an absolute http(s) origin such as http://127.0.0.1:8317, got "${external.baseUrl}"`, + }); + } + const managementSecret = yield* readStoredSecret( + external.managementSecretName ?? PRISM_DEFAULT_MANAGEMENT_SECRET_NAME, + ); + const apiKey = yield* readStoredSecret( + external.apiKeySecretName ?? PRISM_DEFAULT_API_KEY_SECRET_NAME, + ); + const endpoint: PrismEndpoint = { + baseUrl: parsed.baseUrl, + apiKey, + managementSecret, + usageSource: section.usageSource ?? true, + }; + yield* materializeCodexHome(endpoint); + + let wasReady = false; + while (true) { + const failure = yield* readiness.probe({ baseUrl: parsed.baseUrl, managementSecret }).pipe( + Effect.as(undefined), + Effect.catch((error) => Effect.succeed(error.message)), + ); + const current = yield* Ref.get(statusRef); + if (failure === undefined) { + if (current.state !== "ready") { + yield* becomeReady(endpoint, managementSecret, (status) => ({ + restarts: wasReady ? status.restarts + 1 : status.restarts, + })); + yield* Effect.logInfo("prism: external proxy ready", { baseUrl: parsed.baseUrl }); + wasReady = true; + } + } else if (current.state !== "failed" || current.lastError !== failure) { + yield* unpublish; + yield* patchStatus({ state: "failed", baseUrl: undefined, lastError: failure }); + yield* Effect.logWarning("prism: external proxy not reachable", { + baseUrl: parsed.baseUrl, + cause: failure, + }); + } + yield* Effect.sleep(healthInterval); + } + }); + + // One run, in whichever mode `fork.json` names right now. Every way the + // proxy can stop being useful is a failure so the supervisor restarts it. + const runOnce = Effect.scoped( + Effect.gen(function* () { + const section = (yield* flags.config).prism ?? {}; + const mode = section.mode ?? "sidecar"; + const run = yield* Ref.getAndUpdate(runsRef, (count) => count + 1); + const begin: Begin = (port) => + updateStatus((current) => ({ + state: "starting", + mode, + port, + pid: undefined, + version: undefined, + baseUrl: undefined, + restarts: run === 0 ? 0 : current.restarts + 1, + usageSource: section.usageSource ?? true, + })); + return mode === "external" + ? yield* runExternal(section, begin) + : yield* runSidecar(section, begin); + }).pipe(Effect.ensuring(unpublish)), + ); + + const superviseOnce = runOnce.pipe( + Effect.tapError((error) => + patchStatus({ + state: "failed", + pid: undefined, + baseUrl: undefined, + lastError: error.message, + }).pipe( + Effect.andThen( + Effect.logWarning("prism: proxy stopped, restarting", { cause: error.message }), + ), + ), + ), + Effect.retry(restartSchedule), + Effect.ignoreCause({ log: true }), + ); + + // Each pass owns one restart signal. When it fires, the current run (or the + // backoff it is sleeping in) is interrupted and the next pass begins with a + // fresh schedule; when the schedule itself gives up, only a signal continues. + const supervise = Effect.gen(function* () { + while (true) { + const restartRequested = yield* Deferred.make(); + yield* Ref.set(restartRef, Option.some(restartRequested)); + yield* Effect.raceFirst(superviseOnce, Deferred.await(restartRequested)); + yield* Deferred.await(restartRequested); + } + }); + + const start = Effect.gen(function* () { + if (Option.isSome(yield* Ref.get(supervisorRef))) return; + yield* Ref.set(runsRef, 0); + const fiber = yield* supervise.pipe(Effect.forkIn(runScope)); + yield* Ref.set(supervisorRef, Option.some(fiber)); + }); + + const stop = Effect.gen(function* () { + const fiber = yield* Ref.getAndSet(supervisorRef, Option.none()); + if (Option.isNone(fiber)) return; + yield* Fiber.interrupt(fiber.value); + yield* Ref.set(restartRef, Option.none()); + yield* patchStatus({ + state: "off", + pid: undefined, + version: undefined, + baseUrl: undefined, + lastError: undefined, + restarts: 0, + }); + yield* Effect.logInfo("prism: stopped"); + }); + + const apply = (values: { readonly prism: boolean }) => + lifecycle.withPermits(1)( + Effect.sync(() => publishPrismEnabled(values.prism)).pipe( + Effect.andThen(values.prism ? start : stop), + ), + ); + + yield* apply(yield* flags.current); + yield* flags.changes.pipe( + Stream.runForEach(apply), + Effect.ignoreCause({ log: true }), + Effect.forkIn(runScope), + ); + + const restart = Effect.gen(function* () { + const signal = yield* Ref.get(restartRef); + if (Option.isNone(signal)) return yield* Ref.get(statusRef); + return yield* Effect.scoped( + Effect.gen(function* () { + // Subscribe first so the `starting` that follows the signal cannot slip past. + const subscription = yield* PubSub.subscribe(statusPubSub); + yield* Deferred.succeed(signal.value, undefined); + // `off` settles it too: the flag turned off while we waited. + const settled = Effect.gen(function* () { + while (true) { + const status = yield* PubSub.take(subscription); + if (status.state !== "starting") return status; + } + }); + const outcome = yield* settled.pipe(Effect.timeoutOption(READY_TIMEOUT)); + return Option.isSome(outcome) ? outcome.value : yield* Ref.get(statusRef); + }), + ); + }); + + // The toggle lives in fork.json; the endpoint and status carry a copy so the + // usage-limit source and the clients see the same value without re-reading. + const reloadUsageSource = Effect.gen(function* () { + const usageSource = yield* usageSourceEnabled; + const endpoint = yield* Ref.get(endpointRef); + if (Option.isSome(endpoint) && endpoint.value.usageSource !== usageSource) { + yield* publish(Option.some({ ...endpoint.value, usageSource })); + } + if ((yield* Ref.get(statusRef)).usageSource !== usageSource) { + yield* patchStatus({ usageSource }); + } + return yield* Ref.get(statusRef); + }); + + const request: PrismService["Service"]["management"]["request"] = (requestPath, options) => + Effect.gen(function* () { + const management = yield* Ref.get(managementRef); + if (Option.isNone(management)) { + return yield* new PrismManagementError({ reason: "not-ready", path: requestPath }); + } + const client = yield* HttpClient.HttpClient; + let httpRequest = HttpClientRequest.make(options?.method ?? "GET")( + `${management.value.baseUrl}/v0/management${requestPath}`, + { headers: options?.headers }, + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${management.value.secret}`)); + if (options?.body !== undefined) { + httpRequest = HttpClientRequest.bodyText(httpRequest, options.body); + } + return yield* client + .execute(httpRequest) + .pipe( + Effect.mapError( + (cause) => + new PrismManagementError({ reason: "request-failed", path: requestPath, cause }), + ), + ); + }).pipe(Effect.provide(FetchHttpClient.layer)); + + return PrismService.of({ + status: Ref.get(statusRef), + changes: Stream.fromPubSub(statusPubSub), + endpoint: Ref.get(endpointRef), + restart, + reloadUsageSource, + management: { request }, + codexProxyHomePath: directories.codexHomeDir, + }); +}); + +export const READY_TIMEOUT = Duration.seconds(30); +const READY_POLL = Duration.millis(250); + +const tcpConnect = (port: number) => + Effect.callback((resume) => { + const socket = NodeNet.connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resume(Effect.succeed(true)); + }); + socket.once("error", () => resume(Effect.succeed(false))); + return Effect.sync(() => socket.destroy()); + }); + +/** Reads local routing state with the bearer secret, without querying remote release servers. */ +const managementProbe = (baseUrl: string, secret: string) => + HttpClient.HttpClient.pipe( + Effect.flatMap((client) => + client.get(`${baseUrl}${MANAGEMENT_PROBE_PATH}`, { + headers: { Authorization: `Bearer ${secret}` }, + }), + ), + Effect.provide(FetchHttpClient.layer), + ); + +const untilTrue = (probe: Effect.Effect, onTimeout: PrismNotReady) => + probe.pipe( + Effect.flatMap((ok) => (ok ? Effect.void : Effect.fail(onTimeout))), + Effect.retry(Schedule.spaced(READY_POLL)), + Effect.timeoutOption(READY_TIMEOUT), + Effect.flatMap( + Option.match({ onNone: () => Effect.fail(onTimeout), onSome: () => Effect.void }), + ), + ); + +export const readinessLayer = Layer.succeed( + PrismReadiness, + PrismReadiness.of({ + awaitReady: ({ port, managementSecret }) => + untilTrue(tcpConnect(port), new PrismNotReady({ port, stage: "tcp" })).pipe( + Effect.andThen( + untilTrue( + managementProbe(`http://127.0.0.1:${port}`, managementSecret).pipe( + Effect.map((response) => response.status === 200), + Effect.orElseSucceed(() => false), + ), + new PrismNotReady({ port, stage: "management" }), + ), + ), + ), + probe: ({ baseUrl, managementSecret }) => + managementProbe(baseUrl, managementSecret).pipe( + Effect.mapError( + (error) => + new PrismProbeFailed({ + baseUrl, + detail: redactSecrets(error.message, [managementSecret]), + }), + ), + Effect.flatMap((response) => + response.status === 200 + ? Effect.void + : Effect.fail( + new PrismProbeFailed({ + baseUrl, + detail: + response.status === 401 || response.status === 403 + ? `HTTP ${response.status} from GET ${MANAGEMENT_PROBE_PATH}; check the management secret` + : `HTTP ${response.status} from GET ${MANAGEMENT_PROBE_PATH}`, + }), + ), + ), + ), + }), +); + +export const launcherLayer = Layer.effect( + PrismLauncher, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return PrismLauncher.of({ + launch: (input) => + Effect.gen(function* () { + const handle = yield* Effect.acquireRelease( + spawner + .spawn( + ChildProcess.make(input.binaryPath, input.args, { + cwd: input.cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: "SIGTERM", + forceKillAfter: Duration.seconds(5), + }), + ) + .pipe( + Effect.mapError((cause) => new PrismSpawnError({ path: input.binaryPath, cause })), + ), + (child) => child.kill().pipe(Effect.ignore), + ); + return { + pid: handle.pid, + output: handle.all.pipe(Stream.decodeText(), Stream.splitLines, Stream.orDie), + exit: handle.exitCode.pipe( + Effect.map((code) => Number(code)), + Effect.orElseSucceed(() => -1), + ), + } satisfies PrismChild; + }), + }); + }), +); + +/** Production wiring. Needs ServerConfig, ForkFlagsService, ServerSecretStore, and the Node services from outside. */ +export const layer = Layer.effect(PrismService, make).pipe( + Layer.provide(PrismBinary.layer), + Layer.provide(ProcessRunner.layer), + Layer.provide(launcherLayer), + Layer.provide(readinessLayer), +); + +/** The service body alone; tests supply the binary, launcher, readiness, and schedule. */ +export const layerWithoutRuntime = Layer.effect(PrismService, make); diff --git a/apps/server/src/fork/prism/PrismServingCredentials.test.ts b/apps/server/src/fork/prism/PrismServingCredentials.test.ts new file mode 100644 index 000000000000..20bc5ff960db --- /dev/null +++ b/apps/server/src/fork/prism/PrismServingCredentials.test.ts @@ -0,0 +1,50 @@ +import * as Schema from "effect/Schema"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import { servingCredential } from "./PrismServingCredentials.ts"; + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +it.effect( + "serving snapshots preserve access credentials and remove every refresh-token alias", + () => + Effect.gen(function* () { + for (const type of ["claude", "codex", "grok"]) { + const source = { + type, + access_token: "test-access", + refresh_token: "test-refresh", + token: { + refreshToken: "test-nested-refresh", + access_token: "test-nested-access", + }, + nested: [{ "refresh-token": "test-array-refresh" }], + disabled: true, + }; + const result = yield* servingCredential( + new TextEncoder().encode(yield* encodeJson(source)), + ); + assert.deepEqual(yield* decodeJson(new TextDecoder().decode(result)), { + type, + access_token: "test-access", + token: { access_token: "test-nested-access" }, + nested: [{}], + disabled: true, + refresh_disabled: true, + }); + assert.equal(source.refresh_token, "test-refresh"); + } + }), +); + +it.effect("invalid credential errors never contain source material", () => + Effect.gen(function* () { + for (const value of ['{"refresh_token":"test-private-material"', "null", "[]"]) { + const result = yield* servingCredential(new TextEncoder().encode(value)).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.isFalse((yield* encodeJson(result)).includes("test-private-material")); + } + }), +); diff --git a/apps/server/src/fork/prism/PrismServingCredentials.ts b/apps/server/src/fork/prism/PrismServingCredentials.ts new file mode 100644 index 000000000000..da34afa69f51 --- /dev/null +++ b/apps/server/src/fork/prism/PrismServingCredentials.ts @@ -0,0 +1,45 @@ +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import { PrismSyncFailedError } from "@q1code/core/prismApi"; + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeCredential = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Refresh credentials stay with the primary, including provider-specific nested aliases. */ +const withoutRefreshTokens = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(withoutRefreshTokens); + if (!isObject(value)) return value; + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key.replaceAll(/[_-]/g, "").toLowerCase() !== "refreshtoken") + .map(([key, child]) => [key, withoutRefreshTokens(child)]), + ); +}; + +/** Never include the input or parser error: either can contain OAuth credentials. */ +export const servingCredential = Effect.fn("prism.servingCredential")(function* ( + bytes: Uint8Array, +) { + const invalid = () => + new PrismSyncFailedError({ + reason: "io", + message: "Account file is not a valid JSON credential object.", + }); + const text = yield* Effect.try({ + try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes), + catch: invalid, + }); + const source = yield* decodeCredential(text).pipe(Effect.mapError(invalid)); + const serving = withoutRefreshTokens(source); + if (!isObject(serving)) return yield* invalid(); + const encoded = yield* encodeJson({ + ...serving, + refresh_disabled: true, + }).pipe(Effect.mapError(invalid)); + return new TextEncoder().encode(encoded); +}); diff --git a/apps/server/src/fork/prism/PrismSync.test.ts b/apps/server/src/fork/prism/PrismSync.test.ts new file mode 100644 index 000000000000..ddb45279cdb2 --- /dev/null +++ b/apps/server/src/fork/prism/PrismSync.test.ts @@ -0,0 +1,609 @@ +import * as Schema from "effect/Schema"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PrismSyncFailedError, type PrismSyncEntry } from "@q1code/core/prismApi"; +import { DEFAULT_FORK_FLAGS, type ForkFlagValues } from "@q1code/core/flags"; +import type { ForkConfig } from "@q1code/core/config"; +import { EnvironmentId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import { ForkFlagsEnvironment, ForkFlagsService } from "../ForkFlags.ts"; +import { prismDirectories } from "./PrismConfig.ts"; +import { deriveSyncKey, decryptSyncEntry, encryptSyncEntry } from "./PrismSyncCrypto.ts"; +import { + PrismSyncService, + PrismSyncTicker, + PrismSyncTransport, + layerWithoutRuntime, + planSyncMerge, + pruneSyncTombstones, + SYNC_TOMBSTONE_TTL_MILLIS, +} from "./PrismSync.ts"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeTombstoneJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), +); + +const T0 = "2026-09-02T10:00:00.000Z"; +const T1 = "2026-09-02T11:00:00.000Z"; +const T2 = "2026-09-02T12:00:00.000Z"; +const T3 = "2026-09-02T13:00:00.000Z"; + +it("planSyncMerge pulls newer or missing remote files and pushes newer or missing local ones", () => { + const plan = planSyncMerge( + { + files: [ + { id: "same.json", updatedAt: T1 }, + { id: "local-newer.json", updatedAt: T2 }, + { id: "remote-newer.json", updatedAt: T0 }, + { id: "local-only.json", updatedAt: T0 }, + ], + }, + { + files: [ + { id: "same.json", updatedAt: T1 }, + { id: "local-newer.json", updatedAt: T1 }, + { id: "remote-newer.json", updatedAt: T1 }, + { id: "remote-only.json", updatedAt: T0 }, + ], + }, + ); + assert.deepEqual([...plan.pull].sort(), ["remote-newer.json", "remote-only.json"]); + assert.deepEqual([...plan.push].sort(), ["local-newer.json", "local-only.json"]); + assert.deepEqual(plan.deleteLocal, []); + assert.deepEqual(plan.deleteRemote, []); + assert.deepEqual(plan.tombstones, []); +}); + +it("planSyncMerge treats an unparseable stamp as older than everything", () => { + const plan = planSyncMerge( + { files: [{ id: "a.json", updatedAt: "garbage" }] }, + { files: [{ id: "a.json", updatedAt: T0 }] }, + ); + assert.deepEqual(plan, { + pull: ["a.json"], + push: [], + deleteLocal: [], + deleteRemote: [], + tombstones: [], + }); +}); + +it("planSyncMerge lets a tombstone bury files on both sides unless a newer file beats it", () => { + const plan = planSyncMerge( + { + files: [ + { id: "remote-deleted.json", updatedAt: T0 }, + { id: "local-deleted.json", updatedAt: T0 }, + { id: "recreated-here.json", updatedAt: T3 }, + { id: "kept.json", updatedAt: T1 }, + ], + tombstones: [ + { id: "local-deleted.json", deletedAt: T1 }, + { id: "recreated-there.json", deletedAt: T1 }, + { id: "same-both.json", deletedAt: T0 }, + ], + }, + { + files: [ + { id: "local-deleted.json", updatedAt: T1 }, + { id: "recreated-there.json", updatedAt: T2 }, + { id: "kept.json", updatedAt: T0 }, + ], + tombstones: [ + { id: "remote-deleted.json", deletedAt: T2 }, + { id: "recreated-here.json", deletedAt: T2 }, + { id: "same-both.json", deletedAt: T1 }, + ], + }, + ); + // A file stamped at the tombstone's time is still buried; only strictly newer wins. + assert.deepEqual(plan.deleteLocal, ["local-deleted.json", "remote-deleted.json"]); + assert.deepEqual(plan.deleteRemote, ["local-deleted.json"]); + assert.deepEqual(plan.tombstones, [ + { id: "local-deleted.json", deletedAt: T1 }, + { id: "remote-deleted.json", deletedAt: T2 }, + { id: "same-both.json", deletedAt: T1 }, + ]); + assert.deepEqual([...plan.pull].sort(), ["recreated-there.json"]); + assert.deepEqual([...plan.push].sort(), ["kept.json", "recreated-here.json"]); +}); + +it("pruneSyncTombstones drops tombstones older than the TTL", () => { + const now = Date.parse(T2); + const stale = DateTime.formatIso(DateTime.makeUnsafe(now - SYNC_TOMBSTONE_TTL_MILLIS - 1)); + const fresh = DateTime.formatIso(DateTime.makeUnsafe(now - SYNC_TOMBSTONE_TTL_MILLIS + 1)); + assert.deepEqual( + pruneSyncTombstones( + [ + { id: "stale.json", deletedAt: stale }, + { id: "fresh.json", deletedAt: fresh }, + { id: "garbage.json", deletedAt: "garbage" }, + ], + now, + ), + [{ id: "fresh.json", deletedAt: fresh }], + ); +}); + +const flagsLayer = (config: ForkConfig, prism = true) => { + const values: ForkFlagValues = { ...DEFAULT_FORK_FLAGS, prism }; + return Layer.succeed( + ForkFlagsService, + ForkFlagsService.of({ + current: Effect.succeed(values), + reload: Effect.succeed(values), + changes: Stream.empty, + config: Effect.succeed(config), + update: () => Effect.die("unexpected fork.json update"), + }), + ); +}; + +const identityLayer = (id: string) => + Layer.succeed( + ServerEnvironment.ServerEnvironmentIdentity, + ServerEnvironment.ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make(id)), + }), + ); + +/** + * One sync service on its own temp base dir. `Layer.fresh` keeps two nodes + * built in one test from sharing a memoized service instance. + */ +const makeNode = (input: { + readonly id: string; + readonly config: ForkConfig; + readonly env: Record; + readonly transport: Layer.Layer; + readonly ticker?: (interval: unknown) => Stream.Stream; +}) => + Layer.fresh(layerWithoutRuntime).pipe( + Layer.provide(flagsLayer(input.config)), + Layer.provide(identityLayer(input.id)), + Layer.provide(Layer.succeed(ForkFlagsEnvironment, input.env)), + Layer.provide(input.transport), + Layer.provide( + input.ticker === undefined + ? Layer.empty + : Layer.succeed(PrismSyncTicker, input.ticker as never), + ), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: `q1code-sync-${input.id}-` })), + ), + ); + +const noTransport = Layer.succeed( + PrismSyncTransport, + PrismSyncTransport.of({ + fetchExport: () => Effect.die("unexpected fetchExport"), + push: () => Effect.die("unexpected push"), + }), +); + +const writeAuth = (name: string, contents: string, at: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + const { authsDir } = prismDirectories(baseDir, path); + yield* fs.makeDirectory(authsDir, { recursive: true }); + const file = path.join(authsDir, name); + yield* fs.writeFileString(file, contents); + yield* fs.utimes(file, Date.parse(at) / 1000, Date.parse(at) / 1000); + }); + +const readTombstoneFile = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + const { tombstonesPath } = prismDirectories(baseDir, path); + if (!(yield* fs.exists(tombstonesPath))) return null; + return yield* decodeTombstoneJson(yield* fs.readFileString(tombstonesPath)); +}); + +const readAuths = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + const { authsDir } = prismDirectories(baseDir, path); + const exists = yield* fs.exists(authsDir); + if (!exists) return {} as Record; + const names = (yield* fs.readDirectory(authsDir)).filter((name) => name.endsWith(".json")); + const entries = yield* Effect.forEach(names, (name) => + fs.readFileString(path.join(authsDir, name)).pipe(Effect.map((text) => [name, text] as const)), + ); + return Object.fromEntries(entries); +}); + +const KEY = "shared-secret"; + +it.layer(NodeServices.layer, { excludeTestServices: true })("PrismSync", (it) => { + it.effect("primary exports serving snapshots and rejects credential pushes", () => + Effect.gen(function* () { + const primary = makeNode({ + id: "primary", + config: { prism: { sync: { role: "primary" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY }, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + yield* writeAuth("a.json", '{"v":"a1"}', T1); + yield* writeAuth("b.json", '{"v":"b1"}', T1); + const bundle = yield* service.exportBundle; + assert.equal(bundle.primaryEnvironmentId, "primary"); + assert.deepEqual(bundle.entries.map((entry) => [entry.id, entry.updatedAt]).sort(), [ + ["a.json", T1], + ["b.json", T1], + ]); + assert.isFalse(bundle.entries.some((entry) => entry.ciphertext.includes("a1"))); + + assert.equal(bundle.version, 3); + const rejected = yield* service.applyPush(bundle.entries).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(rejected)); + assert.deepEqual(Object.keys(yield* readAuths).sort(), ["a.json", "b.json"]); + }).pipe(Effect.provide(primary)); + }), + ); + + it.effect("primary records deletions but rejects replica tombstones", () => + Effect.gen(function* () { + const primary = makeNode({ + id: "primary-tombstones", + config: { prism: { sync: { role: "primary" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY }, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + yield* writeAuth("old.json", '{"v":"old"}', T0); + yield* writeAuth("refreshed.json", '{"v":"new"}', T3); + yield* writeAuth("keep.json", '{"v":"keep"}', T1); + + // A deletion made here (the sidecar already removed the file). + yield* service.recordTombstone("gone.json"); + const bundle = yield* service.exportBundle; + assert.equal(bundle.version, 3); + assert.deepEqual( + bundle.tombstones?.map((tombstone) => tombstone.id), + ["gone.json"], + ); + const file = yield* readTombstoneFile; + assert.deepEqual(Object.keys(file ?? {}), ["gone.json"]); + + const rejected = yield* service + .applyPush([], [{ id: "old.json", deletedAt: T1 }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(rejected)); + assert.deepEqual(Object.keys(yield* readAuths).sort(), [ + "keep.json", + "old.json", + "refreshed.json", + ]); + }).pipe(Effect.provide(primary)); + }), + ); + + it.effect("reads the shared key from the secret store before the environment", () => + Effect.gen(function* () { + // Default secret name, no env: the store alone configures the primary. + const stored = makeNode({ + id: "stored-key", + config: { prism: { sync: { role: "primary" } } }, + env: {}, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set("prism-sync-key", new TextEncoder().encode(`${KEY}\n`)); + const service = yield* PrismSyncService; + yield* writeAuth("a.json", '{"v":"a"}', T1); + const bundle = yield* service.exportBundle; + assert.equal(bundle.entries.length, 1); + }).pipe(Effect.provide(stored)); + + // A configured name beats the default, and the store beats the env var: + // an entry encrypted under the env key must not decrypt. + const named = makeNode({ + id: "named-key", + config: { prism: { sync: { role: "primary", sharedKeySecretName: "my-key" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: "env-key" }, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* secrets.set("my-key", new TextEncoder().encode("store-key")); + const service = yield* PrismSyncService; + yield* writeAuth("a.json", '{"v":"a"}', T1); + const entry = (yield* service.exportBundle).entries[0]!; + const rejected = yield* decryptSyncEntry(deriveSyncKey("env-key"), entry.ciphertext).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(rejected)); + const accepted = yield* decryptSyncEntry(deriveSyncKey("store-key"), entry.ciphertext); + assert.deepEqual(yield* decodeJson(new TextDecoder().decode(accepted)), { + v: "a", + refresh_disabled: true, + }); + }).pipe(Effect.provide(named)); + }), + ); + + it.effect("external mode exports from the gateway auth dir without modifying credentials", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const authDir = yield* fs.makeTempDirectoryScoped({ prefix: "q1code-external-auths-" }); + const node = makeNode({ + id: "external-primary", + config: { + prism: { + mode: "external", + external: { baseUrl: "http://127.0.0.1:8317", authDir }, + sync: { role: "primary" }, + }, + }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY }, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + const { baseDir } = yield* ServerConfig.ServerConfig; + const managed = prismDirectories(baseDir, path); + const file = path.join(authDir, "a.json"); + yield* fs.writeFileString(file, '{"v":"a1"}'); + yield* fs.utimes(file, Date.parse(T1) / 1000, Date.parse(T1) / 1000); + + const bundle = yield* service.exportBundle; + assert.deepEqual( + bundle.entries.map((entry) => [entry.id, entry.updatedAt]), + [["a.json", T1]], + ); + assert.equal(yield* fs.readFileString(file), '{"v":"a1"}'); + assert.isFalse(yield* fs.exists(managed.authsDir)); + yield* service.recordTombstone("gone.json"); + assert.isTrue(yield* fs.exists(managed.tombstonesPath)); + }).pipe(Effect.provide(node)); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to export without a shared key or when the role is replica", () => + Effect.gen(function* () { + const noKey = makeNode({ + id: "nokey", + config: { prism: { sync: { role: "primary" } } }, + env: {}, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + const exit = yield* service.exportBundle.pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + assert.equal((yield* service.status).role, "primary"); + }).pipe(Effect.provide(noKey)); + + const replica = makeNode({ + id: "replica-export", + config: { prism: { sync: { role: "replica", primaryUrl: "http://primary:1/" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY, Q1CODE_PRISM_SYNC_TOKEN: "t" }, + transport: noTransport, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + const exit = yield* service.exportBundle.pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + const status = yield* service.status; + assert.equal(status.primaryUrl, "http://primary:1"); + assert.equal(status.intervalSeconds, 300); + }).pipe(Effect.provide(replica)); + }), + ); + + it.effect( + "replica uses the primary snapshot despite clock skew and never pushes local changes", + () => + Effect.gen(function* () { + const ticks = yield* Queue.unbounded(); + const pushes: Array> = []; + const primaryReady = yield* Deferred.make(); + + // The replica's transport talks to the primary service directly. + const transport = Layer.succeed( + PrismSyncTransport, + PrismSyncTransport.of({ + fetchExport: () => + Deferred.await(primaryReady).pipe( + Effect.flatMap((primary) => primary.exportBundle), + Effect.orDie, + ), + push: ({ entries, tombstones }) => + Deferred.await(primaryReady).pipe( + Effect.flatMap((primary) => { + pushes.push(entries); + return primary.applyPush(entries, tombstones); + }), + Effect.orDie, + ), + }), + ); + + const primary = makeNode({ + id: "primary", + config: { prism: { sync: { role: "primary" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY }, + transport: noTransport, + }); + const replica = makeNode({ + id: "replica", + config: { + prism: { + sync: { role: "replica", primaryUrl: "http://primary", intervalSeconds: 5 }, + }, + }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY, Q1CODE_PRISM_SYNC_TOKEN: "token" }, + transport, + ticker: () => Stream.fromQueue(ticks), + }); + + yield* Effect.gen(function* () { + const primaryService = yield* PrismSyncService; + yield* writeAuth("shared.json", '{"v":"primary-old"}', T0); + yield* writeAuth("primary-only.json", '{"v":"p"}', T1); + yield* writeAuth("deleted-on-replica.json", '{"v":"d"}', T0); + // Deleted on the primary; the replica still holds an older copy. + yield* primaryService.recordTombstone("deleted-on-primary.json"); + yield* Deferred.succeed(primaryReady, primaryService); + + yield* Effect.gen(function* () { + const replicaService = yield* PrismSyncService; + yield* writeAuth("shared.json", '{"v":"replica-new"}', T2); + yield* writeAuth("replica-only.json", '{"v":"r"}', T1); + yield* writeAuth("deleted-on-primary.json", '{"v":"stale"}', T0); + yield* replicaService.recordTombstone("deleted-on-replica.json"); + + // Each tick runs one cycle; subscribe to `changes` before offering the + // tick so the completion cannot be missed, then await it. + const awaitCycle = Effect.gen(function* () { + const done = yield* replicaService.changes.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* Queue.offer(ticks, undefined); + yield* Fiber.join(done); + }); + + yield* awaitCycle; + const replicaFiles = yield* readAuths; + assert.deepEqual(yield* decodeJson(replicaFiles["primary-only.json"]!), { + v: "p", + refresh_disabled: true, + }); + assert.deepEqual(yield* decodeJson(replicaFiles["shared.json"]!), { + v: "primary-old", + refresh_disabled: true, + }); + assert.isUndefined(replicaFiles["deleted-on-primary.json"]); + assert.isDefined(replicaFiles["deleted-on-replica.json"]); + assert.isUndefined(replicaFiles["replica-only.json"]); + assert.deepEqual(Object.keys((yield* readTombstoneFile) ?? {}).sort(), [ + "deleted-on-primary.json", + ]); + assert.equal(pushes.length, 0); + const status = yield* replicaService.status; + assert.equal(status.role, "replica"); + assert.isUndefined(status.lastSyncError); + + // A second cycle with nothing new moves no files. + yield* awaitCycle; + assert.equal(pushes.length, 0); + }).pipe(Effect.provide(replica)); + + const primaryFiles = yield* readAuths; + assert.equal(primaryFiles["shared.json"], '{"v":"primary-old"}'); + assert.isUndefined(primaryFiles["replica-only.json"]); + assert.equal(primaryFiles["primary-only.json"], '{"v":"p"}'); + assert.isDefined(primaryFiles["deleted-on-replica.json"]); + assert.deepEqual(Object.keys((yield* readTombstoneFile) ?? {}).sort(), [ + "deleted-on-primary.json", + ]); + }).pipe(Effect.provide(primary)); + }), + ); + + it.effect( + "replicas keep their snapshot when a bundle is legacy, corrupt, or contains duplicate accounts", + () => + Effect.gen(function* () { + const valid = yield* encryptSyncEntry( + deriveSyncKey(KEY), + new TextEncoder().encode('{"access_token":"test-new"}'), + ); + const invalid = yield* encryptSyncEntry( + deriveSyncKey(KEY), + new TextEncoder().encode("not-json"), + ); + const entry = { id: "new.json", updatedAt: T1, ciphertext: valid }; + for (const bundle of [ + { version: 2 as const, entries: [entry] }, + { + version: 3 as const, + entries: [entry, { id: "bad.json", updatedAt: T1, ciphertext: "corrupt" }], + }, + { + version: 3 as const, + entries: [entry, { id: "bad.json", updatedAt: T1, ciphertext: invalid }], + }, + { version: 3 as const, entries: [entry, entry] }, + ]) { + const transport = Layer.succeed( + PrismSyncTransport, + PrismSyncTransport.of({ + fetchExport: () => + Effect.succeed({ ...bundle, generatedAt: T2, primaryEnvironmentId: "primary" }), + push: () => Effect.die("unexpected push"), + }), + ); + const replica = makeNode({ + id: "replica-invalid", + config: { prism: { sync: { role: "replica", primaryUrl: "http://primary" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY, Q1CODE_PRISM_SYNC_TOKEN: "token" }, + transport, + ticker: () => Stream.empty, + }); + yield* Effect.gen(function* () { + yield* writeAuth("old.json", '{"access_token":"test-kept"}', T0); + const service = yield* PrismSyncService; + const result = yield* service.syncNow.pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + assert.deepEqual(yield* readAuths, { "old.json": '{"access_token":"test-kept"}' }); + }).pipe(Effect.provide(replica)); + } + }), + ); + + it.effect("replica records the failure when the primary is unreachable", () => + Effect.gen(function* () { + const failing = Layer.succeed( + PrismSyncTransport, + PrismSyncTransport.of({ + fetchExport: () => + Effect.fail( + new PrismSyncFailedError({ reason: "transport", message: "connection refused" }), + ), + push: () => Effect.die("unexpected push"), + }), + ); + const replica = makeNode({ + id: "replica-fail", + config: { prism: { sync: { role: "replica", primaryUrl: "http://primary" } } }, + env: { Q1CODE_PRISM_SYNC_KEY: KEY, Q1CODE_PRISM_SYNC_TOKEN: "token" }, + transport: failing, + ticker: () => Stream.empty, + }); + yield* Effect.gen(function* () { + const service = yield* PrismSyncService; + const exit = yield* service.syncNow.pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + assert.equal((yield* service.status).lastSyncError, "connection refused"); + }).pipe(Effect.provide(replica)); + }), + ); +}); diff --git a/apps/server/src/fork/prism/PrismSync.ts b/apps/server/src/fork/prism/PrismSync.ts new file mode 100644 index 000000000000..106e0efe6757 --- /dev/null +++ b/apps/server/src/fork/prism/PrismSync.ts @@ -0,0 +1,636 @@ +/** + * The primary owns account enrollment and token rotation. Version 3 exports a + * complete, encrypted serving snapshot without refresh tokens. Replicas never + * push credentials back or trust local mtimes over the primary. A failed fetch + * or decrypt leaves the last serving snapshot intact. + */ +import { + PRISM_API_PATHS, + PrismSyncBundle, + type PrismSyncEntry, + PrismSyncFailedError, + PrismSyncPushResult, + type PrismSyncStatus, + type PrismSyncTombstone, +} from "@q1code/core/prismApi"; +import { + PRISM_SYNC_DEFAULT_INTERVAL_SECONDS, + PRISM_SYNC_DEFAULT_KEY_SECRET_NAME, + PRISM_SYNC_DEFAULT_TOKEN_SECRET_NAME, + type PrismSyncConfig, +} from "@q1code/core/config"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as ForkFlags from "../ForkFlags.ts"; +import { prismAuthsDir, prismDirectories } from "./PrismConfig.ts"; +import { servingCredential } from "./PrismServingCredentials.ts"; +import { + decryptSyncEntry, + deriveSyncKey, + encryptSyncEntry, + type SyncKey, +} from "./PrismSyncCrypto.ts"; + +export const SYNC_TOKEN_ENV = "Q1CODE_PRISM_SYNC_TOKEN"; +export const SYNC_KEY_ENV = "Q1CODE_PRISM_SYNC_KEY"; + +export const SYNC_TOMBSTONE_TTL_MILLIS = 30 * 24 * 60 * 60 * 1000; + +/** Sync has no usable configuration for the requested role. The message never names a secret value. */ +export class PrismSyncNotConfigured extends Schema.TaggedErrorClass()( + "PrismSyncNotConfigured", + { + message: Schema.String, + }, +) {} + +export type PrismSyncError = PrismSyncNotConfigured | PrismSyncFailedError; + +export interface SyncStamp { + readonly id: string; + readonly updatedAt: string; +} + +export type SyncTombstone = PrismSyncTombstone; + +/** One side of a merge: its files by stamp and the deletions it knows about. */ +export interface SyncSide { + readonly files: ReadonlyArray; + readonly tombstones?: ReadonlyArray | undefined; +} + +export interface SyncPlan { + /** Ids whose remote copy is newer than (or missing from) the local set. */ + readonly pull: ReadonlyArray; + /** Ids whose local copy is newer than (or missing from) the remote set. */ + readonly push: ReadonlyArray; + /** Local files a tombstone covers: remove them. */ + readonly deleteLocal: ReadonlyArray; + /** Remote files a tombstone covers: the other side removes them once it sees `tombstones`. */ + readonly deleteRemote: ReadonlyArray; + /** Still in force after this merge: newest per id from both sides, minus those a newer file beat. Both sides keep this set. */ + readonly tombstones: ReadonlyArray; +} + +const isoMillis = (iso: string): number => { + const millis = Date.parse(iso); + return Number.isFinite(millis) ? millis : 0; +}; + +/** + * Pure: newer wins on each side, equal stamps move nowhere. A tombstone beats + * every file stamped at or before its `deletedAt` on either side; a file + * stamped strictly later beats the tombstone (a re-creation). + */ +export const planSyncMerge = (local: SyncSide, remote: SyncSide): SyncPlan => { + const localById = new Map(local.files.map((stamp) => [stamp.id, isoMillis(stamp.updatedAt)])); + const remoteById = new Map(remote.files.map((stamp) => [stamp.id, isoMillis(stamp.updatedAt)])); + const newestTombstone = new Map(); + for (const tombstone of [...(local.tombstones ?? []), ...(remote.tombstones ?? [])]) { + const known = newestTombstone.get(tombstone.id); + if (known === undefined || isoMillis(tombstone.deletedAt) > isoMillis(known)) { + newestTombstone.set(tombstone.id, tombstone.deletedAt); + } + } + const deleteLocal: Array = []; + const deleteRemote: Array = []; + const tombstones: Array = []; + for (const [id, deletedAt] of [...newestTombstone].sort(([a], [b]) => (a < b ? -1 : 1))) { + const deletedMillis = isoMillis(deletedAt); + const localMillis = localById.get(id); + const remoteMillis = remoteById.get(id); + const beaten = + (localMillis !== undefined && localMillis > deletedMillis) || + (remoteMillis !== undefined && remoteMillis > deletedMillis); + if (beaten) continue; + tombstones.push({ id, deletedAt }); + if (localMillis !== undefined) deleteLocal.push(id); + if (remoteMillis !== undefined) deleteRemote.push(id); + } + const buried = new Set(tombstones.map((tombstone) => tombstone.id)); + const pull: Array = []; + const push: Array = []; + for (const [id, remoteMillis] of remoteById) { + if (buried.has(id)) continue; + const localMillis = localById.get(id); + if (localMillis === undefined || remoteMillis > localMillis) pull.push(id); + } + for (const [id, localMillis] of localById) { + if (buried.has(id)) continue; + const remoteMillis = remoteById.get(id); + if (remoteMillis === undefined || localMillis > remoteMillis) push.push(id); + } + return { pull, push, deleteLocal, deleteRemote, tombstones }; +}; + +/** Pure: drop tombstones older than the TTL; nothing that old can still be waiting on a replica. */ +export const pruneSyncTombstones = ( + tombstones: ReadonlyArray, + nowMillis: number, + ttlMillis: number = SYNC_TOMBSTONE_TTL_MILLIS, +): ReadonlyArray => + tombstones.filter((tombstone) => isoMillis(tombstone.deletedAt) > nowMillis - ttlMillis); + +export interface PrismSyncTransportInput { + readonly primaryUrl: string; + readonly token: string; +} + +/** The replica's view of the primary. Tests provide one backed by another sync service. */ +export class PrismSyncTransport extends Context.Service< + PrismSyncTransport, + { + readonly fetchExport: ( + input: PrismSyncTransportInput, + ) => Effect.Effect; + readonly push: ( + input: PrismSyncTransportInput & { + readonly entries: ReadonlyArray; + readonly tombstones: ReadonlyArray; + }, + ) => Effect.Effect; + } +>()("t3/fork/prism/PrismSync/PrismSyncTransport") {} + +/** Emits once per replica cycle; the first emission is the startup sync. Tests inject a PubSub. */ +export const PrismSyncTicker = Context.Reference< + (interval: Duration.Duration) => Stream.Stream +>("t3/fork/prism/PrismSync/PrismSyncTicker", { + defaultValue: () => (interval) => Stream.tick(interval), +}); + +export class PrismSyncService extends Context.Service< + PrismSyncService, + { + readonly status: Effect.Effect; + /** Emits the status after every replica cycle, success or failure. */ + readonly changes: Stream.Stream; + /** Primary: every auth file, encrypted, stamped with its mtime, plus the live tombstones. */ + readonly exportBundle: Effect.Effect; + /** Primary: decrypt and write the entries that are newer than the local copies; apply and keep the tombstones. */ + readonly applyPush: ( + entries: ReadonlyArray, + tombstones?: ReadonlyArray, + ) => Effect.Effect; + /** Replica: one pull-then-push cycle against the primary. */ + readonly syncNow: Effect.Effect; + /** Any role: remember that `id` was deleted here, so the deletion reaches the other environments. */ + readonly recordTombstone: (id: string) => Effect.Effect; + } +>()("t3/fork/prism/PrismSync/PrismSyncService") {} + +interface ResolvedSync { + readonly role: PrismSyncConfig["role"]; + readonly key: SyncKey; + readonly primaryUrl: string | undefined; + readonly token: string | undefined; + readonly tokenSecretName: string; + readonly interval: Duration.Duration; +} + +const isAuthFileName = (name: string) => name.endsWith(".json") && !name.startsWith("."); + +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const nowMillis = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + +/** On disk: `{ "": "" }`. */ +const TombstoneFile = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeTombstoneFile = Schema.decodeUnknownExit(TombstoneFile); +const encodeTombstoneFile = Schema.encodeSync(TombstoneFile); + +const trimOrigin = (url: string) => url.replace(/\/+$/, ""); + +const ioError = (message: string) => (cause: unknown) => + new PrismSyncFailedError({ + reason: "io", + message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const flags = yield* ForkFlags.ForkFlagsService; + const env = yield* ForkFlags.ForkFlagsEnvironment; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const identity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const transport = yield* PrismSyncTransport; + const ticker = yield* PrismSyncTicker; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = prismDirectories(config.baseDir, path); + const { rootDir, tombstonesPath } = directories; + // Read per operation: in external mode the proxy's own auth dir replaces the managed one. + const currentAuthsDir = flags.config.pipe( + Effect.map((forkConfig) => prismAuthsDir(forkConfig.prism, directories, path)), + ); + + const lastSyncRef = yield* Ref.make<{ at?: string; error?: string }>({}); + const changesPubSub = yield* PubSub.unbounded(); + const loopRef = yield* Ref.make>>(Option.none()); + const lifecycle = yield* Semaphore.make(1); + const cycleLock = yield* Semaphore.make(1); + const runScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(runScope, Exit.void)); + + const section = flags.config.pipe(Effect.map((forkConfig) => forkConfig.prism?.sync)); + + /** Store first, environment second; an empty value in either counts as absent. */ + const readSecret = (storeName: string, envName: string) => + Effect.gen(function* () { + const stored = yield* secrets + .get(storeName) + .pipe(Effect.orElseSucceed(() => Option.none())); + const fromStore = Option.isSome(stored) + ? new TextDecoder().decode(stored.value).trim() + : undefined; + if (fromStore) return fromStore; + const fromEnv = env[envName]?.trim(); + return fromEnv || undefined; + }); + + const resolve = Effect.gen(function* () { + const sync = yield* section; + if (sync === undefined) { + return yield* new PrismSyncNotConfigured({ message: "fork.json has no prism.sync" }); + } + const keySecretName = sync.sharedKeySecretName ?? PRISM_SYNC_DEFAULT_KEY_SECRET_NAME; + const sharedSecret = yield* readSecret(keySecretName, SYNC_KEY_ENV); + if (sharedSecret === undefined) { + return yield* new PrismSyncNotConfigured({ + message: `no shared key (secret '${keySecretName}' or ${SYNC_KEY_ENV})`, + }); + } + const token = yield* readSecret( + sync.tokenSecretName ?? PRISM_SYNC_DEFAULT_TOKEN_SECRET_NAME, + SYNC_TOKEN_ENV, + ); + const resolved: ResolvedSync = { + role: sync.role, + key: deriveSyncKey(sharedSecret), + primaryUrl: sync.primaryUrl === undefined ? undefined : trimOrigin(sync.primaryUrl), + token, + tokenSecretName: sync.tokenSecretName ?? PRISM_SYNC_DEFAULT_TOKEN_SECRET_NAME, + interval: Duration.seconds(sync.intervalSeconds ?? PRISM_SYNC_DEFAULT_INTERVAL_SECONDS), + }; + return resolved; + }); + + const requireRole = (role: PrismSyncConfig["role"]) => + resolve.pipe( + Effect.filterOrFail( + (resolved) => resolved.role === role, + (resolved) => + new PrismSyncNotConfigured({ + message: `prism.sync.role is '${resolved.role}', this needs '${role}'`, + }), + ), + ); + + const listLocal = Effect.gen(function* () { + const authsDir = yield* currentAuthsDir; + const exists = yield* fs.exists(authsDir).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return [] as Array; + const names = yield* fs.readDirectory(authsDir).pipe(Effect.mapError(ioError("read auths"))); + return yield* Effect.forEach(names.filter(isAuthFileName), (name) => + fs.stat(path.join(authsDir, name)).pipe( + Effect.map((info): SyncStamp => ({ + id: name, + updatedAt: Option.match(info.mtime, { + onNone: () => EPOCH_ISO, + onSome: (date) => date.toISOString(), + }), + })), + Effect.mapError(ioError(`stat ${name}`)), + ), + ); + }); + + const readEntry = (key: SyncKey, stamp: SyncStamp) => + currentAuthsDir.pipe( + Effect.flatMap((authsDir) => fs.readFile(path.join(authsDir, stamp.id))), + Effect.mapError(ioError(`read ${stamp.id}`)), + Effect.flatMap(servingCredential), + Effect.flatMap((bytes) => encryptSyncEntry(key, bytes)), + Effect.mapError((error) => + error._tag === "PrismSyncCryptoError" + ? new PrismSyncFailedError({ reason: "crypto", message: error.message }) + : error, + ), + Effect.map((ciphertext): PrismSyncEntry => ({ ...stamp, ciphertext })), + ); + + // Temp file without the `.json` suffix so the sidecar's watcher never sees a + // half-written credential; the mtime is set to the entry's stamp so the next + // comparison is exact on both sides. + const writeEntry = (id: string, bytes: Uint8Array, updatedAt: string) => + Effect.gen(function* () { + const authsDir = yield* currentAuthsDir; + const target = path.join(authsDir, id); + const temp = path.join(authsDir, `.sync-${id}.tmp`); + // Numeric `utimes` arguments are seconds; the stamp keeps millisecond precision. + const stampSeconds = Date.parse(updatedAt) / 1000; + yield* fs.makeDirectory(authsDir, { recursive: true }); + yield* fs.writeFile(temp, bytes, { mode: 0o600 }); + yield* fs.chmod(temp, 0o600); + yield* fs.rename(temp, target); + yield* fs.utimes(target, stampSeconds, stampSeconds); + }).pipe(Effect.mapError(ioError(`write ${id}`))); + + const removeEntry = (id: string) => + currentAuthsDir.pipe( + Effect.flatMap((authsDir) => fs.remove(path.join(authsDir, id))), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error), + ), + Effect.mapError(ioError(`remove ${id}`)), + ); + + /** Expired tombstones are dropped on read, so nothing depends on a separate sweep. */ + const readTombstones: Effect.Effect< + ReadonlyArray, + PrismSyncFailedError + > = Effect.gen(function* () { + const exists = yield* fs.exists(tombstonesPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return []; + const text = yield* fs + .readFileString(tombstonesPath) + .pipe(Effect.mapError(ioError("read tombstones"))); + const decoded = decodeTombstoneFile(text); + if (Exit.isFailure(decoded)) { + return yield* new PrismSyncFailedError({ + reason: "io", + message: `tombstones.json is not a { "": "" } object`, + }); + } + const tombstones = Object.entries(decoded.value) + .map(([id, deletedAt]): SyncTombstone => ({ id, deletedAt })) + .sort((a, b) => (a.id < b.id ? -1 : 1)); + return pruneSyncTombstones(tombstones, yield* nowMillis); + }); + + const writeTombstones = (tombstones: ReadonlyArray) => + Effect.gen(function* () { + const temp = path.join(rootDir, ".tombstones.json.tmp"); + const contents = `${encodeTombstoneFile( + Object.fromEntries(tombstones.map((tombstone) => [tombstone.id, tombstone.deletedAt])), + )}\n`; + yield* fs.makeDirectory(rootDir, { recursive: true }); + yield* fs.writeFileString(temp, contents); + yield* fs.rename(temp, tombstonesPath); + }).pipe(Effect.mapError(ioError("write tombstones"))); + + const recordTombstone = (id: string) => + Effect.gen(function* () { + const known = yield* readTombstones; + const deletedAt = yield* nowIso; + const merged = planSyncMerge( + { files: [], tombstones: known }, + { files: [], tombstones: [{ id, deletedAt }] }, + ).tombstones; + yield* writeTombstones(merged); + }); + + const decryptAll = (key: SyncKey, entries: ReadonlyArray) => + Effect.forEach(entries, (entry) => + decryptSyncEntry(key, entry.ciphertext).pipe( + Effect.map((bytes) => ({ entry, bytes })), + Effect.mapError( + (error) => + new PrismSyncFailedError({ + reason: "crypto", + message: `${entry.id}: ${error.message}`, + }), + ), + ), + ); + + const exportBundle = Effect.gen(function* () { + const resolved = yield* requireRole("primary"); + const local = yield* listLocal; + const entries = yield* Effect.forEach(local, (stamp) => readEntry(resolved.key, stamp)); + const tombstones = yield* readTombstones; + const primaryEnvironmentId = yield* identity.getEnvironmentId; + return { + version: 3, + generatedAt: yield* nowIso, + primaryEnvironmentId, + entries, + tombstones, + } satisfies PrismSyncBundle; + }); + + const applyPush = ( + entries: ReadonlyArray, + tombstones: ReadonlyArray = [], + ) => + Effect.gen(function* () { + const resolved = yield* requireRole("primary"); + void resolved; + if (entries.length > 0 || tombstones.length > 0) { + return yield* new PrismSyncNotConfigured({ + message: + "The primary owns pooled accounts. Replica credential pushes are no longer accepted.", + }); + } + return { written: [], skipped: [], deleted: [] } satisfies PrismSyncPushResult; + }); + + const status = Effect.gen(function* () { + const sync = yield* section; + const last = yield* Ref.get(lastSyncRef); + return { + role: sync?.role ?? "standalone", + ...(sync?.primaryUrl !== undefined ? { primaryUrl: trimOrigin(sync.primaryUrl) } : {}), + ...(sync !== undefined + ? { intervalSeconds: sync.intervalSeconds ?? PRISM_SYNC_DEFAULT_INTERVAL_SECONDS } + : {}), + ...(last.at !== undefined ? { lastSyncAt: last.at } : {}), + ...(last.error !== undefined ? { lastSyncError: last.error } : {}), + } satisfies PrismSyncStatus; + }); + + const publishStatus = status.pipe( + Effect.flatMap((current) => PubSub.publish(changesPubSub, current)), + ); + + const syncNow = cycleLock.withPermits(1)( + Effect.gen(function* () { + const resolved = yield* requireRole("replica"); + if (resolved.primaryUrl === undefined || resolved.token === undefined) { + return yield* new PrismSyncNotConfigured({ + message: `replica needs prism.sync.primaryUrl and a token (secret '${resolved.tokenSecretName}' or ${SYNC_TOKEN_ENV})`, + }); + } + const target = { primaryUrl: resolved.primaryUrl, token: resolved.token }; + const bundle = yield* transport.fetchExport(target); + if (bundle.version !== 3) { + return yield* new PrismSyncNotConfigured({ + message: + "Upgrade the primary: replicas require version 3 serving snapshots with primary-owned refresh.", + }); + } + const ids = new Set(bundle.entries.map((entry) => entry.id)); + if (ids.size !== bundle.entries.length) { + return yield* new PrismSyncFailedError({ + reason: "io", + message: "Duplicate account in sync snapshot.", + }); + } + // Validate the whole snapshot before any deletion or write. Strip again + // defensively so even a misconfigured primary cannot give a replica refresh ownership. + const decrypted = yield* decryptAll(resolved.key, bundle.entries); + const pulled = yield* Effect.forEach(decrypted, ({ entry, bytes }) => + servingCredential(bytes).pipe(Effect.map((bytes) => ({ entry, bytes }))), + ); + const local = yield* listLocal; + const authsDir = yield* currentAuthsDir; + for (const { entry, bytes } of pulled) { + const existing = yield* fs.readFile(path.join(authsDir, entry.id)).pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + Effect.mapError(ioError("read serving credential")), + ); + if (existing === undefined || !Buffer.from(existing).equals(Buffer.from(bytes))) { + yield* writeEntry(entry.id, bytes, entry.updatedAt); + } + } + const removed = local.filter((entry) => !ids.has(entry.id)); + for (const entry of removed) yield* removeEntry(entry.id); + yield* writeTombstones(bundle.tombstones ?? []); + yield* Ref.set(lastSyncRef, { at: yield* nowIso }); + yield* Effect.logInfo("prism sync: cycle complete", { + pulled: pulled.length, + deleted: removed.length, + }); + }).pipe( + Effect.tapError((error) => + Ref.update(lastSyncRef, (last) => ({ ...last, error: error.message })), + ), + Effect.ensuring(publishStatus), + ), + ); + + const loop = Effect.gen(function* () { + const resolved = yield* resolve; + if (resolved.role !== "replica") return; + yield* ticker(resolved.interval).pipe( + Stream.runForEach(() => + syncNow.pipe( + Effect.catch((error) => + Effect.logWarning("prism sync: cycle failed", { cause: error.message }), + ), + ), + ), + ); + }).pipe( + Effect.catchTag("PrismSyncNotConfigured", (error) => + Effect.logInfo("prism sync: idle", { cause: error.message }), + ), + ); + + const start = Effect.gen(function* () { + if (Option.isSome(yield* Ref.get(loopRef))) return; + const fiber = yield* loop.pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(runScope)); + yield* Ref.set(loopRef, Option.some(fiber)); + }); + + const stop = Effect.gen(function* () { + const fiber = yield* Ref.getAndSet(loopRef, Option.none()); + if (Option.isSome(fiber)) yield* Fiber.interrupt(fiber.value); + }); + + const apply = (values: { readonly prism: boolean }) => + lifecycle.withPermits(1)(values.prism ? start : stop); + + yield* apply(yield* flags.current); + yield* flags.changes.pipe( + Stream.runForEach(apply), + Effect.ignoreCause({ log: true }), + Effect.forkIn(runScope), + ); + + return PrismSyncService.of({ + status, + changes: Stream.fromPubSub(changesPubSub), + exportBundle, + applyPush, + syncNow, + recordTombstone, + }); +}); + +const transportError = (message: string) => (cause: unknown) => + new PrismSyncFailedError({ + reason: "transport", + message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + +/** Plain bearer HTTP against the primary's environment origin. Loopback or tailnet; no TLS assumed. */ +export const transportLayer = Layer.succeed( + PrismSyncTransport, + PrismSyncTransport.of({ + fetchExport: (input) => + HttpClient.HttpClient.pipe( + Effect.flatMap((client) => + client.get(`${input.primaryUrl}${PRISM_API_PATHS.syncExport}`, { + headers: { Authorization: `Bearer ${input.token}` }, + }), + ), + Effect.flatMap(HttpClientResponse.schemaBodyJson(PrismSyncBundle)), + Effect.mapError(transportError("sync export")), + Effect.provide(FetchHttpClient.layer), + ), + push: (input) => + HttpClient.HttpClient.pipe( + Effect.flatMap((client) => + client.execute( + HttpClientRequest.post(`${input.primaryUrl}${PRISM_API_PATHS.syncPush}`, { + headers: { + Authorization: `Bearer ${input.token}`, + "content-type": "application/json", + }, + }).pipe( + HttpClientRequest.bodyText( + JSON.stringify({ entries: input.entries, tombstones: input.tombstones }), + ), + ), + ), + ), + Effect.flatMap(HttpClientResponse.schemaBodyJson(PrismSyncPushResult)), + Effect.mapError(transportError("sync push")), + Effect.provide(FetchHttpClient.layer), + ), + }), +); + +/** Production wiring. Needs ServerConfig, ForkFlagsService, ServerSecretStore, ServerEnvironmentIdentity, and the Node services. */ +export const layer = Layer.effect(PrismSyncService, make).pipe(Layer.provide(transportLayer)); + +/** The service body alone; tests supply the transport and ticker. */ +export const layerWithoutRuntime = Layer.effect(PrismSyncService, make); diff --git a/apps/server/src/fork/prism/PrismSyncCrypto.test.ts b/apps/server/src/fork/prism/PrismSyncCrypto.test.ts new file mode 100644 index 000000000000..d619b2b2b1f8 --- /dev/null +++ b/apps/server/src/fork/prism/PrismSyncCrypto.test.ts @@ -0,0 +1,39 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import { decryptSyncEntry, deriveSyncKey, encryptSyncEntry } from "./PrismSyncCrypto.ts"; + +const plaintext = new TextEncoder().encode('{"type":"codex","email":"a@example.com"}'); + +it.effect("round-trips an entry and never repeats a nonce", () => + Effect.gen(function* () { + const key = deriveSyncKey("shared-secret"); + const first = yield* encryptSyncEntry(key, plaintext); + const second = yield* encryptSyncEntry(key, plaintext); + assert.notEqual(first, second); + assert.deepEqual(yield* decryptSyncEntry(key, first), plaintext); + assert.deepEqual(yield* decryptSyncEntry(key, second), plaintext); + }), +); + +it.effect("rejects a tampered payload, a foreign key, and garbage", () => + Effect.gen(function* () { + const key = deriveSyncKey("shared-secret"); + const ciphertext = yield* encryptSyncEntry(key, plaintext); + const bytes = Buffer.from(ciphertext, "base64"); + bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xff; + const tampered = yield* decryptSyncEntry(key, bytes.toString("base64")).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(tampered)); + const foreign = yield* decryptSyncEntry(deriveSyncKey("other"), ciphertext).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(foreign)); + const garbage = yield* decryptSyncEntry(key, "AA==").pipe(Effect.exit); + assert.isTrue(Exit.isFailure(garbage)); + }), +); + +it("derives the same key from the same secret and different keys otherwise", () => { + assert.deepEqual(deriveSyncKey("s"), deriveSyncKey("s")); + assert.notDeepEqual(deriveSyncKey("s"), deriveSyncKey("t")); + assert.equal(deriveSyncKey("s").length, 32); +}); diff --git a/apps/server/src/fork/prism/PrismSyncCrypto.ts b/apps/server/src/fork/prism/PrismSyncCrypto.ts new file mode 100644 index 000000000000..0356cd305f99 --- /dev/null +++ b/apps/server/src/fork/prism/PrismSyncCrypto.ts @@ -0,0 +1,71 @@ +/** + * Payload encryption for cross-machine auth-file sync. AES-256-GCM with a key + * derived from the shared secret through HKDF-SHA256 and a fresh 12-byte + * nonce per entry; the wire form is `base64(nonce || tag || data)`. Pure + * functions over `node:crypto`; nothing here logs. + */ +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const NONCE_BYTES = 12; +const TAG_BYTES = 16; +const KEY_BYTES = 32; +const HKDF_SALT = "q1code-prism-sync"; +const HKDF_INFO = "auth-file-v1"; + +/** An opaque 32-byte AES key. Keep it out of logs and error messages. */ +export type SyncKey = Uint8Array & { readonly __brand: "PrismSyncKey" }; + +export class PrismSyncCryptoError extends Schema.TaggedErrorClass()( + "PrismSyncCryptoError", + { + operation: Schema.Literals(["encrypt", "decrypt"]), + }, +) { + override get message(): string { + return this.operation === "decrypt" + ? "Sync entry could not be decrypted (wrong shared secret or corrupt payload)." + : "Sync entry could not be encrypted."; + } +} + +export const deriveSyncKey = (sharedSecret: string): SyncKey => + new Uint8Array( + NodeCrypto.hkdfSync("sha256", sharedSecret, HKDF_SALT, HKDF_INFO, KEY_BYTES), + ) as SyncKey; + +export const encryptSyncEntry = ( + key: SyncKey, + plaintext: Uint8Array, +): Effect.Effect => + Effect.try({ + try: () => { + const nonce = NodeCrypto.randomBytes(NONCE_BYTES); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); + const data = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([nonce, cipher.getAuthTag(), data]).toString("base64"); + }, + catch: () => new PrismSyncCryptoError({ operation: "encrypt" }), + }); + +export const decryptSyncEntry = ( + key: SyncKey, + ciphertext: string, +): Effect.Effect => + Effect.try({ + try: () => { + const bytes = Buffer.from(ciphertext, "base64"); + if (bytes.length < NONCE_BYTES + TAG_BYTES) { + throw new Error("short"); + } + const nonce = bytes.subarray(0, NONCE_BYTES); + const tag = bytes.subarray(NONCE_BYTES, NONCE_BYTES + TAG_BYTES); + const data = bytes.subarray(NONCE_BYTES + TAG_BYTES); + const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce); + decipher.setAuthTag(tag); + return new Uint8Array(Buffer.concat([decipher.update(data), decipher.final()])); + }, + catch: () => new PrismSyncCryptoError({ operation: "decrypt" }), + }); diff --git a/apps/server/src/fork/prism/PrismUsageLimitSource.test.ts b/apps/server/src/fork/prism/PrismUsageLimitSource.test.ts new file mode 100644 index 000000000000..891518ae06e0 --- /dev/null +++ b/apps/server/src/fork/prism/PrismUsageLimitSource.test.ts @@ -0,0 +1,180 @@ +/** + * The `UsageLimitSources.ts` seam end to end: upstream's reader, built through + * its real `make`, polls the Prism entry like a hub the user configured, and + * drops it again when the toggle goes off. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { UsageLimitSourceId } from "@t3tools/contracts"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as ServerSettings from "../../serverSettings.ts"; +import { UsageLimitSources, make } from "../../usage/UsageLimitSources.ts"; +import { type PrismEndpoint, publishPrismEndpoint } from "./PrismEnvironment.ts"; + +const endpoint: PrismEndpoint = { + baseUrl: "http://127.0.0.1:8317", + apiKey: "client-key", + managementSecret: "mgmt", + usageSource: true, +}; + +const quotaStatus = { + accounts: { + "claude-a@example.com.json": { provider: "claude", five_hour: { used_percent: 25 } }, + }, +}; + +/** A proxy that answers the quota status only for Prism's management secret. */ +const makeProxy = () => { + const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = []; + const client = HttpClient.make((request) => + Effect.sync(() => { + const authorization = request.headers["authorization"]; + requests.push({ url: request.url, authorization }); + return HttpClientResponse.fromWeb( + request, + authorization === "Bearer mgmt" + ? Response.json(quotaStatus) + : new Response("nope", { status: 401 }), + ); + }), + ); + return { layer: Layer.succeed(HttpClient.HttpClient, client), requests }; +}; + +const makeSources = ( + proxy: ReturnType, + settings: Parameters[0] = {}, +) => + Layer.effect(UsageLimitSources, make).pipe( + Layer.provide(proxy.layer), + Layer.provide(ServerSettings.layerTest(settings)), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + ); + +const firstSnapshotWhere = ( + sources: UsageLimitSources["Service"], + predicate: (snapshots: ReadonlyArray<{ readonly id: string }>) => boolean, +) => sources.streamChanges.pipe(Stream.filter(predicate), Stream.take(1), Stream.runCollect); + +it.layer(NodeServices.layer, { excludeTestServices: true })("Prism usage-limit source", (it) => { + it.effect( + "polls the Prism entry like a configured hub and drops it when the toggle goes off", + () => + Effect.gen(function* () { + const proxy = makeProxy(); + publishPrismEndpoint(endpoint); + try { + yield* Effect.gen(function* () { + const sources = yield* UsageLimitSources; + const [published] = yield* firstSnapshotWhere(sources, (s) => s.length > 0); + assert.equal(published?.length, 1); + const prism = published![0]!; + assert.equal(prism.id, "prism"); + assert.equal(prism.kind, "cliproxy"); + assert.equal(prism.label, "Prism"); + assert.isUndefined(prism.error); + assert.equal(prism.accounts.length, 1); + assert.equal(prism.accounts[0]?.email, "a@example.com"); + assert.equal(prism.accounts[0]?.driver, "claudeAgent"); + assert.deepEqual(proxy.requests, [ + { + url: "http://127.0.0.1:8317/v0/management/quota-scheduler/status", + authorization: "Bearer mgmt", + }, + ]); + + const emptied = yield* firstSnapshotWhere(sources, (s) => s.length === 0).pipe( + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + publishPrismEndpoint({ ...endpoint, usageSource: false }); + const [after] = yield* Fiber.join(emptied); + assert.deepEqual(after, []); + assert.deepEqual(yield* sources.current, []); + }).pipe(Effect.provide(makeSources(proxy))); + } finally { + publishPrismEndpoint(undefined); + } + }), + ); + + it.effect("leaves a hub the user pointed at the proxy's origin alone", () => + Effect.gen(function* () { + const proxy = makeProxy(); + publishPrismEndpoint(endpoint); + try { + yield* Effect.gen(function* () { + const sources = yield* UsageLimitSources; + const [snapshots] = yield* firstSnapshotWhere(sources, (s) => s.length > 0); + assert.deepEqual( + snapshots?.map((s) => s.id), + ["mine"], + ); + // The user's key, not Prism's, is what the hub saw. + assert.deepEqual( + proxy.requests.map((r) => r.authorization), + ["Bearer user"], + ); + }).pipe( + Effect.provide( + makeSources(proxy, { + usageLimitSources: { + [UsageLimitSourceId.make("mine")]: { + kind: "cliproxy", + url: "http://127.0.0.1:8317/", + managementKey: "user", + enabled: true, + }, + }, + }), + ), + ); + } finally { + publishPrismEndpoint(undefined); + } + }), + ); + + it.effect("reads only the configured hubs while nothing is published (flag off)", () => + Effect.gen(function* () { + const proxy = makeProxy(); + publishPrismEndpoint(undefined); + yield* Effect.gen(function* () { + const sources = yield* UsageLimitSources; + const [snapshots] = yield* firstSnapshotWhere(sources, (s) => s.length > 0); + assert.deepEqual( + snapshots?.map((s) => s.id), + ["hub"], + ); + assert.deepEqual( + proxy.requests.map((r) => r.url), + ["https://hub.example/v0/management/quota-scheduler/status"], + ); + }).pipe( + Effect.provide( + makeSources(proxy, { + usageLimitSources: { + [UsageLimitSourceId.make("hub")]: { + kind: "cliproxy", + url: "https://hub.example", + managementKey: "user", + enabled: true, + }, + }, + }), + ), + ); + }), + ); +}); diff --git a/apps/server/src/fork/releaseTarball.testing.ts b/apps/server/src/fork/releaseTarball.testing.ts new file mode 100644 index 000000000000..9e4c61bb5b5e --- /dev/null +++ b/apps/server/src/fork/releaseTarball.testing.ts @@ -0,0 +1,33 @@ +import { releaseChecksumsUrl, releaseTarballName, releaseTarballUrl } from "@q1code/core/brand"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ReleaseDownloader, ReleaseDownloadError, sha256Hex } from "./releaseTarball.ts"; + +/** + * In-memory GitHub release for tests: serves `checksums.txt` and the tarball + * for one version. `checksums` overrides the published text to simulate a + * mismatch or a missing entry; `tarball` overrides the bytes. + */ +export function releaseDownloaderTestLayer( + version: string, + options: { + readonly tarball?: Uint8Array; + readonly checksums?: string; + } = {}, +): Layer.Layer { + const tarball = options.tarball ?? new TextEncoder().encode(`fake tarball ${version}\n`); + const checksums = options.checksums ?? `${sha256Hex(tarball)} ${releaseTarballName(version)}\n`; + const assets = new Map([ + [releaseChecksumsUrl(version), new TextEncoder().encode(checksums)], + [releaseTarballUrl(version), tarball], + ]); + return Layer.succeed(ReleaseDownloader, { + download: (url) => { + const bytes = assets.get(url); + return bytes === undefined + ? Effect.fail(new ReleaseDownloadError({ url })) + : Effect.succeed(bytes); + }, + }); +} diff --git a/apps/server/src/fork/releaseTarball.ts b/apps/server/src/fork/releaseTarball.ts new file mode 100644 index 000000000000..853af7b7e807 --- /dev/null +++ b/apps/server/src/fork/releaseTarball.ts @@ -0,0 +1,147 @@ +import { + BRAND, + releaseChecksumsUrl, + releaseTarballName, + releaseTarballUrl, +} from "@q1code/core/brand"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; + +/** + * GitHub-only distribution for the pinned runtime. Upstream installs + * `t3@` from the npm registry, which carries an integrity field. + * A GitHub release asset does not, so the tarball is downloaded here, checked + * against the release's `checksums.txt` (` ` lines, as + * `sha256sum` prints), and only then handed to `npm install` as a local file. + */ + +export class ReleaseTarballError extends Schema.TaggedErrorClass()( + "ReleaseTarballError", + { + reason: Schema.Literals([ + "download-failed", + "checksum-missing", + "checksum-mismatch", + "write-failed", + ]), + version: Schema.String, + asset: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + /** Reads as a `PinnedRuntimeInstallError` step so the existing install error keeps its shape. */ + get step(): string { + switch (this.reason) { + case "download-failed": + return `downloading ${this.asset} from the ${BRAND.productName} release`; + case "checksum-missing": + return `finding ${this.asset} in the release checksums`; + case "checksum-mismatch": + return `verifying the sha256 of ${this.asset}`; + case "write-failed": + return `saving ${this.asset} for install`; + } + } + + override get message(): string { + return `Release tarball for ${BRAND.packageName}@${this.version} failed while ${this.step}.`; + } +} + +export class ReleaseDownloadError extends Schema.TaggedErrorClass()( + "ReleaseDownloadError", + { + url: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not download ${this.url}.`; + } +} + +/** Fetches a release asset as bytes. Tests provide a fake; production uses `fetchReleaseDownloader`. */ +export class ReleaseDownloader extends Context.Service< + ReleaseDownloader, + { + readonly download: (url: string) => Effect.Effect; + } +>()("t3/fork/releaseTarball/ReleaseDownloader") {} + +export const fetchReleaseDownloader: ReleaseDownloader["Service"] = { + download: (url) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .get(url) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + return new Uint8Array(yield* response.arrayBuffer); + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((cause) => new ReleaseDownloadError({ url, cause })), + ), +}; + +export function parseChecksums(text: string): ReadonlyMap { + const entries = new Map(); + for (const line of text.split("\n")) { + const match = /^([0-9a-fA-F]{64})\s+\*?(\S.*?)\s*$/.exec(line.trim()); + if (match?.[1] !== undefined && match[2] !== undefined) { + entries.set(match[2], match[1].toLowerCase()); + } + } + return entries; +} + +export const sha256Hex = (bytes: Uint8Array) => + NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +/** + * Downloads and verifies `-.tgz` into `directory`, then + * returns the local tarball path for `npm install`. Fails closed: no checksum + * entry or a mismatch never leaves a tarball behind. + */ +export const stageReleaseTarball = Effect.fn("fork.release_tarball.stage")(function* ( + input: { + readonly version: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }, + directory: string, +) { + const downloader = Option.getOrElse( + yield* Effect.serviceOption(ReleaseDownloader), + () => fetchReleaseDownloader, + ); + const asset = releaseTarballName(input.version); + const fail = (reason: ReleaseTarballError["reason"], cause?: unknown) => + new ReleaseTarballError({ reason, version: input.version, asset, cause }); + + const checksumsText = yield* downloader.download(releaseChecksumsUrl(input.version)).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.mapError((cause) => fail("download-failed", cause)), + ); + const expected = parseChecksums(checksumsText).get(asset); + if (expected === undefined) { + return yield* fail("checksum-missing"); + } + + const tarball = yield* downloader + .download(releaseTarballUrl(input.version)) + .pipe(Effect.mapError((cause) => fail("download-failed", cause))); + if (sha256Hex(tarball) !== expected) { + return yield* fail("checksum-mismatch"); + } + + const tarballPath = input.path.join(directory, asset); + yield* input.fs + .writeFile(tarballPath, tarball) + .pipe(Effect.mapError((cause) => fail("write-failed", cause))); + return tarballPath; +}); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 643e1e7a0d5b..999a0c202216 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; @@ -30,10 +31,13 @@ import { TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; +const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; @@ -511,7 +517,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -555,7 +561,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -620,6 +626,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; + sourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -659,7 +666,10 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make.pipe( + (input?.sourceControlProvider === undefined + ? GitHubSourceControlProvider.make + : Effect.succeed(input.sourceControlProvider) + ).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -1146,8 +1156,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/saved-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRef: "main", + headRef: "feature/saved-branch", state: "open", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); @@ -1179,6 +1196,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "develop", headRefName: "main", state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00Z", }, ]), @@ -1188,8 +1206,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00.000Z", }); }), @@ -1239,8 +1259,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/deleted-local-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( @@ -1303,8 +1325,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/deleted-fork-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-05T15:00:00.000Z", }); expect( @@ -1423,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-07T15:00:00Z", }, ]), + encodeCliJson([ + { + number: 221, + title: "New PR on the same branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), ], }, }); @@ -1436,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status.pr?.state).toBe("merged"); expect(pullRequest?.state).toBe("merged"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + const refreshed = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/shared-pr-cache" }, + { refresh: true }, + ); + expect(refreshed).toMatchObject({ + number: 221, + state: "open", + repositoryKey: "github.com/pingdotgg/codething-mvp", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); }), ); @@ -1450,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); yield* runGit(repoDir, ["checkout", "main"]); - const { manager } = yield* makeManager({ + const { manager, ghCalls } = yield* makeManager({ ghScenario: { failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -1465,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("SourceControlProviderError"); + const refreshError = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) + .pipe(Effect.flip); + expect(refreshError._tag).toBe("SourceControlProviderError"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); }), ); @@ -1585,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); }); + it.each([ + [ + "https://github.example.com/team/repository/pull/42?tab=files", + "github.example.com/team/repository", + ], + [ + "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42", + "gitlab.example.com/group/subgroup/repository", + ], + ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], + [ + "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + "dev.azure.com/org/project/_git/repository", + ], + [ + "https://org.visualstudio.com/project/_git/repository/pullrequest/42", + "org.visualstudio.com/project/_git/repository", + ], + [ + "https://gitlab.example/group/pull/123/repository/-/merge_requests/42", + "gitlab.example/group/pull/123/repository", + ], + ["https://github.example.com/team/repository/issues/42", null], + ] as const)("reads the repository from the returned PR URL %s", (url, expected) => { + expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected); + }); + + it.effect("distinguishes Enterprise forks with the same head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature"]); + yield* runGit(repoDir, ["push", "-u", "fork", "feature"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.example.com:team/repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.example.com:alice/repository.git", + forkDir, + ); + const output = encodeCliJson([ + { + number: 2, + title: "Another fork", + url: "https://github.example.com/team/repository/pull/2", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "bob/repository" }, + headRepositoryOwner: { login: "bob" }, + }, + { + number: 1, + title: "This fork", + url: "https://github.example.com/team/repository/pull/1", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-07T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "alice/repository" }, + headRepositoryOwner: { login: "alice" }, + }, + ]); + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "alice:feature": output, + "fork:feature": output, + feature: output, + }, + }, + }); + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({ + number: 1, + repositoryKey: "github.example.com/team/repository", + }); + }), + ); + + it.effect.each([ + "git@gitlab.com:Group/Subgroup/Fork.git", + "https://gitlab.com/Group/Subgroup/Fork.git", + ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + const branch = "feature/NestedGroups"; + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", branch]); + yield* runGit(repoDir, ["push", "-u", "fork", branch]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:Group/Upstream/Repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); + const output = encodeCliJson([ + { + iid: 2, + title: "Another subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-08T15:00:00Z", + source_project_id: 102, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Other/Fork" }, + }, + { + iid: 1, + title: "This subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-07T15:00:00Z", + source_project_id: 101, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Subgroup/Fork" }, + }, + ]); + const calls: VcsProcess.VcsProcessInput[] = []; + const provider = yield* GitLabSourceControlProvider.make.pipe( + Effect.provide( + GitLabCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + return fakeGhOutput(output); + }), + }), + ), + ), + ), + ); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); + + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({ + number: 1, + repositoryKey: "gitlab.com/group/upstream/repository", + }); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.command).toBe("glab"); + expect(call.args).toEqual([ + "mr", + "list", + "--source-branch", + branch, + "--all", + "--per-page", + "20", + "--output", + "json", + ]); + } + }), + ); + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => @@ -1691,7 +1921,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1757,7 +1987,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2140,8 +2370,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/fork-settle", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-05-02T10:00:00.000Z", }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2d8af0c9e8bb..f60eb2781872 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti readonly refreshMissingPullRequest?: boolean; } +export type GitBranchPullRequest = NonNullable & { + readonly repositoryKey: string | null; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; +}; + interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; @@ -96,13 +103,10 @@ export class GitManager extends Context.Service< options?: GitRemoteStatusOptions, ) => Effect.Effect; /** Resolve the PR for a saved branch without changing the current checkout. */ - readonly branchPullRequest: (input: { - readonly cwd: string; - readonly branch: string; - }) => Effect.Effect< - { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, - GitManagerServiceError - >; + readonly branchPullRequest: ( + input: { readonly cwd: string; readonly branch: string }, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -171,6 +175,8 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -207,9 +213,26 @@ interface BranchHeadContext { isCrossRepository: boolean; } +export function pullRequestRepositoryKey(value: string): string | null { + try { + const url = new URL(value); + const match = + /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + url.pathname, + ); + if (match?.[1] === undefined) return null; + url.pathname = match[1]; + url.search = ""; + url.hash = ""; + return normalizeGitRemoteUrl(url.toString()); + } catch { + return null; + } +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); - const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null; } @@ -247,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } -function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { +function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; @@ -266,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } + // GitLab reports the top-level group as owner. The full path distinguishes subgroups. const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; @@ -406,6 +430,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -1252,7 +1278,7 @@ export const make = Effect.gen(function* () { } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, @@ -2022,7 +2048,7 @@ export const make = Effect.gen(function* () { }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", - )(function* ({ cwd, branch }) { + )(function* ({ cwd, branch }, options) { const cacheCwd = yield* normalizeStatusCacheKey(cwd); const remotes = yield* gitCore.execute({ operation: "GitManager.branchPullRequest.remotes", @@ -2102,6 +2128,14 @@ export const make = Effect.gen(function* () { localBranchExists, ...(localBranchExists ? {} : { remoteName }), }); + if (options?.refresh) { + // A completed turn can create a PR or reuse a merged PR's branch. + // Refresh successful answers, but keep failed lookups' retry backoff. + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); + } let cached = yield* Cache.get(prLookupCache, cacheKey); // The cached head context may have resolved on a different remote than // the saved upstream: a branch tracking origin/main but pushed to a fork @@ -2156,8 +2190,14 @@ export const make = Effect.gen(function* () { ) { return null; } - const statusPr = toStatusPr(latest); - return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + return { + ...toStatusPr(latest), + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + // Hosting CLIs can select an upstream repository instead of origin. + // The returned PR URL names the repository that actually owns it. + repositoryKey: pullRequestRepositoryKey(latest.url), + }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 160755a8f9b2..ec7070809435 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,33 +188,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => - Effect.sync(() => { - const defaultsByCommand = new Map( - Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), - ); - - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); - assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); - assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); - assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); - assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); - assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); - assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); - assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); - }), - ); - it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 2f2c4b30525b..9e141600713f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import { CommandId, + CheckpointRef, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, @@ -291,6 +292,7 @@ describe("CheckpointReactor", () => { async function createHarness(options?: { readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; + readonly initializeGit?: boolean; readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly threadBranch?: string | null; @@ -300,8 +302,12 @@ describe("CheckpointReactor", () => { readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; readonly pullRequestRefreshCalls?: Array; + readonly pullRequestRefresh?: Effect.Effect; }) { const cwd = createGitRepository(); + if (options?.initializeGit === false) { + NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); + } tempDirs.push(cwd); const provider = createProviderServiceHarness( cwd, @@ -352,7 +358,7 @@ describe("CheckpointReactor", () => { refreshPullRequestStatus: (cwd: string) => Effect.sync(() => { options?.pullRequestRefreshCalls?.push(cwd); - }).pipe(Effect.as(null)), + }).pipe(Effect.andThen(options?.pullRequestRefresh ?? Effect.void), Effect.as(null)), streamStatus: () => Stream.empty, }); @@ -667,6 +673,167 @@ describe("CheckpointReactor", () => { }), ); + effectIt.effect.each(["turn.completed", "turn.aborted"] as const)( + "captures every edit after a mid-turn diff update on %s", + (terminalEventType) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-1"); + const assistantMessageId = MessageId.make("assistant:mid-turn"); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-mid-turn-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-mid-turn-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + }); + + NodeFS.writeFileSync(NodePath.join(harness.cwd, "early.ts"), "export const early = 1;\n"); + yield* harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-mid-turn-diff"), + threadId, + turnId, + completedAt: createdAt, + checkpointRef: CheckpointRef.make("provider-diff:mid-turn"), + assistantMessageId, + status: "missing", + files: [], + checkpointTurnCount: 1, + createdAt, + }); + yield* Effect.promise(harness.drain); + + NodeFS.writeFileSync(NodePath.join(harness.cwd, "late.ts"), "export const late = 2;\n"); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-mid-turn-settled"), + threadId, + session: { + threadId, + status: terminalEventType === "turn.aborted" ? "interrupted" : "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + harness.provider.emit({ + eventId: EventId.make("evt-mid-turn-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + ...(terminalEventType === "turn.completed" + ? { type: "turn.completed", payload: { state: "completed" } } + : { type: "turn.aborted", payload: { reason: "Interrupted by user." } }), + }); + yield* Effect.promise(harness.drain); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 1))).toBe(true); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId, + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "turn.processing.quiesced", + turnId, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.checkpoints).toHaveLength(1); + expect(thread?.checkpoints[0]?.status).toBe("ready"); + expect(thread?.latestTurn?.state).toBe( + terminalEventType === "turn.aborted" ? "interrupted" : "completed", + ); + expect(thread?.checkpoints[0]?.assistantMessageId).toBe(assistantMessageId); + expect(thread?.checkpoints[0]?.files.map((file) => file.path)).toEqual([ + "early.ts", + "late.ts", + ]); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "late.ts"), + ).toBe("export const late = 2;\n"); + + const followUpTurnId = asTurnId("turn-2"); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-follow-up-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: followUpTurnId, + }); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-follow-up-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: followUpTurnId, + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId: followUpTurnId, + checkpointTurnCount: 2, + }); + const followUp = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect( + followUp?.checkpoints.find((checkpoint) => checkpoint.turnId === followUpTurnId), + ).toMatchObject({ checkpointTurnCount: 2, files: [] }); + }), + ); + + it("does not capture an aborted turn without a matching start or active session", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + harness.provider.emit({ + type: "turn.aborted", + eventId: EventId.make("evt-untracked-abort"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-untracked"), + payload: { reason: "Interrupted before the turn started." }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect(thread?.checkpoints).toEqual([]); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(false); + }); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ @@ -713,6 +880,49 @@ describe("CheckpointReactor", () => { expect(pullRequestRefreshCalls).toEqual([harness.cwd]); }); + effectIt.effect("captures files while the pull request lookup is still pending", () => + Effect.gen(function* () { + const lookupStarted = yield* Deferred.make(); + const finishLookup = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefresh: Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishLookup)), + ), + }), + ); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "completed turn\n"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-slow-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-slow-pr"), + payload: { state: "completed" }, + }); + + yield* Deferred.await(lookupStarted); + yield* Effect.gen(function* () { + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId: "turn-slow-pr", + }); + expect( + gitShowFileAtRef( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + "README.md", + ), + ).toBe("completed turn\n"); + }).pipe(Effect.ensuring(Deferred.succeed(finishLookup, undefined))); + yield* Effect.promise(harness.drain); + }), + ); + it("re-asks for the pull request after adopting a drifted checkout", async () => { const pullRequestRefreshCalls: string[] = []; const harness = await createHarness({ @@ -996,52 +1206,148 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - it("appends capture failure activity when turn diff summary cannot be derived", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), + effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-missing-baseline"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: ProviderDriverKind.make("codex"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + checkpointTurnCount: 1, + files: [], + }); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(true); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); - createdAt: "2026-01-01T00:00:00.000Z", - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-missing-baseline"), - payload: { state: "completed" }, - }); + effectIt.effect.each([ + { timing: "between turns", commit: false }, + { timing: "between turns", commit: true }, + { timing: "during a turn", commit: false }, + { timing: "during a turn", commit: true }, + ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const emit = (type: "turn.started" | "turn.completed", turn: number) => + harness.provider.emit({ + type, + eventId: EventId.make(`${type}-${turn}`), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: asTurnId(`turn-${turn}`), + ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}), + }); + emit("turn.started", 1); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n"); + emit("turn.completed", 1); + yield* Effect.promise(harness.drain); + expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ); + if (timing === "during a turn") { + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + runGit(harness.cwd, ["init", "--initial-branch=main"]); + if (commit) { + runGit(harness.cwd, ["add", "."]); + runGit(harness.cwd, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Initial", + ]); + } + if (timing === "between turns") { + // Exercise the domain entry point as well as the provider turn-start event. + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-after-git-init"), + threadId, + message: { + messageId: MessageId.make("message-after-git-init"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + checkpointTurnCount: 0, + }); + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n"); + emit("turn.completed", 2); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + yield* Effect.promise(harness.drain); + const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0]; + expect(firstCheckpoint?.files).toEqual( + timing === "between turns" + ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }] + : [], + ); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"), + ).toBe("after git\n"); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe( + timing === "between turns", + ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ).toBe(true); - }); + emit("turn.started", 3); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); + emit("turn.completed", 3); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 2, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[1]?.files).toEqual([ + { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, + ]); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ @@ -1080,6 +1386,36 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("does not create checkpoints while importing historical user messages", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); + + await runtime.runPromise( + harness.engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("cmd-import-history-without-checkpoint"), + threadId: ThreadId.make("thread-1"), + messages: [ + { + messageId: MessageId.make("imported-user-message"), + role: "user", + text: "A message from an existing agent session", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }), + ); + await harness.drain(); + + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 108abd5d06bb..eb77348180ff 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -216,9 +216,7 @@ const make = Effect.gen(function* () { return cwd; }); - // Shared tail for both capture paths: creates the git checkpoint ref, diffs - // it against the previous turn, then dispatches the domain events to update - // the orchestration read model. + // Capture the completed turn's files, then publish its summary and receipts. const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { readonly threadId: ThreadId; readonly turnId: TurnId; @@ -260,41 +258,46 @@ const make = Effect.gen(function* () { // reflects files created or deleted during this turn. yield* workspaceEntries.refresh(input.cwd); - const files = yield* checkpointStore - .diffCheckpoints({ - cwd: input.cwd, - fromCheckpointRef, - toCheckpointRef: targetCheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified" as const, - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.tapError((error) => - appendCaptureFailureActivity({ - threadId: input.threadId, - turnId: input.turnId, - detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, - createdAt: input.createdAt, - }), - ), - Effect.catch((error) => - Effect.logWarning("failed to derive checkpoint file summary", { - threadId: input.threadId, - turnId: input.turnId, - turnCount: input.turnCount, - detail: error.message, - }).pipe(Effect.as([])), - ), - ); + // Git may have been initialized during this turn, leaving no pre-turn + // snapshot. Keep the completion checkpoint for future turns, but do not + // invent a baseline or attempt a diff against a ref that does not exist. + const files = yield* ( + fromCheckpointExists + ? checkpointStore.diffCheckpoints({ + cwd: input.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }) + : Effect.succeed("") + ).pipe( + Effect.map((diff) => + parseTurnDiffFilesFromNumstat(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.tapError((error) => + appendCaptureFailureActivity({ + threadId: input.threadId, + turnId: input.turnId, + detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, + createdAt: input.createdAt, + }), + ), + Effect.catch((error) => + Effect.logWarning("failed to derive checkpoint file summary", { + threadId: input.threadId, + turnId: input.turnId, + turnCount: input.turnCount, + detail: error.message, + }).pipe(Effect.as([])), + ), + ); const assistantMessageId = input.assistantMessageId ?? @@ -353,9 +356,9 @@ const make = Effect.gen(function* () { }); }); - // Captures a real git checkpoint when a turn completes via a runtime event. + // Capture the files left by a completed or interrupted turn. const captureCheckpointFromTurnCompletion = Effect.fn("captureCheckpointFromTurnCompletion")( - function* (event: Extract) { + function* (event: Extract) { const turnId = toTurnId(event.turnId); if (!turnId) { return; @@ -412,75 +415,16 @@ const make = Effect.gen(function* () { thread, cwd: checkpointCwd, turnCount: nextTurnCount, - status: checkpointStatusFromRuntime(event.payload.state), - assistantMessageId: undefined, + status: + event.type === "turn.aborted" + ? "ready" + : checkpointStatusFromRuntime(event.payload.state), + assistantMessageId: existingPlaceholder?.assistantMessageId ?? undefined, createdAt: event.createdAt, }); }, ); - // Captures a real git checkpoint when a placeholder checkpoint (status "missing") - // is detected via a domain event. This replaces the placeholder with a real - // git-ref-based checkpoint. - // - // ProviderRuntimeIngestion creates placeholder checkpoints on turn.diff.updated - // events from the Codex runtime. This handler fires when the corresponding - // domain event arrives, allowing the reactor to capture the actual filesystem - // state into a git ref and dispatch a replacement checkpoint. - const captureCheckpointFromPlaceholder = Effect.fn("captureCheckpointFromPlaceholder")(function* ( - event: Extract, - ) { - const { threadId, turnId, checkpointTurnCount, status } = event.payload; - - // Only replace placeholders; skip events from our own real captures. - if (status !== "missing") { - return; - } - - const thread = yield* resolveThreadDetail(threadId); - if (!thread) { - yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", { - threadId, - }); - return; - } - - // If a real checkpoint already exists for this turn, skip. - if ( - thread.checkpoints.some( - (checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing", - ) - ) { - yield* Effect.logDebug( - "checkpoint capture from placeholder skipped: real checkpoint already exists", - { threadId, turnId }, - ); - return; - } - - const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId, - thread, - projects, - preferSessionRuntime: true, - }); - if (!checkpointCwd) { - return; - } - - yield* captureAndDispatchCheckpoint({ - threadId, - turnId, - thread, - cwd: checkpointCwd, - turnCount: checkpointTurnCount, - status: "ready", - assistantMessageId: event.payload.assistantMessageId ?? undefined, - createdAt: event.payload.completedAt, - }); - }); - const ensurePreTurnBaselineFromTurnStart = Effect.fn("ensurePreTurnBaselineFromTurnStart")( function* (event: Extract) { const turnId = toTurnId(event.turnId); @@ -661,6 +605,23 @@ const make = Effect.gen(function* () { ); }); + // Refreshing git status ends in a remote PR lookup under the vcs status + // write lock. Run it on its own worker so file capture for this turn (and + // checkpoints for other threads) never wait behind that network call. + const statusRefreshWorker = yield* makeDrainableWorker( + (event: Extract) => + refreshLocalGitStatusFromTurnCompletion(event).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to refresh git status after turn completion", { + threadId: event.threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ); + const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn( "ensurePreTurnBaselineFromDomainTurnStart", )(function* ( @@ -671,6 +632,7 @@ const make = Effect.gen(function* () { ) { if (event.type === "thread.message-sent") { if ( + event.metadata.historyImport === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null @@ -876,25 +838,6 @@ const make = Effect.gen(function* () { ); return; } - - // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") - // from a turn.diff.updated runtime event, capture the real git checkpoint to - // replace it. ProviderService broadcasts runtime events to each subscriber. - // This domain-event path also captures checkpoints from turn diff updates. - if (event.type === "thread.turn-diff-completed") { - yield* captureCheckpointFromPlaceholder(event).pipe( - Effect.catch((error) => - Effect.flatMap(nowIso, (createdAt) => - appendCaptureFailureActivity({ - threadId: event.payload.threadId, - turnId: event.payload.turnId, - detail: error.message, - createdAt, - }).pipe(Effect.catch(() => Effect.void)), - ), - ), - ); - } }); const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( @@ -927,7 +870,7 @@ const make = Effect.gen(function* () { const isTrackedTurn = sameId(startedTurnId, turnId); if (isTrackedTurn) startedTurns.delete(event.threadId); if (event.type === "turn.completed") { - yield* refreshLocalGitStatusFromTurnCompletion(event); + yield* statusRefreshWorker.enqueue(event); } if ( turnId !== null && @@ -939,7 +882,13 @@ const make = Effect.gen(function* () { pending.delete(event.threadId); yield* pullRequests.refreshAfterTurn; } - if (event.type === "turn.aborted") return; + if ( + event.type === "turn.aborted" && + !isTrackedTurn && + !sameId(thread?.session?.activeTurnId, turnId) + ) { + return; + } yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => Effect.flatMap(nowIso, (createdAt) => @@ -987,8 +936,7 @@ const make = Effect.gen(function* () { if ( event.type !== "thread.turn-start-requested" && event.type !== "thread.message-sent" && - event.type !== "thread.checkpoint-revert-requested" && - event.type !== "thread.turn-diff-completed" + event.type !== "thread.checkpoint-revert-requested" ) { return Effect.void; } @@ -1013,7 +961,7 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: worker.drain.pipe(Effect.andThen(statusRefreshWorker.drain)), } satisfies CheckpointReactorShape; }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 866e56ac0378..0b2e08c40d68 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -13,6 +13,7 @@ import { ProjectId, ThreadId, TurnId, + type OrchestrationCommand, type OrchestrationEvent, ProviderInstanceId, } from "@t3tools/contracts"; @@ -418,9 +419,11 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -991,6 +994,216 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( + "rejects PR discovery completed after a newer %s command", + async (change) => { + const system = await createOrchestrationSystem(); + try { + const projectId = ProjectId.make("pr-race-project"); + const threadId = ThreadId.make("pr-race-thread"); + const previous = { + projectId, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const replacement = { + ...previous, + number: 2, + url: "https://example.test/owner/repository/pull/2", + }; + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("pr-race-project-create"), + projectId, + title: "PR race project", + workspaceRoot: "/tmp/pr-race-project", + defaultModelSelection: null, + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("pr-race-thread-create"), + threadId, + projectId, + title: "PR race thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now(), + }), + ); + const observed = await system.run( + system.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("pr-race-link"), + threadId, + linkedPullRequest: previous, + }), + ); + const metadataChanges = { + unlink: { linkedPullRequest: null }, + relink: { + linkedPullRequest: { + ...previous, + number: 3, + url: "https://example.test/owner/repository/pull/3", + }, + }, + branch: { branch: "another-feature" }, + worktree: { worktreePath: "/tmp/another-worktree" }, + project: {}, + }; + await system.run( + system.engine.dispatch( + change === "project" + ? { + type: "project.meta.update", + commandId: CommandId.make("pr-race-project-move"), + projectId, + workspaceRoot: "/tmp/another-project-root", + } + : change === "delete" + ? { type: "thread.delete", commandId: CommandId.make("pr-race-delete"), threadId } + : { + type: "thread.meta.update", + commandId: CommandId.make(`pr-race-${change}`), + threadId, + ...metadataChanges[change], + }, + ), + ); + const command = { + type: "thread.pull-request.sync", + commandId: CommandId.make("pr-race-stale-sync"), + threadId, + projectId, + snapshotSequence: observed.sequence, + expected: { + workspaceRoot: "/tmp/pr-race-project", + branch: "feature", + worktreePath: null, + linkedPullRequest: previous, + branchPullRequest: null, + }, + branchPullRequest: replacement, + linkedPullRequest: replacement, + } satisfies OrchestrationCommand; + const error = await system.run(system.engine.dispatch(command).pipe(Effect.flip)); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (change === "delete") return; + const current = (await system.readModel()).threads[0]; + expect(current?.branchPullRequest ?? null).toBeNull(); + expect(current?.linkedPullRequest ?? null).toEqual( + change === "unlink" + ? null + : change === "relink" + ? metadataChanges.relink.linkedPullRequest + : previous, + ); + } finally { + await system.dispose(); + } + }, + ); + + it("saves PR associations through streaming and unrelated metadata edits", async () => { + const system = await createOrchestrationSystem(); + try { + const projectId = ProjectId.make("pr-sync-project"); + const threadId = ThreadId.make("pr-sync-thread"); + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("pr-sync-project-create"), + projectId, + title: "PR sync project", + workspaceRoot: "/tmp/pr-sync-project", + defaultModelSelection: null, + createdAt: now(), + }), + ); + const created = await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("pr-sync-thread-create"), + threadId, + projectId, + title: "PR sync thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now(), + }), + ); + const reference = { + projectId, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }; + const activityAt = "2026-01-01T01:00:00.000Z"; + await system.run( + system.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("pr-sync-streaming-message"), + threadId, + messageId: MessageId.make("pr-sync-message"), + delta: "The PR is ready.", + createdAt: activityAt, + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("pr-sync-title-and-model"), + threadId, + title: "Renamed thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + }), + ); + await system.run( + system.engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("pr-sync-project-title"), + projectId, + title: "Renamed project", + }), + ); + const beforeSync = (await system.readModel()).threads[0]; + await system.run( + system.engine.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make("pr-sync-discovery"), + projectId, + threadId, + snapshotSequence: created.sequence, + expected: { + workspaceRoot: "/tmp/pr-sync-project", + branch: "feature", + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + }, + branchPullRequest: reference, + }), + ); + const current = (await system.readModel()).threads[0]; + expect(current?.branchPullRequest).toEqual(reference); + expect(current?.linkedPullRequest ?? null).toBeNull(); + expect(current?.updatedAt).toBe(beforeSync?.updatedAt); + } finally { + await system.dispose(); + } + }); + it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 8512007ae8d3..4350d145810c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // The decider compares the lookup inputs. Only recreation needs an + // event check, since it can reset a thread to the same field values. + if ( + envelope.command.type === "thread.pull-request.sync" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + type: "thread.created", + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} was recreated before pull request discovery`, + }); + } + if ( envelope.command.type === "thread.auto-settle" && threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 1340480bce55..dd76721defa0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { + start: () => { + started.push("thread-pull-request-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { start: () => { @@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-pull-request-reactor", "thread-settlement-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 649e803809db..a86907b0d78b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadPullRequestReactor.start(); yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0249844864fb..1a989958f50c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -8,6 +8,7 @@ import { MessageId, ProjectId, ThreadId, + ThreadLinkedPullRequest, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -18,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -59,6 +61,9 @@ const exists = (filePath: string) => }); const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); +const encodeThreadLinkedPullRequest = Schema.encodeSync( + Schema.fromJsonString(ThreadLinkedPullRequest), +); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( "OrchestrationProjectionPipeline cursor batches", @@ -108,6 +113,189 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( + "imported thread shell projection", + (it) => { + it.effect("does not mark imported user messages as queued work in thread shells", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("import:codex:shell-session"); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-import-shell-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-import-shell"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-import-shell-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:shell-session:0"), + role: "user", + text: "Imported user prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + + const sessionEvent = yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-import-shell-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-session"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }, + }); + yield* projectionPipeline.projectEvent(sessionEvent); + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + }), + ); + }, +); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-projection-")))( + "branch pull request projection", + (it) => { + it.effect("persists branch pull request updates without changing manual links", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-pull-request"); + const projectId = ProjectId.make("project-pull-request"); + const eventFields = { + aggregateKind: "thread" as const, + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }; + const created = yield* eventStore.append({ + ...eventFields, + type: "thread.created", + eventId: EventId.make("evt-pull-request-created"), + payload: { + threadId, + projectId, + title: "Pull request thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionPipeline.projectEvent(created); + const linkedPullRequest = { + projectId, + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const updates = [ + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, + { payload: { branchPullRequest: null }, expected: null }, + ]; + + for (const [index, update] of updates.entries()) { + const event = yield* eventStore.append({ + ...eventFields, + type: "thread.meta-updated", + eventId: EventId.make(`evt-pull-request-update-${index}`), + payload: { threadId, updatedAt: now, ...update.payload }, + }); + yield* projectionPipeline.projectEvent(event); + + const rows = yield* sql<{ + readonly linkedPullRequest: string | null; + readonly branchPullRequest: string | null; + }>` + SELECT + linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(rows, [ + { + linkedPullRequest: encodeThreadLinkedPullRequest(linkedPullRequest), + branchPullRequest: + update.expected === null ? null : encodeThreadLinkedPullRequest(update.expected), + }, + ]); + } + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { @@ -300,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* sql`DROP TRIGGER count_thread_shell_updates`; yield* sql`DROP TABLE thread_shell_updates`; + // Replayed order events must survive later lifecycle upserts, whose + // complete SQL row writes otherwise risk dropping the placement. + const orderUpdatedAt = "2026-01-01T00:00:00.200Z"; + const orderEvents = [ + { type: "thread.meta-updated", payload: { activeOrderKey: "gm" } }, + { type: "thread.pinned", payload: { pinnedAt: now, pinOrderKey: "m" } }, + { + type: "thread.snoozed", + payload: { snoozedAt: now, snoozedUntil: "2026-01-02T00:00:00.000Z" }, + }, + { type: "thread.unsnoozed", payload: { reason: "user" } }, + { type: "thread.unpinned", payload: {} }, + { type: "thread.meta-updated", payload: { title: "Renamed" } }, + ] as const; + for (const [index, event] of orderEvents.entries()) { + yield* eventStore.append({ + type: event.type, + eventId: EventId.make(`evt-active-order-${index}`), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.500Z", + commandId: CommandId.make(`cmd-active-order-${index}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + ...event.payload, + threadId: ThreadId.make("thread-1"), + updatedAt: orderUpdatedAt, + }, + }); + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", updatedAt: orderUpdatedAt }]); + } + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -324,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, + { + settledOverride: "settled", + settledAt: "2026-01-01T00:00:01.000Z", + unsettledAt: null, + activeOrderKey: null, + }, ]); yield* eventStore.append({ @@ -358,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; @@ -373,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { settledOverride: "active", settledAt: null, unsettledAt: "2026-01-01T00:00:02.000Z", + activeOrderKey: null, }, ]); }), @@ -2601,7 +2841,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("maintains shell summaries without reading message bodies", () => + it.effect("maintains shell summaries without decoding message or plan bodies", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2827,12 +3067,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ('summary-other-thread', 'thread-shell-summary-other', NULL, 'pending', NULL, '2026-03-01T08:00:06.000Z', NULL) `; + // Empty markdown must not be decoded when the shell only needs plan status. yield* sql` INSERT INTO projection_thread_proposed_plans ( plan_id, thread_id, turn_id, plan_markdown, implemented_at, implementation_thread_id, created_at, updated_at ) VALUES ( - 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '# Plan', NULL, + 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '', NULL, NULL, '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z' ) `; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 77dfb5e3b88d..050ad1a902ae 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,10 +1,12 @@ import { ApprovalRequestId, + isImportedAgentSessionMessageId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -195,33 +197,6 @@ function derivePendingUserInputCountFromActivities( return openRequestIds.size; } -function deriveHasActionableProposedPlan(input: { - readonly latestTurnId: string | null; - readonly proposedPlans: ReadonlyArray; -}): boolean { - const sorted = [...input.proposedPlans].toSorted( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), - ); - - let latestForTurn: ProjectionThreadProposedPlan | null = null; - if (input.latestTurnId !== null) { - for (let index = sorted.length - 1; index >= 0; index -= 1) { - const plan = sorted[index]; - if (plan?.turnId === input.latestTurnId) { - latestForTurn = plan; - break; - } - } - } - if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; - } - - const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; -} - function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -248,7 +223,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { retainedMessageIds.add(message.messageId); continue; } @@ -258,7 +233,10 @@ function retainProjectionMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -271,7 +249,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); @@ -281,7 +259,10 @@ function retainProjectionMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -294,7 +275,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); @@ -587,19 +568,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] = + const [latestUserMessageAt, hasActionableProposedPlan, activities, pendingApprovalCount] = yield* Effect.all([ projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }), - projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.hasActionableByThreadId({ + threadId, + latestTurnId: existingRow.value.latestTurnId, + }), projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), ]); const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); - const hasActionableProposedPlan = deriveHasActionableProposedPlan({ - latestTurnId: existingRow.value.latestTurnId, - proposedPlans, - }); yield* projectionThreadRepository.upsert({ ...existingRow.value, @@ -625,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti branch: event.payload.branch, worktreePath: event.payload.worktreePath, linkedPullRequest: null, + branchPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -636,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedAt: null, pinnedAt: null, pinOrderKey: null, + activeOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -690,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }); return; @@ -809,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, @@ -825,6 +811,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.linkedPullRequest !== undefined ? { linkedPullRequest: event.payload.linkedPullRequest } : {}), + ...(event.payload.branchPullRequest !== undefined + ? { branchPullRequest: event.payload.branchPullRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; @@ -903,6 +892,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, latestUserMessageAt: event.payload.role === "user" && + !isImportedAgentSessionMessageId(event.payload.messageId) && (previousLatest === null || event.payload.createdAt > previousLatest) ? event.payload.createdAt : previousLatest, @@ -1573,7 +1563,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionTurnRepository.upsertByTurnId({ ...existingTurn.value, assistantMessageId: event.payload.assistantMessageId, - state: turnStillRunning ? existingTurn.value.state : nextState, + state: + turnStillRunning || existingTurn.value.state === "interrupted" + ? existingTurn.value.state + : nextState, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6330c38ca0c3..e262bce34aaf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,9 +1,12 @@ import { + type AgentSessionImportSource, + ChatAttachment, CheckpointRef, EventId, MessageId, ProjectId, ThreadId, + ThreadLinkedPullRequest, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -11,6 +14,8 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -29,6 +34,12 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); +const encodeChatAttachments = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ChatAttachment)), +); +const encodeThreadLinkedPullRequest = Schema.encodeSync( + Schema.fromJsonString(ThreadLinkedPullRequest), +); const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( @@ -45,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; + const branchPullRequest = { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; @@ -85,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, linked_pull_request_json, + branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, @@ -92,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { has_actionable_proposed_plan, pinned_at, pin_order_key, + active_order_key, created_at, updated_at, deleted_at @@ -106,6 +125,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', + ${encodeThreadLinkedPullRequest(branchPullRequest)}, 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -113,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 0, '2026-02-24T00:00:01.000Z', 'gm', + 'hq', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -316,6 +337,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -338,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, deletedAt: null, messages: [ @@ -444,6 +467,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -466,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), @@ -491,6 +516,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); + assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); + } + yield* sql` INSERT INTO projection_thread_activities ( activity_id, @@ -531,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ); assert.equal(detailWithoutActivities._tag, "Some"); if (detailWithoutActivities._tag === "Some") { + assert.equal(detailWithoutActivities.value.activeOrderKey, "hq"); assert.deepEqual(detailWithoutActivities.value.activities, []); assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); assert.deepEqual( @@ -596,10 +631,152 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("reads one turn-start message without decoding unrelated history", () => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-turn-start-read"); + const messageId = MessageId.make("message-turn-start-read"); + const createdAt = "2026-09-05T00:00:00.000Z"; + const attachments = [ + { + type: "file" as const, + id: "notes", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 8, + }, + ]; + const attachmentsJson = yield* encodeChatAttachments(attachments); + yield* sql` + WITH RECURSIVE history(n) AS ( + VALUES (1) UNION ALL SELECT n + 1 FROM history WHERE n < 2000 + ) + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) + SELECT 'turn-start-history:' || n, ${threadId}, 'old-turn:' || n, 'assistant', + 'Unrelated assistant output', 'not-json', 0, ${createdAt}, ${createdAt} + FROM history + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES (${messageId}, ${threadId}, 'user', 'Read these notes', + ${attachmentsJson}, 0, ${createdAt}, ${createdAt}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES ('turn-start-unrelated-user', 'thread-turn-start-unrelated', 'user', 'Unrelated prompt', + 'not-json', 0, ${createdAt}, ${createdAt}) + `; + + const counter = makeSqlStatementCounter(); + const context = yield* query + .getTurnStartMessage({ threadId, messageId }) + .pipe(Effect.withTracer(counter.tracer)); + assert.equal(counter.count(), 1); + assert.deepEqual( + context, + Option.some({ + message: { + id: messageId, + role: "user", + text: "Read these notes", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + attachments, + }, + hasOtherUserMessages: false, + }), + ); + assert.equal( + (yield* query.getTurnStartMessage({ + threadId: ThreadId.make("thread-turn-start-unrelated"), + messageId, + }))._tag, + "None", + ); + assert.equal( + (yield* query.getTurnStartMessage({ threadId, messageId: MessageId.make("missing") }))._tag, + "None", + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + DELETE FROM projection_thread_messages + WHERE thread_id IN ('thread-turn-start-read', 'thread-turn-start-unrelated') + `; + }).pipe(Effect.orDie), + ), + ), + ); + + it.effect("keeps compaction and queued-message eligibility in the turn-start query", () => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-turn-start-eligibility"); + const messageId = MessageId.make("message-turn-start-eligibility"); + const createdAt = "2026-09-05T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${messageId}, ${threadId}, 'user', 'Start a turn', 0, ${createdAt}, ${createdAt}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES ('turn-start-other-user', ${threadId}, 'user', '/compact', NULL, 0, + '2026-09-05T00:00:01.000Z', '2026-09-05T00:00:01.000Z') + `; + + for (const { text, attachments, hasOtherUserMessages } of [ + { text: "/compact", attachments: null, hasOtherUserMessages: false }, + { + text: "\t\n\r /CoMpAcT\u00a0\u2028\ufeff", + attachments: "[ ]", + hasOtherUserMessages: false, + }, + { text: "/compact keep recent errors", attachments: "[]", hasOtherUserMessages: true }, + { text: "", attachments: null, hasOtherUserMessages: true }, + { text: "Queued prompt", attachments: null, hasOtherUserMessages: true }, + { + text: "/compact", + attachments: + '[{"type":"file","id":"notes","name":"notes.txt","mimeType":"text/plain","sizeBytes":8}]', + hasOtherUserMessages: true, + }, + ]) { + yield* sql` + UPDATE projection_thread_messages SET text = ${text}, attachments_json = ${attachments} + WHERE message_id = 'turn-start-other-user' + `; + const context = yield* query.getTurnStartMessage({ threadId, messageId }); + assert.equal(context._tag, "Some"); + if (context._tag === "Some") { + assert.equal(context.value.hasOtherUserMessages, hasOtherUserMessages); + } + } + }), + ); + it.effect("keeps archived threads out of the main shell snapshot", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; + const branchPullRequest = { + projectId: asProjectId("project-archive-test"), + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_threads`; @@ -706,6 +883,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { shellSnapshot.threads.map((thread) => thread.id), [ThreadId.make("thread-active")], ); + assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null); + + yield* sql` + UPDATE projection_threads + SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)} + WHERE thread_id = 'thread-archived' + `; const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); assert.deepEqual( @@ -713,6 +897,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { [ThreadId.make("thread-archived")], ); assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); + assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const activeContext = yield* snapshotQuery.getThreadRuntimeContext( ThreadId.make("thread-active"), ); @@ -2134,7 +2319,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = // // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) // and a turnless activity at T03.6 — both belong to the page containing T03+. - const seedFanOutThread = Effect.fnUntraced(function* () { + const seedFanOutThread = Effect.fnUntraced(function* (options?: { + readonly importedMessageCount?: number; + }) { const sql = yield* SqlClient.SqlClient; // Tests in this block share one in-memory database; reset before seeding. @@ -2163,6 +2350,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) `; + if (options?.importedMessageCount) { + for (let index = 0; index < options.importedMessageCount; index += 1) { + const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; + const role = index % 2 === 0 ? "user" : "assistant"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, + '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') + `; + } + } + const turns: ReadonlyArray<{ turn: string; pendingMessage: string | null; @@ -2396,6 +2597,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("keeps imported history on the oldest page after resumed turns", () => + Effect.gen(function* () { + yield* seedFanOutThread({ importedMessageCount: 12 }); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); + assert.equal(completePage._tag, "Some"); + if (completePage._tag !== "Some") return; + assert.equal( + completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) + .length, + 12, + ); + assert.equal(completePage.value.page?.hasMore, false); + assert.equal(completePage.value.page?.beforeCursor, null); + + const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(recentPage._tag, "Some"); + if (recentPage._tag !== "Some") return; + assert.equal( + recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), + false, + ); + const cursor = recentPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(oldestPage._tag, "Some"); + if (oldestPage._tag !== "Some") return; + + const importedIds = oldestPage.value.thread.messages + .map((message) => message.id) + .filter((messageId) => messageId.startsWith("import:")); + assert.equal(importedIds.length, 12); + assert.equal(new Set(importedIds).size, 12); + assert.equal(oldestPage.value.page?.hasMore, false); + assert.equal(oldestPage.value.page?.beforeCursor, null); + }), + ); + it.effect("a cursor for a different thread degrades to the first page", () => Effect.gen(function* () { yield* seedFanOutThread(); @@ -2779,3 +3025,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { + const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const source: AgentSessionImportSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex-home"), + providerSessionId: "native-session", + filePath: "/tmp/transcript.jsonl", + size: 128, + mtimeMs: 1_700_000_000_000, + device: 1, + inode: 2, + birthtimeMs: 1_699_000_000_000, + }; + + const seedImportedSession = Effect.fn("seedImportedSession")(function* ( + projectId: ProjectId, + source: AgentSessionImportSource, + ) { + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT OR IGNORE INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at + ) VALUES (${threadId}, ${projectId}, 'Imported thread', + ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, + 'full-access', 'default', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, + ${source.provider}, 'full-access', 'stopped', ${timestamp}, + ${encodeJson({ threadId: source.providerSessionId })}, + ${encodeJson({ importedTranscripts: [source] })}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, + ${timestamp}, ${timestamp}) + `; + return { threadId, source }; + }); + + it.effect("reads completed source copies without decoding message bodies", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-metadata"); + const imported = yield* seedImportedSession(projectId, source); + const copiedSource = { + ...source, + filePath: "/tmp/transcript-copy.jsonl", + mtimeMs: null, + inode: null, + birthtimeMs: null, + }; + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + cwd: "/tmp/imported-project", + importedTranscripts: [source, copiedSource], + })} + WHERE thread_id = ${imported.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET attachments_json = 'not-json' + WHERE thread_id = ${imported.threadId} + `; + + const counter = makeSqlStatementCounter(); + const sources = yield* query + .getImportedAgentSessionSources(projectId) + .pipe(Effect.withTracer(counter.tracer)); + assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); + assert.equal(counter.count(), 1); + }), + ); + + it.effect("requires active project threads, a binding, and an imported message", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-completion"); + const completed = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "completed", + }); + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} + WHERE thread_id = ${completed.threadId} + `; + const partials = yield* Effect.forEach( + [ + "no-binding", + "no-history", + "no-imported-message", + "wrong-message-thread", + "archived", + "deleted", + ], + (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), + ); + const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = + partials; + assert.isDefined(noBinding); + assert.isDefined(noHistory); + assert.isDefined(noImportedMessage); + assert.isDefined(wrongMessageThread); + assert.isDefined(archived); + assert.isDefined(deleted); + yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; + yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} + WHERE thread_id = ${noImportedMessage.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' + WHERE thread_id = ${wrongMessageThread.threadId} + `; + yield* sql` + UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${archived.threadId} + `; + yield* sql` + UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${deleted.threadId} + `; + const otherProjectId = ProjectId.make("project-import-other"); + const otherProject = yield* seedImportedSession(otherProjectId, { + ...source, + providerSessionId: "other-project", + }); + const deletedProjectId = ProjectId.make("project-import-deleted"); + yield* seedImportedSession(deletedProjectId, { + ...source, + providerSessionId: "deleted-project", + }); + yield* sql` + UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE project_id = ${deletedProjectId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); + assert.deepEqual( + yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), + [], + ); + }), + ); + + it.effect("keeps original sources when the current runtime provider and cursor change", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-switched"); + const imported = yield* seedImportedSession(projectId, { + ...source, + provider: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-original"), + providerSessionId: "original-session", + }); + yield* sql` + UPDATE provider_session_runtime + SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', + resume_cursor_json = '{"threadId":"new-session"}' + WHERE thread_id = ${imported.threadId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); + }), + ); + + it.effect("skips invalid source payloads and entries without dropping valid sources", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-invalid"); + const imported = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "a-invalid", + }); + const valid = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "z-valid", + }); + for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = ${payload} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + } + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = X'FF' + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + importedTranscripts: [ + null, + {}, + { ...imported.source, size: -1 }, + { ...imported.source, provider: "cursor" }, + { ...imported.source, providerInstanceId: "wrong-instance" }, + { ...imported.source, providerSessionId: "wrong-session" }, + imported.source, + ], + })} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 91d09bf57193..5f82a26e2a36 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + AgentSessionImportSource, ApprovalRequestId, ChatAttachment, CheckpointRef, @@ -75,6 +76,14 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + importedTranscripts: Schema.Array(Schema.Unknown), + }), + ), +); +const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); // Keep detail reads consistent with the in-memory projector's retained // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. @@ -82,6 +91,9 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; // Snapshot payloads are decoded and projected in small sequential batches so // one client read does not retain the raw payloads for the full activity window. const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; +// SQLite trim defaults to spaces. Match the whitespace removed by String.trim. +const MESSAGE_TRIM_WHITESPACE = + "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -96,11 +108,15 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema.mapFields( + Struct.assign({ hasOtherUserMessages: Schema.Number }), +); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -164,9 +180,17 @@ const WorkspaceRootLookupInput = Schema.Struct({ const ProjectIdLookupInput = Schema.Struct({ projectId: ProjectId, }); +const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ + threadId: ThreadId, + runtimePayload: Schema.Unknown, +}); const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const TurnStartMessageLookupInput = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); const ThreadActivityKindsLookupInput = Schema.Struct({ threadId: ThreadId, activityKinds: Schema.Array(Schema.String), @@ -477,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -488,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -515,6 +541,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -526,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -555,6 +583,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -566,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -983,6 +1013,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listImportedAgentSessionSourceRows = SqlSchema.findAll({ + Request: ProjectIdLookupInput, + Result: ProjectionImportedAgentSessionSourcesRowSchema, + execute: ({ projectId }) => + sql` + SELECT + threads.thread_id AS "threadId", + runtime.runtime_payload_json AS "runtimePayload" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + INNER JOIN provider_session_runtime AS runtime + ON runtime.thread_id = threads.thread_id + WHERE threads.project_id = ${projectId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.message_id GLOB 'import:*' + ) + ORDER BY threads.thread_id ASC + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -1017,6 +1074,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1028,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1077,6 +1136,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), }); + const getTurnStartMessageRow = SqlSchema.findOneOption({ + Request: TurnStartMessageLookupInput, + Result: ProjectionTurnStartMessageDbRowSchema, + execute: ({ threadId, messageId }) => sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt", + EXISTS ( + SELECT 1 + FROM projection_thread_messages AS other + WHERE other.thread_id = ${threadId} + AND other.message_id != ${messageId} + AND other.role = 'user' + AND ( + LOWER(TRIM(other.text, ${MESSAGE_TRIM_WHITESPACE})) != '/compact' + OR COALESCE(json_array_length(other.attachments_json), 0) > 0 + ) + ) AS "hasOtherUserMessages" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND message_id = ${messageId} + LIMIT 1 + `, + }); + const listThreadMessageRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadMessageDbRowSchema, @@ -1981,6 +2071,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -1995,6 +2086,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -2194,6 +2286,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2208,6 +2301,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -2334,6 +2428,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2348,6 +2443,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2482,6 +2578,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2496,6 +2593,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2663,6 +2761,33 @@ pending_approval_requests AS ( Effect.map(Option.map((row) => row.threadId)), ); + const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = + Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { + const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", + "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", + ), + ), + ); + return rows.flatMap((row) => { + const payload = decodeImportedTranscriptsPayload(row.runtimePayload); + if (Option.isNone(payload)) return []; + return payload.value.importedTranscripts.flatMap((entry) => { + const source = decodeAgentSessionImportSource(entry); + if ( + Option.isNone(source) || + row.threadId !== + `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` + ) { + return []; + } + return [{ threadId: row.threadId, source: source.value }]; + }); + }); + }); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -2776,6 +2901,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + branchPullRequest: threadRow.value.branchPullRequest, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), @@ -2790,6 +2916,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -2820,6 +2947,32 @@ pending_approval_requests AS ( })); }); + const getTurnStartMessage: ProjectionSnapshotQueryShape["getTurnStartMessage"] = Effect.fn( + "ProjectionSnapshotQuery.getTurnStartMessage", + )(function* (input) { + const message = yield* getTurnStartMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getTurnStartMessage:query", + "ProjectionSnapshotQuery.getTurnStartMessage:decodeRow", + ), + ), + ); + return Option.map(message, (row) => ({ + message: { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + }, + hasOtherUserMessages: row.hasOtherUserMessages === 1, + })); + }); + // Contiguous turn range bounding a windowed detail read; undefined loads the // full thread. Resolved from a window request inside the snapshot // transaction (see getThreadDetailSnapshot). @@ -3031,6 +3184,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + branchPullRequest: threadRow.value.branchPullRequest, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), @@ -3045,6 +3199,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { @@ -3151,17 +3306,35 @@ pending_approval_requests AS ( ); const oldest = windowRows[0]; + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; // An empty window (no turns before the cursor, or a thread with no // turns at all) still returns thread metadata with empty collections // for turn-linked rows; turnless rows are bounded to the same empty // range. The first page of a turnless thread stays unwindowed so - // pre-turn content (e.g. a just-created thread) is not hidden. + // pre-turn content (e.g. a just-created thread) is not hidden. Once + // paging reaches the oldest turn, include turnless messages before + // the first turn, such as history imported from a provider session. const bounds: ThreadDetailBounds | undefined = oldest === undefined && cursor === null ? undefined : { - minAnchorAt: oldest?.anchorAt ?? "", - minTurnKey: oldest?.turnKey ?? "", + minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", + minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, beforeTurnKey: cursor?.beforeTurnId ?? "", }; @@ -3178,23 +3351,6 @@ pending_approval_requests AS ( return Option.none(); } - const hasMore = - oldest !== undefined && - (yield* listTurnWindowRows({ - threadId, - beforeAnchorAt: oldest.anchorAt, - beforeTurnKey: oldest.turnKey, - userTurnLimit: 1, - maxRawTurns: 1, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", - ), - ), - )).length > 0; - const { snapshotSequence } = yield* getSnapshotSequence(); const watermarkRow = yield* getThreadEventWatermarkRow({ threadId, @@ -3253,10 +3409,12 @@ pending_approval_requests AS ( getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getImportedAgentSessionSources, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, getThreadRuntimeContext, + getTurnStartMessage, getThreadDetailById, getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4d35c5b04dd4..d93fec5a3cf6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -61,7 +61,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { - providerErrorLabel, providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, } from "./ProviderCommandReactor.ts"; @@ -164,10 +163,6 @@ describe("ProviderCommandReactor", () => { }), ).toBe("claude_openrouter"); }); - - it("uses the unknown driver kind when the resolved driver is not registered locally", () => { - expect(providerErrorLabel("third_party_driver")).toBe("third_party_driver"); - }); }); async function createHarness(input?: { @@ -914,6 +909,48 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("starts a turn and generates its title without loading old message bodies", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const titleGenerated = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + unreadableHistory: true, + startSessionEffect: (session) => + Deferred.succeed(started, undefined).pipe(Effect.as(session)), + }), + ); + harness.generateThreadTitle.mockReturnValue( + Deferred.succeed(titleGenerated, undefined).pipe(Effect.as({ title: "Generated title" })), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-with-old-history"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-turn-start-with-old-history"), + role: "user", + text: "Use the current message", + attachments: [], + }, + titleSeed: "Thread", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(started); + yield* Deferred.await(titleGenerated); + yield* Effect.promise(() => harness.drain()); + + expect(harness.sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ input: "Use the current message" }), + ); + expect(harness.generateThreadTitle).toHaveBeenCalledWith( + expect.objectContaining({ message: "Use the current message" }), + ); + }), + ); + effectIt.effect("rejects /compact without conversation context", () => Effect.gen(function* () { const harness = yield* Effect.promise(() => createHarness()); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b8e457e34ebb..5c1086b9e29c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -228,7 +228,7 @@ function formatThreadTitleContext(messages: ReadonlyArray): }; } -export function providerErrorLabel(value: string | undefined): string { +function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; } @@ -1188,12 +1188,15 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThreadDetail(event.payload.threadId); + const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; } - const message = thread.messages.find((entry) => entry.id === event.payload.messageId); - if (!message || message.role !== "user") { + const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ + threadId: thread.id, + messageId: event.payload.messageId, + }); + if (Option.isNone(turnStart) || turnStart.value.message.role !== "user") { yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.start.failed", @@ -1205,6 +1208,7 @@ const make = Effect.gen(function* () { }); return; } + const { message, hasOtherUserMessages } = turnStart.value; const appendTurnStartFailure = (summary: string, detail: string) => appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -1297,10 +1301,7 @@ const make = Effect.gen(function* () { yield* ensureThreadWorktree(thread); const isCompactCommand = isCompactCommandMessage(message); - const nonCompactUserMessageCount = thread.messages.filter( - (entry) => entry.role === "user" && !isCompactCommandMessage(entry), - ).length; - if (nonCompactUserMessageCount === 1 && !isCompactCommand) { + if (!hasOtherUserMessages && !isCompactCommand) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -1371,7 +1372,7 @@ const make = Effect.gen(function* () { ), ); if (isCompactCommand) { - if (nonCompactUserMessageCount === 0) { + if (!hasOtherUserMessages) { return yield* appendTurnStartFailure( "Context compaction failed", "Context compaction requires an existing conversation.", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index dd6a0b179a8f..1094ab48b7ac 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -286,9 +286,20 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + const ingestionProjectionSnapshotLayer = Layer.effect( + ProjectionSnapshotQuery, + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + return ProjectionSnapshotQuery.of({ + ...query, + getThreadDetailById: () => + Effect.die("provider runtime ingestion must not hydrate thread detail"), + }); + }), + ).pipe(Layer.provide(projectionSnapshotLayer)); const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), - Layer.provideMerge(projectionSnapshotLayer), + Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), @@ -483,6 +494,89 @@ describe("ProviderRuntimeIngestion", () => { ]); }); + it.each(["turn.completed", "turn.aborted"] as const)( + "finalizes old buffered text on late %s without stopping the newer turn", + async (terminalType) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: false }, + }); + const threadId = asThreadId("thread-1"); + const oldTurnId = asTurnId("old-buffered-turn"); + const newTurnId = asTurnId("new-active-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + await harness.emitAndDrain([ + { + ...base, + type: "turn.started", + eventId: asEventId("old-buffered-started"), + turnId: oldTurnId, + }, + { + ...base, + type: "content.delta", + eventId: asEventId("old-buffered-delta"), + turnId: oldTurnId, + itemId: asItemId("old-buffered-message"), + payload: { streamKind: "assistant_text", delta: "Keep the old answer." }, + }, + ]); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-new-while-old-finishes"), + threadId, + message: { + messageId: asMessageId("new-turn-prompt"), + role: "user", + text: "Continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: base.createdAt, + }); + harness.setProviderSession({ + provider: base.provider, + status: "running", + runtimeMode: "approval-required", + threadId, + createdAt: base.createdAt, + updatedAt: base.createdAt, + activeTurnId: newTurnId, + }); + await harness.emitAndDrain([ + { + ...base, + type: "turn.started", + eventId: asEventId("new-active-started"), + turnId: newTurnId, + }, + { + ...base, + type: terminalType, + eventId: asEventId("old-buffered-terminal"), + turnId: oldTurnId, + payload: + terminalType === "turn.completed" + ? { state: "completed" } + : { reason: "Interrupted by user." }, + }, + ]); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ activeTurnId: newTurnId, status: "running" }); + expect(thread?.messages).toContainEqual( + expect.objectContaining({ + turnId: oldTurnId, + text: "Keep the old answer.", + streaming: false, + }), + ); + }, + ); + it.each([ { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, { source: "an unspecified turn", turnId: undefined }, @@ -1690,6 +1784,31 @@ describe("ProviderRuntimeIngestion", () => { ).toMatchObject({ implementationThreadId: "thread-implement", }); + const implementedPlan = sourceThreadAfterStart.proposedPlans.find( + (entry) => entry.id === sourcePlan.id, + ); + await harness.emitAndDrain([ + { + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-source-late-completion"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:01:00.000Z", + threadId: sourceThreadId, + turnId: sourceTurnId, + payload: { planMarkdown: "# Source plan with late details" }, + }, + ]); + const sourceAfterLateCompletion = (await harness.readModel()).threads.find( + (entry) => entry.id === sourceThreadId, + ); + expect( + sourceAfterLateCompletion?.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + planMarkdown: "# Source plan with late details", + createdAt: sourcePlan.createdAt, + implementedAt: implementedPlan?.implementedAt, + implementationThreadId: targetThreadId, + }); }); it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { @@ -2120,7 +2239,7 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-1"), provider: ProviderDriverKind.make("codex"), - createdAt: now, + createdAt: "", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), payload: { @@ -2131,7 +2250,7 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-2"), provider: ProviderDriverKind.make("codex"), - createdAt: now, + createdAt: "", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), payload: { @@ -2161,6 +2280,42 @@ describe("ProviderRuntimeIngestion", () => { entry.id === "plan:thread-1:turn:turn-plan-buffer", ); expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); + expect(proposedPlan?.createdAt).toBe(now); + }); + + it("releases a blank completed plan before a late replacement", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("blank-plan-turn"); + const base = { provider: ProviderDriverKind.make("codex"), threadId, turnId }; + const replacementTime = "2026-01-01T00:00:02.000Z"; + await harness.emitAndDrain([ + { + ...base, + type: "turn.proposed.delta", + eventId: asEventId("blank-plan-delta"), + createdAt: "2026-01-01T00:00:00.000Z", + payload: { delta: " \n " }, + }, + { + ...base, + type: "turn.completed", + eventId: asEventId("blank-plan-completed"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { state: "completed" }, + }, + { + ...base, + type: "turn.proposed.completed", + eventId: asEventId("late-plan-completed"), + createdAt: replacementTime, + payload: { planMarkdown: "# Replacement plan" }, + }, + ]); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.proposedPlans).toEqual([ + expect.objectContaining({ planMarkdown: "# Replacement plan", createdAt: replacementTime }), + ]); }); it("buffers assistant deltas with one lifecycle query per event until completion", async () => { @@ -4037,71 +4192,65 @@ describe("ProviderRuntimeIngestion", () => { expect(completedPayload?.title).toBe("wait for codex review to finish"); }); - it("titles task completion from persisted activities after the description cache is swept", async () => { + it("recovers a task title past untitled progress after the cache is swept", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-swept-task"); + const provider = ProviderDriverKind.make("claudeAgent"); - harness.emit({ - type: "task.progress", - eventId: asEventId("evt-swept-task-progress"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-swept-task"), - payload: { - taskId: "swept-task-1", - description: "Watch round-3 CI and bots", + await harness.emitAndDrain([ + { + type: "task.started", + eventId: asEventId("evt-swept-task-started"), + provider, + createdAt: now, + threadId, + turnId, + payload: { taskId: "swept-task-1", description: "Watch round-3 CI and bots" }, + }, + ]); + // Older saved progress rows can have no title even when the start has one. + await harness.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-swept-task-progress"), + threadId, + activity: { + id: asEventId("evt-swept-task-progress"), + kind: "task.progress", + tone: "info", summary: "Polling CI checks.", + payload: { taskId: "swept-task-1" }, + turnId, + createdAt: "2026-01-01T00:00:01.000Z", }, + createdAt: "2026-01-01T00:00:01.000Z", }); - - await waitForThread(harness.readModel, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => - activity.id === "task-progress:thread-1:swept-task-1", - ), - ); - - // session.exited sweeps the in-memory description cache; the completion - // that follows must recover the name from persisted activities. - harness.emit({ - type: "session.exited", - eventId: asEventId("evt-swept-task-session-exited"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - payload: {}, - }); - - harness.emit({ - type: "task.completed", - eventId: asEventId("evt-swept-task-completed"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-swept-task"), - payload: { - taskId: "swept-task-1", - status: "completed", - summary: "CI is green.", + await harness.emitAndDrain([ + { + type: "session.exited", + eventId: asEventId("evt-swept-task-session-exited"), + provider, + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + payload: {}, }, - }); - - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", - ), - ); + { + type: "task.completed", + eventId: asEventId("evt-swept-task-completed"), + provider, + createdAt: "2026-01-01T00:00:03.000Z", + threadId, + turnId, + payload: { taskId: "swept-task-1", status: "completed", summary: "CI is green." }, + }, + ]); - const completed = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + const completed = thread?.activities.find( + (activity) => activity.id === "evt-swept-task-completed", ); - const completedPayload = - completed?.payload && typeof completed.payload === "object" - ? (completed.payload as Record) - : undefined; - - expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + expect(completed?.payload).toMatchObject({ title: "Watch round-3 CI and bots" }); }); it("projects structured user input request and resolution as thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index cf59d00a248b..8d34fee4f981 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -4,8 +4,7 @@ import { CommandId, MessageId, type OrchestrationEvent, - type OrchestrationMessage, - type OrchestrationProposedPlanId, + OrchestrationProposedPlanId, CheckpointRef, classifyTaskAgentKind, EventId, @@ -14,8 +13,6 @@ import { type ThreadTokenUsageSnapshot, TurnId, type OrchestrationCheckpointSummary, - type OrchestrationProposedPlan, - type OrchestrationThread, type OrchestrationThreadActivity, type ProviderRuntimeEvent, RuntimeRequestId, @@ -39,6 +36,10 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlanRepository } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; @@ -54,13 +55,12 @@ import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; -const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier // task.started/task.progress activities for the task are persisted with it. function findTaskTitleInActivities( - activities: ReadonlyArray | undefined, + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }> | undefined, taskId: string, ): string | undefined { if (!activities) { @@ -138,57 +138,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine return left === right; } -function hasAssistantMessageForTurn( - messages: ReadonlyArray, - turnId: TurnId, - options?: { readonly streamingOnly?: boolean }, -): boolean { - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (!message) { - continue; - } - if (message.role !== "assistant" || message.turnId !== turnId) { - continue; - } - if (options?.streamingOnly === true && !message.streaming) { - continue; - } - return true; - } - return false; -} - -function findMessageById( - messages: ReadonlyArray, - messageId: MessageId, -): OrchestrationMessage | undefined { - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (message?.id === messageId) { - return message; - } - } - return undefined; -} - -function findProposedPlanById( - proposedPlans: ReadonlyArray< - Pick - >, - planId: string, -): - | Pick - | undefined { - for (let index = 0; index < proposedPlans.length; index += 1) { - const proposedPlan = proposedPlans[index]; - if (proposedPlan?.id === planId) { - return proposedPlan; - } - } - return undefined; -} - function hasCheckpointForTurn( checkpoints: ReadonlyArray, turnId: TurnId, @@ -264,16 +213,15 @@ function buildContextWindowActivityPayload( } function compactedTokenCountsFromActivities( - activities: ReadonlyArray | undefined, + activities: ReadonlyArray< + Pick + >, ): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { - const lastCompactionIndex = activities?.findLastIndex( + const lastCompactionIndex = activities.findLastIndex( (activity) => activity.kind === "context-compaction", ); - const lastCompaction = - lastCompactionIndex !== undefined && lastCompactionIndex >= 0 - ? activities?.[lastCompactionIndex] - : undefined; - const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? []; + const lastCompaction = activities[lastCompactionIndex]; + const activitiesSinceLastCompaction = activities.slice(lastCompactionIndex + 1); const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { if (activity.kind !== "context-window.updated") return []; if (lastCompaction !== undefined) { @@ -956,6 +904,8 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; + const projectionThreadMessages = yield* ProjectionThreadMessageRepository; + const projectionThreadProposedPlans = yield* ProjectionThreadProposedPlanRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const serverSettingsService = yield* ServerSettingsService; @@ -1013,21 +963,22 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( threadId: ThreadId, - activityKinds: ReadonlyArray = [], ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId, { activityKinds }) + .getThreadRuntimeContext(threadId) .pipe(Effect.map(Option.getOrUndefined)); }); - const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( + const getThreadMessageById = Effect.fn("getThreadMessageById")(function* ( threadId: ThreadId, + messageId: MessageId, ) { - return yield* projectionSnapshotQuery - .getThreadRuntimeContext(threadId) - .pipe(Effect.map(Option.getOrUndefined)); + const message = yield* projectionThreadMessages.getByMessageId({ messageId }); + return Option.filter(message, (entry) => entry.threadId === threadId).pipe( + Option.getOrUndefined, + ); }); const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => @@ -1195,15 +1146,6 @@ const make = Effect.gen(function* () { }), ); - const takeBufferedProposedPlan = (planId: string) => - Cache.getOption(bufferedProposedPlanById, planId).pipe( - Effect.flatMap((existingEntry) => - Cache.invalidate(bufferedProposedPlanById, planId).pipe( - Effect.as(Option.getOrUndefined(existingEntry)), - ), - ), - ); - const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); @@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () { } }); - const upsertProposedPlan = (input: { - event: ProviderRuntimeEvent; - threadId: ThreadId; - threadProposedPlans: ReadonlyArray<{ - id: string; - createdAt: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - }>; - planId: string; - turnId?: TurnId; - planMarkdown: string | undefined; - createdAt: string; - updatedAt: string; - }) => - Effect.gen(function* () { - const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); - if (!planMarkdown) { - return; - } - - const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId); - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), - threadId: input.threadId, - proposedPlan: { - id: input.planId, - turnId: input.turnId ?? null, - planMarkdown, - implementedAt: existingPlan?.implementedAt ?? null, - implementationThreadId: existingPlan?.implementationThreadId ?? null, - createdAt: existingPlan?.createdAt ?? input.createdAt, - updatedAt: input.updatedAt, - }, - createdAt: input.updatedAt, - }); - }); - - const finalizeBufferedProposedPlan = (input: { + const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { event: ProviderRuntimeEvent; threadId: ThreadId; - threadProposedPlans: ReadonlyArray<{ - id: string; - createdAt: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - }>; planId: string; turnId?: TurnId; fallbackMarkdown?: string; updatedAt: string; - }) => - Effect.gen(function* () { - const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); - const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; - if (!planMarkdown) { - return; - } + }) { + const bufferedPlan = Option.getOrUndefined( + yield* Cache.getOption(bufferedProposedPlanById, input.planId), + ); + const planMarkdown = + normalizeProposedPlanMarkdown(bufferedPlan?.text) ?? + normalizeProposedPlanMarkdown(input.fallbackMarkdown); + if (!planMarkdown) return yield* clearBufferedProposedPlan(input.planId); - yield* upsertProposedPlan({ - event: input.event, + const existingPlan = Option.getOrUndefined( + yield* projectionThreadProposedPlans.getByPlanId({ threadId: input.threadId, - threadProposedPlans: input.threadProposedPlans, - planId: input.planId, - ...(input.turnId ? { turnId: input.turnId } : {}), + planId: OrchestrationProposedPlanId.make(input.planId), + }), + ); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, planMarkdown, - createdAt: - bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 - ? bufferedPlan.createdAt - : input.updatedAt, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? (bufferedPlan?.createdAt || input.updatedAt), updatedAt: input.updatedAt, - }); - yield* clearBufferedProposedPlan(input.planId); + }, + createdAt: input.updatedAt, }); + yield* clearBufferedProposedPlan(input.planId); + }); const clearTurnStateForSession = (threadId: ThreadId) => Effect.gen(function* () { @@ -1538,8 +1442,13 @@ const make = Effect.gen(function* () { implementationThreadId: ThreadId, implementedAt: string, ) { - const sourceThread = yield* resolveThreadDetail(sourceThreadId); - const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); + const sourceThread = yield* resolveThreadRuntimeContext(sourceThreadId); + const sourcePlan = Option.getOrUndefined( + yield* projectionThreadProposedPlans.getByPlanId({ + threadId: sourceThreadId, + planId: sourcePlanId, + }), + ); if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { return; } @@ -1550,9 +1459,12 @@ const make = Effect.gen(function* () { commandId: CommandId.make( `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, ), - threadId: sourceThread.id, + threadId: sourceThreadId, proposedPlan: { - ...sourcePlan, + id: sourcePlan.planId, + turnId: sourcePlan.turnId, + planMarkdown: sourcePlan.planMarkdown, + createdAt: sourcePlan.createdAt, implementedAt, implementationThreadId, updatedAt: implementedAt, @@ -1571,16 +1483,6 @@ const make = Effect.gen(function* () { const thread = yield* resolveThreadRuntimeContext(event.threadId); if (!thread) return; - let loadedThreadDetail: OrchestrationThread | null | undefined; - const getLoadedThreadDetail = () => - Effect.gen(function* () { - if (loadedThreadDetail !== undefined) { - return loadedThreadDetail; - } - loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null; - return loadedThreadDetail; - }); - const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; @@ -1800,7 +1702,11 @@ const make = Effect.gen(function* () { ? toTurnId(event.turnId) : undefined; if (pauseForUserTurnId) { - const detailedThread = yield* getLoadedThreadDetail(); + const hasProjectedMessage = yield* projectionThreadMessages.hasAssistantMessageForTurn({ + threadId: thread.id, + turnId: pauseForUserTurnId, + streamingOnly: true, + }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), @@ -1831,11 +1737,7 @@ const make = Effect.gen(function* () { event.type === "request.opened" ? "assistant-delta-finalize-on-request-opened" : "assistant-delta-finalize-on-user-input-requested", - hasProjectedMessage: - detailedThread !== null && - hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { - streamingOnly: true, - }), + hasProjectedMessage, flushedMessageIds, }); } @@ -1864,19 +1766,24 @@ const make = Effect.gen(function* () { : undefined; if (assistantCompletion) { - const detailedThread = yield* getLoadedThreadDetail(); - const messages = detailedThread?.messages ?? []; const turnId = toTurnId(event.turnId); const activeAssistantMessageId = turnId ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) : Option.none(); - const hasAssistantMessagesForTurn = - turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; const assistantMessageId = Option.getOrElse( activeAssistantMessageId, () => assistantCompletion.messageId, ); - const existingAssistantMessage = findMessageById(messages, assistantMessageId); + const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ + getThreadMessageById(thread.id, assistantMessageId), + turnId === undefined + ? Effect.succeed(false) + : projectionThreadMessages.hasAssistantMessageForTurn({ + threadId: thread.id, + turnId, + streamingOnly: false, + }), + ]); const shouldApplyFallbackCompletionText = !existingAssistantMessage || existingAssistantMessage.text.length === 0; @@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () { } if (proposedPlanCompletion) { - const detailedThread = yield* getLoadedThreadDetail(); yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, - threadProposedPlans: detailedThread?.proposedPlans ?? [], planId: proposedPlanCompletion.planId, ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), fallbackMarkdown: proposedPlanCompletion.planMarkdown, @@ -1929,9 +1834,6 @@ const make = Effect.gen(function* () { } if (isTerminalTurn) { - const detailedThread = yield* getLoadedThreadDetail(); - const messages = detailedThread?.messages ?? []; - const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { const userInputActivities = @@ -1979,16 +1881,20 @@ const make = Effect.gen(function* () { yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, - }), + getThreadMessageById(thread.id, assistantMessageId).pipe( + Effect.flatMap((existingMessage) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: existingMessage !== undefined, + }), + ), + ), { concurrency: 1 }, ).pipe(Effect.asVoid); yield* clearAssistantMessageIdsForTurn(thread.id, turnId); @@ -1997,7 +1903,6 @@ const make = Effect.gen(function* () { yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, - threadProposedPlans: proposedPlans, planId: proposedPlanIdForTurn(thread.id, turnId), turnId, updatedAt: now, @@ -2153,8 +2058,17 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); - taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); + const taskActivity = yield* projectionThreadActivityRepository.getLatestTaskActivity({ + threadId: thread.id, + taskId: event.payload.taskId, + }); + taskTitle = findTaskTitleInActivities( + Option.match(taskActivity, { + onNone: () => undefined, + onSome: (activity) => [activity], + }), + event.payload.taskId, + ); } } @@ -2172,8 +2086,9 @@ const make = Effect.gen(function* () { DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), ) ) { - const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find( - (message) => message.id === pendingTurnStart.value.messageId, + const pendingMessage = yield* getThreadMessageById( + thread.id, + pendingTurnStart.value.messageId, ); if ( pendingMessage?.role === "user" && @@ -2192,11 +2107,13 @@ const make = Effect.gen(function* () { (activityEvent.payload.beforeTokens === undefined || activityEvent.payload.afterTokens === undefined) ) { - const threadDetail = yield* resolveThreadDetail(thread.id, [ - "context-window.updated", - "context-compaction", - ]); - const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities); + const activities = yield* projectionThreadActivityRepository.listByThreadId({ + threadId: thread.id, + activityKinds: ["context-window.updated", "context-compaction"], + // Preserve the previous thread-detail read's context-history bound. + limit: 500, + }); + const tokenCounts = compactedTokenCountsFromActivities(activities); if (tokenCounts) { activityEvent = { ...activityEvent, @@ -2274,6 +2191,8 @@ export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, make, ).pipe( - Layer.provide(ProjectionTurnRepositoryLive), Layer.provide(ProjectionThreadActivityRepositoryLive), + Layer.provide(ProjectionThreadMessageRepositoryLive), + Layer.provide(ProjectionThreadProposedPlanRepositoryLive), + Layer.provide(ProjectionTurnRepositoryLive), ); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 96072b266319..35d5bacc239c 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,9 +7,12 @@ * @module ProjectionSnapshotQuery */ import type { + AgentSessionImportSource, ApprovalRequestId, CheckpointRef, + MessageId, OrchestrationCheckpointSummary, + OrchestrationMessage, OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, @@ -170,6 +173,15 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read completed import sources without loading thread history. */ + readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }>, + ProjectionRepositoryError + >; + /** * Read the checkpoint context needed to resolve a single thread diff. */ @@ -201,6 +213,21 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read one requested message and whether another non-compaction user message exists. + * Newer queued messages count too, preserving first-turn title eligibility. + */ + readonly getTurnStartMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect< + Option.Option<{ + readonly message: OrchestrationMessage; + readonly hasOtherUserMessages: boolean; + }>, + ProjectionRepositoryError + >; + /** * Read a single active thread detail snapshot by id. */ diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts new file mode 100644 index 000000000000..f9872e6dd22a --- /dev/null +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -0,0 +1,613 @@ +import { + EventId, + GitManagerError, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestRef, + type PullRequestSummary, + type ThreadLinkedPullRequest, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; + +const NOW = "2026-09-01T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("project"); +const REPOSITORY = "owner/repository"; +const REPOSITORY_KEY = `github.com/${REPOSITORY}`; +type SyncCommand = Extract; + +function reference(number: number): ThreadLinkedPullRequest { + return { + projectId: PROJECT_ID, + repository: REPOSITORY, + number, + url: `https://github.com/${REPOSITORY}/pull/${number}`, + }; +} + +function branchPullRequest( + number = 42, + state: GitBranchPullRequest["state"] = "open", +): GitBranchPullRequest { + return { + ...reference(number), + title: "Branch pull request", + baseRef: "main", + headRef: "feature", + state, + updatedAt: NOW, + repositoryKey: REPOSITORY_KEY, + }; +} + +function summary(input: PullRequestRef, state: PullRequestSummary["state"]): PullRequestSummary { + return { + ...input, + provider: "github", + title: "Pull request", + url: reference(input.number).url, + state, + headBranch: "feature", + baseBranch: "main", + updatedAt: NOW, + }; +} + +function thread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +const project = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + repositoryIdentity: { + canonicalKey: REPOSITORY_KEY, + displayName: REPOSITORY, + rootPath: "/workspace/project", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `git@github.com:${REPOSITORY}.git`, + }, + }, + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +} satisfies OrchestrationProjectShell; + +const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options: { + readonly threads: ReadonlyArray; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly summary?: PullRequestService["Service"]["summary"]; + readonly existingWorktrees?: ReadonlyArray; + readonly project?: OrchestrationProjectShell; + readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; +}) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make({ + snapshotSequence: 1, + projects: [options.project ?? project], + threads: options.threads, + updatedAt: NOW, + }); + const reads = yield* Queue.unbounded(); + const events = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string; readonly refresh: boolean }> + >([]); + const summaryCalls = yield* Ref.make>([]); + let uuid = 0; + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), + }), + Layer.mock(GitManager)({ + branchPullRequest: (input, readOptions) => + Ref.update(branchCalls, (calls) => [ + ...calls, + { ...input, refresh: readOptions?.refresh === true }, + ]).pipe( + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), + ), + }), + Layer.mock(PullRequestService)({ + summary: (input, readOptions) => + Ref.update(summaryCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.summary?.(input, readOptions) ?? Effect.succeed(summary(input, "open")), + ), + ), + }), + Layer.mock(RepositoryIdentityResolver)({ + resolve: + options.resolveRepositoryIdentity ?? + (() => Effect.succeed(options.project?.repositoryIdentity ?? project.repositoryIdentity)), + }), + Layer.mock(OrchestrationEngineService)({ + subscribeDomainEvents: PubSub.subscribe(events).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + dispatch: (command) => { + if (command.type !== "thread.pull-request.sync") { + return Effect.die(`Unexpected command: ${command.type}`); + } + return Ref.update(commands, (current) => [...current, command]).pipe( + Effect.andThen( + Ref.updateAndGet(snapshots, (snapshot) => ({ + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((current) => + current.id === command.threadId + ? { + ...current, + branchPullRequest: command.branchPullRequest, + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), + } + : current, + ), + })), + ), + Effect.map((snapshot) => ({ sequence: snapshot.snapshotSequence })), + ); + }, + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(++uuid), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(options.existingWorktrees?.includes(path) ?? false), + }), + ); + + const start = Effect.fn("startThreadPullRequestHarness")(function* () { + const reactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(reads); + yield* reactor.drain; + return reactor; + }); + + return { + start, + reads, + snapshots, + commands, + branchCalls, + summaryCalls, + publish: (event: OrchestrationEvent) => PubSub.publish(events, event), + layer: ThreadPullRequestReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +describe("ThreadPullRequestReactor", () => { + it.effect("discovers saved branch PRs without a client and shares branch lookups", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("first"), + thread("second"), + thread("archived", { archivedAt: NOW }), + thread("no-branch", { branch: null }), + ], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.branchCalls)).toEqual([ + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, + ]); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + "first", + "second", + ]); + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual( + reference(42), + ); + + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect(yield* Ref.get(fixture.commands)).toHaveLength(2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("replaces terminal manual links but preserves open links and explicit unlink", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("merged", { linkedPullRequest: reference(1) }), + thread("closed", { linkedPullRequest: reference(2) }), + thread("open", { linkedPullRequest: reference(3) }), + thread("unlinked", { linkedPullRequest: null }), + ], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + summary: (input) => + Effect.succeed( + summary( + input, + input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open", + ), + ), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + const snapshot = yield* Ref.get(fixture.snapshots); + expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([ + reference(42), + reference(42), + reference(3), + null, + ]); + expect( + snapshot.threads.every((current) => current.branchPullRequest?.number === 42), + ).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("refreshes discovery after a turn ends without client demand", () => + Effect.scoped( + Effect.gen(function* () { + const current = thread("turn-thread"); + const detected = yield* Ref.make(null); + const fixture = yield* makeHarness({ + threads: [current], + branchPullRequest: (_input, options) => + options?.refresh + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) + : Ref.get(detected), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* fixture.publish({ + type: "thread.session-set", + sequence: 2, + eventId: EventId.make("turn-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: current.id, + session: { + threadId: current.id, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( + 1, + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("live", { worktreePath: "/workspace/worktree" }), + thread("removed", { worktreePath: "/workspace/removed" }), + ], + existingWorktrees: ["/workspace/worktree"], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual( + new Set(["/workspace/project", "/workspace/worktree"]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("terminal", { branch: "main", branchPullRequest: reference(1) }), + thread("open", { branchPullRequest: reference(2) }), + thread("worktree", { + worktreePath: "/workspace/worktree", + branchPullRequest: reference(1), + }), + thread("cleared", { + branch: null, + branchPullRequest: reference(1), + linkedPullRequest: reference(3), + }), + ], + summary: (input) => + Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + const snapshot = yield* Ref.get(fixture.snapshots); + expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([ + reference(1), + null, + null, + null, + ]); + expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3)); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps saved links on lookup failures and rejects a different repository", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("failed", { branch: "failed", branchPullRequest: reference(1) }), + thread("wrong-repository", { + branch: "wrong-repository", + branchPullRequest: reference(2), + }), + thread("healthy"), + ], + branchPullRequest: ({ cwd, branch }) => + branch === "failed" + ? Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Lookup failed", + }), + ) + : Effect.succeed({ + ...branchPullRequest(), + repositoryKey: + branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY, + }), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect( + (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest), + ).toEqual([reference(1), reference(2), reference(42)]); + expect(yield* Ref.get(fixture.commands)).toHaveLength(1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retries failed settled backfills and stops querying them after success", () => + Effect.scoped( + Effect.gen(function* () { + const online = yield* Ref.make(false); + const fixture = yield* makeHarness({ + threads: [ + thread("backfill", { settledOverride: "settled", settledAt: NOW }), + thread("known", { + branch: "known", + settledOverride: "settled", + settledAt: NOW, + branchPullRequest: reference(1), + }), + ], + branchPullRequest: ({ cwd }) => + Ref.get(online).pipe( + Effect.flatMap((connected) => + connected + ? Effect.succeed(branchPullRequest(42, "merged")) + : Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }), + ), + ), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* Ref.set(online, true); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ + "feature", + "feature", + "feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("stops retrying a settled backfill after repeated lookup failures", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })], + branchPullRequest: ({ cwd }) => + Effect.fail( + new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + for ( + let attempt = 1; + attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2; + attempt++ + ) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + expect(yield* Ref.get(fixture.branchCalls)).toHaveLength( + ThreadPullRequestReactor.BACKFILL_ATTEMPTS, + ); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [thread("azure")], + project: { + ...project, + repositoryIdentity: { + canonicalKey: "ssh.dev.azure.com/v3/org/project/repository", + displayName: "v3/org/project/repository", + name: "repository", + provider: "azure-devops", + rootPath: project.workspaceRoot, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository", + }, + }, + }, + branchPullRequest: () => + Effect.succeed({ + ...branchPullRequest(), + repositoryKey: "dev.azure.com/org/project/_git/repository", + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + }), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "repository", + number: 42, + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect.each(["primary", "branch"] as const)( + "rejects the group's links if the %s remote changes during a summary read", + (changedRemote) => + Effect.scoped( + Effect.gen(function* () { + const identity = yield* Ref.make(project.repositoryIdentity); + const detected = yield* Ref.make(branchPullRequest()); + const fixture = yield* makeHarness({ + threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")], + branchPullRequest: () => Ref.get(detected), + resolveRepositoryIdentity: (_cwd, options) => + options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity), + summary: (input) => + (changedRemote === "primary" + ? Ref.set(identity, { + ...project.repositoryIdentity, + canonicalKey: "github.com/other/repository", + displayName: "other/repository", + }) + : Ref.set(detected, { + ...branchPullRequest(99), + repositoryKey: "github.com/other/repository", + url: "https://github.com/other/repository/pull/99", + }) + ).pipe(Effect.as(summary(input, "merged"))), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual( + reference(1), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts new file mode 100644 index 000000000000..6e2aa0b3e054 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -0,0 +1,371 @@ +import { + CommandId, + type OrchestrationEvent, + type OrchestrationProjectShell, + type ThreadId, + type ThreadLinkedPullRequest, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; + +export class ThreadPullRequestReactor extends Context.Service< + ThreadPullRequestReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadPullRequestReactor") {} + +function samePullRequest( + left: ThreadLinkedPullRequest | null | undefined, + right: ThreadLinkedPullRequest | null, +): boolean { + if (left == null || right === null) return left == null && right === null; + return ( + left.projectId === right.projectId && + left.repository.toLowerCase() === right.repository.toLowerCase() && + left.number === right.number && + left.url === right.url + ); +} + +/** Startup lookups per settled thread before discovery gives up on it. */ +export const BACKFILL_ATTEMPTS = 5; + +interface RefreshRequest { + readonly threadId: ThreadId | null; + readonly refresh: boolean; + readonly backfill?: boolean; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +export function pullRequestMatchesProject( + pullRequest: GitManager.GitBranchPullRequest, + project: OrchestrationProjectShell, +): boolean { + return ( + pullRequest.repositoryKey !== null && + project.repositoryIdentity != null && + canonicalRepositoryKey(pullRequest.repositoryKey) === + canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + ); +} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + // Settled threads get one link discovery at startup. Failed lookups retry on + // the periodic pass a few times, then stop until the thread changes or the + // server restarts, so a missing or logged-out CLI cannot loop forever. + const pendingBackfill = new Map(); + const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) pendingBackfill.delete(thread.id); + }; + const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) { + const remaining = pendingBackfill.get(thread.id); + if (remaining === undefined) continue; + if (remaining <= 1) pendingBackfill.delete(thread.id); + else pendingBackfill.set(thread.id, remaining - 1); + } + }; + + const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( + request: RefreshRequest, + ) { + const snapshot = yield* snapshots.getShellSnapshot(); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + if (request.backfill) { + for (const thread of snapshot.threads) { + if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { + pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); + } + } + } + const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); + for (const threadId of pendingBackfill.keys()) { + if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + } + const threads = snapshot.threads.filter( + (thread) => + thread.archivedAt === null && + (request.threadId === null || thread.id === request.threadId) && + (thread.settledOverride !== "settled" || + request.threadId !== null || + pendingBackfill.has(thread.id)) && + (thread.branch !== null || thread.branchPullRequest != null), + ); + const groups = Map.groupBy(threads, (thread) => + JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), + ); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const first = group[0]!; + const project = projects.get(first.projectId); + if (project === undefined) return finishBackfill(group); + const repository = PullRequestService.repositoryIdentityOf(project); + if (first.branch !== null && repository === null) return finishBackfill(group); + const worktreeExists = + first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); + const cwd = + worktreeExists && first.worktreePath !== null + ? first.worktreePath + : project.workspaceRoot; + const detected = + first.branch === null + ? null + : yield* git.branchPullRequest( + { cwd, branch: first.branch }, + { refresh: request.refresh }, + ); + // A worktree can have different remotes, and the project identity + // can lag a remote edit. Do not attach its PR to the wrong repository. + if (detected !== null && !pullRequestMatchesProject(detected, project)) { + return finishBackfill(group); + } + const detectedReference = + detected !== null && repository !== null + ? { + projectId: project.id, + repository, + number: detected.number, + url: detected.url, + } + : null; + + const plans = yield* Effect.forEach(group, (thread) => + Effect.gen(function* () { + let branchPullRequest = detectedReference; + // Shared checkouts often return to the default branch after + // a merge. Keep that thread's terminal PR across the change. + if ( + branchPullRequest === null && + thread.branch !== null && + thread.worktreePath === null && + thread.branchPullRequest != null + ) { + const previous = yield* pullRequests.summary(thread.branchPullRequest, { + recoverTransientFailure: false, + }); + if (previous.state === "merged" || previous.state === "closed") { + branchPullRequest = thread.branchPullRequest; + } + } + + let replacement: ThreadLinkedPullRequest | undefined; + if ( + thread.linkedPullRequest != null && + detected?.state === "open" && + detectedReference !== null && + !samePullRequest(thread.linkedPullRequest, detectedReference) + ) { + const linked = yield* pullRequests.summary(thread.linkedPullRequest, { + recoverTransientFailure: false, + }); + if (linked.state === "merged" || linked.state === "closed") { + replacement = detectedReference; + } + } + + if ( + samePullRequest(thread.branchPullRequest, branchPullRequest) && + replacement === undefined + ) { + pendingBackfill.delete(thread.id); + return null; + } + return { thread, branchPullRequest, replacement }; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request discovery failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), + Effect.as(null), + ), + ), + ), + ); + const updates = plans.filter((plan) => plan !== null); + if (updates.length === 0) return; + + if (detected !== null && first.branch !== null) { + // Summary reads can outlast a remote edit. Recheck the branch and + // the project's primary remote before saving the group's links. + const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); + const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { + refresh: true, + }); + if ( + current === null || + current.number !== detected.number || + current.url !== detected.url || + current.state !== detected.state || + current.repositoryKey !== detected.repositoryKey || + !pullRequestMatchesProject(current, { + ...project, + repositoryIdentity: currentIdentity, + }) + ) { + return failBackfill(updates.map((update) => update.thread)); + } + } + + yield* Effect.forEach( + updates, + ({ thread, branchPullRequest, replacement }) => + Effect.gen(function* () { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), + threadId: thread.id, + projectId: project.id, + snapshotSequence: snapshot.snapshotSequence, + expected: { + workspaceRoot: project.workspaceRoot, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, + branchPullRequest: thread.branchPullRequest ?? null, + }, + branchPullRequest, + ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}), + }); + pendingBackfill.delete(thread.id); + }).pipe( + // The thread changed since the lookup. Its own events requeue it. + Effect.catchTags({ + OrchestrationCommandInvariantError: () => + Effect.sync(() => finishBackfill([thread])), + }), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread branch pull request lookup failed", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker((request: RefreshRequest) => + synchronize(request).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request refresh failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const processEvent = (event: OrchestrationEvent) => { + switch (event.type) { + case "thread.created": + case "thread.unarchived": + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); + case "thread.meta-updated": + if ( + event.payload.branchPullRequest === undefined && + (event.payload.branch !== undefined || + event.payload.worktreePath !== undefined || + event.payload.linkedPullRequest !== undefined) + ) { + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); + } + break; + case "thread.session-set": + if ( + event.payload.session.status !== "running" && + event.payload.session.status !== "starting" + ) { + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + } + break; + case "thread.turn-diff-completed": + case "thread.unsettled": + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + case "project.meta-updated": + if (event.payload.workspaceRoot !== undefined) { + return worker.enqueue({ threadId: null, refresh: false }); + } + break; + } + return Effect.void; + }; + + const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { + const events = yield* engine.subscribeDomainEvents; + yield* forkParked(Stream.runForEach(events, processEvent)); + // Run without client demand. Saved branch lookups share GitManager's + // provider cache and retry backoff with status and automatic settlement. + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); + yield* worker.drain; + yield* Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false }); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); + }).pipe(Effect.asVoid), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadPullRequestReactor, make); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 05fb16203e0b..61c512e6fd91 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -36,7 +36,7 @@ const makeThread = ( const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: SettlementPullRequest | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => resolveAutoSettlementAt({ @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { latestTurn: null, updatedAt: "2026-08-27T00:00:00.000Z", }), - pullRequest: { state: "closed", updatedAt: NOW }, + pullRequest: { state: "closed", closedAt: NOW }, now: NOW, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { }); it("settles closed requests and honors the merge setting", () => { - expect(decide(makeThread(), { state: "closed", updatedAt: NOW }, { merge: false })).toBe(true); - expect(decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "closed", closedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); expect( - decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), ).toBe(false); }); @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { expect( decide( makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), - { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, { days: null }, ), ).toBe(false); }); + it.each(["closed", "merged"] as const)( + "ignores metadata edits after resumed work for %s requests", + (state) => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { + state, + closedAt: "2026-08-26T00:00:00.000Z", + mergedAt: "2026-08-26T00:00:00.000Z", + updatedAt: NOW, + }, + { days: null }, + ), + ).toBe(false); + expect(decide(makeThread(), { state, updatedAt: NOW }, { days: null })).toBe(false); + }, + ); + it("does not inherit a terminal pull request older than the thread", () => { expect( decide( makeThread({ createdAt: "2026-08-20T00:00:00.000Z", latestUserMessageAt: null }), - { state: "closed", updatedAt: "2026-08-19T00:00:00.000Z" }, + { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, { days: null }, ), ).toBe(false); @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { it("requires a comparable PR timestamp for immediate settlement", () => { const recentThread = makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }); - expect(decide(recentThread, { state: "closed", updatedAt: null })).toBe(false); - expect(decide(recentThread, { state: "merged", updatedAt: "unknown" })).toBe(false); - expect(decide(makeThread(), { state: "closed", updatedAt: null })).toBe(true); + expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); }); it("uses user request time instead of completion time as the PR anchor", () => { @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { assistantMessageId: null, }, }); - expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); }); it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index eac5a960a482..7c55fa37d1f3 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string | null; } const DAY_MS = 24 * 60 * 60 * 1_000; @@ -49,14 +51,15 @@ function pullRequestSettles( if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } - if (pullRequest.updatedAt === null) return false; + const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; + if (terminalAt == null) return false; const userAnchor = latestTimestamp([ thread.createdAt, thread.latestUserMessageAt, thread.latestTurn?.requestedAt, ]); if (userAnchor === null) return false; - const pullRequestAt = Date.parse(pullRequest.updatedAt); + const pullRequestAt = Date.parse(terminalAt); const userAnchorAt = Date.parse(userAnchor); if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; return pullRequestAt >= userAnchorAt; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index d3b24d5f77b1..f2038bfe689d 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -25,7 +25,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { GitManager } from "../git/GitManager.ts"; +import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -127,6 +127,26 @@ function makePullRequestSummary(input: { headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, + }; +} + +function makeBranchPullRequest( + state: GitBranchPullRequest["state"], + updatedAt: string | null = NOW, +): GitBranchPullRequest { + return { + number: 42, + title: "Branch pull request", + url: "https://example.test/owner/repository/pull/42", + baseRef: "main", + headRef: "saved-feature", + repositoryKey: "example.test/owner/repository", + state, + updatedAt, + closedAt: state === "closed" ? updatedAt : null, + mergedAt: state === "merged" ? updatedAt : null, }; } @@ -171,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: return next; }); - const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) => Ref.update(branchCalls, (calls) => [...calls, input]).pipe( - Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), ); const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { @@ -279,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const previous = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const project = { + ...makeProject(), + repositoryIdentity: { + canonicalKey: "example.test/owner/repository", + rootPath: "/workspace/project", + displayName: "owner/repository", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + }, + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }), + makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }), + makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), + makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), + makeThread("resumed-manual", { + branch: "main", + linkedPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + makeThread("resumed-detected", { + branch: "main", + branchPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ], + [project], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: ({ branch }, options) => + Effect.succeed( + branch === "reused" + ? makeBranchPullRequest(options?.refresh ? "open" : "merged") + : branch === "foreign" + ? { + ...makeBranchPullRequest("open"), + repositoryKey: "example.test/another/repository", + } + : null, + ), + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { @@ -303,7 +400,10 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("inactive", { branch: "inactive-feature" }), - makeThread("closed-pr", { linkedPullRequest }), + makeThread("closed-pr", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ...skipped, ], [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], @@ -335,7 +435,7 @@ describe("ThreadSettlementReactor", () => { { threadId: ThreadId.make("closed-pr"), snapshotSequence: 1, - settledAt: "2026-08-20T00:00:00.000Z", + settledAt: "2026-08-27T00:00:00.000Z", }, { threadId: ThreadId.make("inactive"), @@ -344,9 +444,7 @@ describe("ThreadSettlementReactor", () => { }, ], ); - assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ - { cwd: "/workspace/project", branch: "inactive-feature" }, - ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, ]); @@ -372,7 +470,7 @@ describe("ThreadSettlementReactor", () => { }), ]), branchPullRequest: () => - Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))), }); yield* Effect.gen(function* () { @@ -425,10 +523,10 @@ describe("ThreadSettlementReactor", () => { Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( Effect.flatMap((count) => count === 1 - ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) + ? Effect.succeed(makeBranchPullRequest("open")) : Deferred.succeed(periodicLookupStarted, undefined).pipe( Effect.andThen(Deferred.await(releasePeriodicLookup)), - Effect.as({ state: "open" as const, updatedAt: NOW }), + Effect.as(makeBranchPullRequest("open")), ), ), ), @@ -472,7 +570,7 @@ describe("ThreadSettlementReactor", () => { ]), branchPullRequest: () => Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), ), onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), }); @@ -580,7 +678,12 @@ describe("ThreadSettlementReactor", () => { const releaseLaterLookup = yield* Deferred.make(); const lookupCount = yield* Ref.make(0); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + snapshot: makeSnapshot([ + makeThread("settings-thread", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ]), settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null, @@ -603,7 +706,7 @@ describe("ThreadSettlementReactor", () => { : Effect.void, ), Effect.andThen(Ref.get(state)), - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), ), }); @@ -647,6 +750,7 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("lookup-failed", { + latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest: { projectId: LINKED_PROJECT_ID, repository: "owner/repository", @@ -681,6 +785,121 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("settles inactive linked and branch threads without reading an unavailable host", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("inactive-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("inactive-branch", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-21T00:00:00.000Z", + }), + ]), + branchPullRequest: () => Effect.die(new Error("host unavailable")), + pullRequestSummary: () => + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map(({ threadId, snapshotSequence, settledAt }) => ({ + threadId, + snapshotSequence, + settledAt, + })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("inactive-branch"), + snapshotSequence: 1, + settledAt: "2026-08-21T00:00:00.000Z", + }, + { + threadId: ThreadId.make("inactive-linked"), + snapshotSequence: 1, + settledAt: "2026-08-20T00:00:00.000Z", + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles an inactive thread before its shared pull request lookup completes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const lookupStarted = yield* Deferred.make(); + const releaseLookup = yield* Deferred.make(); + const linkedPullRequest = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("recent-linked", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("inactive-linked", { linkedPullRequest }), + ]), + pullRequestSummary: () => + Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseLookup)), + Effect.andThen( + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + detail: "host unavailable", + }), + ), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Deferred.await(lookupStarted); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("inactive-linked")], + ); + + yield* Deferred.succeed(releaseLookup, undefined); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("keeps threads active when their pull request project is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -698,7 +917,10 @@ describe("ThreadSettlementReactor", () => { latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest, }), - makeThread("missing-branch-project", { branch: "saved-feature" }), + makeThread("missing-branch-project", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), @@ -736,20 +958,28 @@ describe("ThreadSettlementReactor", () => { makeThread("branch-one", { branch: "saved-feature", worktreePath: "/deleted/worktree-one", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("branch-two", { branch: "saved-feature", worktreePath: "/deleted/worktree-two", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-one", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-two", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), - makeThread("linked-one", { linkedPullRequest }), - makeThread("linked-two", { linkedPullRequest }), ], [ makeProject(PROJECT_ID, "/workspace/project-root"), makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), ], ), - branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")), pullRequestSummary: (input) => Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); @@ -788,10 +1018,12 @@ describe("ThreadSettlementReactor", () => { makeThread("live-worktree", { branch: "feature/live", worktreePath: "/workspace/project-root/.worktrees/live", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("deleted-worktree", { branch: "feature/deleted", worktreePath: "/workspace/project-root/.worktrees/deleted", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), ], [makeProject(PROJECT_ID, "/workspace/project-root")], diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 6539135adfe6..994830b30a47 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; +import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt, @@ -46,19 +47,67 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - // A merge event re-sweeps every candidate, not just the threads linked to - // the merged pull request: most threads carry no link and settle from - // their branch lookup, which would otherwise wait for the next minute's - // sweep on a possibly stale cached answer. + // A merge rechecks all candidates, including branches that discovery has + // not linked yet. Those lookups can still have cached the PR as open. const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); - // Use the same cwd as the sidebar so both paths share GitManager's PR cache. + + // Return the thread when it still needs a pull request decision. A rejected + // dispatch skips it for this snapshot instead of retrying through a lookup. + const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + const settledAt = resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }); + if (settledAt === null) { + return thread; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + settledAt, + }); + return null; + }, + (effect, thread) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ), + ); + + // Inactivity needs no host state. Finish these decisions before any lookup + // can fail or wait on the network, including lookups shared by recent threads. + const lookupCandidates = (yield* Effect.forEach( + candidates, + (thread) => settleThread(thread, null), + { + concurrency: 8, + }, + )).filter((thread) => thread !== null); + + // Use the same cwd as PR discovery so both paths share GitManager's cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( - candidates, + lookupCandidates, (thread) => Effect.gen(function* () { const project = projects.get(thread.projectId); - if (project === undefined || thread.linkedPullRequest != null) return; + if (project === undefined || thread.branch === null) return; const worktreeExists = thread.worktreePath !== null && (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); @@ -72,12 +121,8 @@ export const make = Effect.gen(function* () { { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { - // The merge just confirmed a terminal state the lookup caches can still - // call open (branch answers live two minutes, the sweep runs every - // minute). Drop the swept checkouts' cached answers so the merge settles - // its branch threads now instead of on a later sweep. Threads linked to - // the merged pull request settle from the event itself below and need no - // lookup, so they are absent from this map by construction. + // The merge confirmed a state the branch cache can still call open. + // Recheck those branches now instead of waiting for cache expiry. const cwds = [...new Set(lookupCwdByThreadId.values())]; yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { concurrency: 8, @@ -85,12 +130,15 @@ export const make = Effect.gen(function* () { }); } const lookupKey = (thread: (typeof candidates)[number]) => { - if (thread.linkedPullRequest != null) { + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + if (reference != null) { return JSON.stringify([ "linked", - thread.linkedPullRequest.projectId, - thread.linkedPullRequest.repository, - thread.linkedPullRequest.number, + reference.projectId, + reference.repository, + reference.number, + lookupCwdByThreadId.get(thread.id), + thread.branch, ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); @@ -99,43 +147,56 @@ export const make = Effect.gen(function* () { cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; - const groups = Map.groupBy(candidates, lookupKey); + const groups = Map.groupBy(lookupCandidates, lookupKey); const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( thread: (typeof candidates)[number], ) { - if (thread.linkedPullRequest != null) { - // The event carries the merged state, so only the threads linked to - // that exact pull request settle from it. Every other linked thread - // falls through to a fresh summary lookup below: the merge sweep - // covers all candidates, and an unrelated merge must never settle - // them. - if ( + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + if (reference != null) { + const matchesMerge = mergedPullRequest !== null && - thread.linkedPullRequest.projectId === mergedPullRequest.projectId && - thread.linkedPullRequest.repository.toLowerCase() === - mergedPullRequest.repository.toLowerCase() && - thread.linkedPullRequest.number === mergedPullRequest.number - ) { - return { - state: "merged", - updatedAt: mergedPullRequest.mergedAt, - } satisfies SettlementPullRequest; - } - if (!projects.has(thread.linkedPullRequest.projectId)) { + reference.projectId === mergedPullRequest.projectId && + reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && + reference.number === mergedPullRequest.number; + if (!matchesMerge && !projects.has(reference.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } - const summary = yield* pullRequests.summary( - { - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, - }, - { recoverTransientFailure: false }, - ); + const summary = matchesMerge + ? ({ + state: "merged", + closedAt: null, + mergedAt: mergedPullRequest.mergedAt, + } satisfies SettlementPullRequest) + : yield* pullRequests.summary( + { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + }, + { recoverTransientFailure: false }, + ); + const cwd = lookupCwdByThreadId.get(thread.id); + if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { + // A reused branch can already have a new open PR while discovery + // is replacing its old link. Do not let settlement win that race. + const current = yield* git.branchPullRequest( + { cwd, branch: thread.branch }, + { refresh: true }, + ); + const project = projects.get(thread.projectId); + if ( + current?.state === "open" && + project !== undefined && + pullRequestMatchesProject(current, project) + ) { + return current; + } + } return { state: summary.state, - updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, } satisfies SettlementPullRequest; } if (thread.branch === null) return null; @@ -151,42 +212,9 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const pullRequest = yield* pullRequestFor(group[0]!); - yield* Effect.forEach( - group, - (thread) => - Effect.gen(function* () { - const settings = yield* settingsService.getSettings; - const decisionNow = DateTime.formatIso(yield* DateTime.now); - const settledAt = resolveAutoSettlementAt({ - thread, - pullRequest, - now: decisionNow, - autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, - autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, - }); - if (settledAt === null) { - return; - } - const uuid = yield* crypto.randomUUIDv4; - yield* engine.dispatch({ - type: "thread.auto-settle", - commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), - threadId: thread.id, - snapshotSequence: snapshot.snapshotSequence, - settledAt, - }); - }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadId: thread.id, - cause: Cause.pretty(cause), - }), - ), - ), - { discard: true }, - ); + yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { + discard: true, + }); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9aaeba943423..93777c67d3e1 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,12 +11,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { - findThreadById, - listThreadsByProjectId, - requireThread, - requireThreadAbsent, -} from "./commandInvariants.ts"; +import { listThreadsByProjectId, requireThread, requireThreadAbsent } from "./commandInvariants.ts"; const now = "2026-01-01T00:00:00.000Z"; @@ -121,9 +116,7 @@ const messageSendCommand: OrchestrationCommand = { }; describe("commandInvariants", () => { - it("finds threads by id and project", () => { - expect(findThreadById(readModel, ThreadId.make("thread-1"))?.projectId).toBe("project-a"); - expect(findThreadById(readModel, ThreadId.make("missing"))).toBeUndefined(); + it("lists threads by project", () => { expect( listThreadsByProjectId(readModel, ProjectId.make("project-b")).map((thread) => thread.id), ).toEqual([ThreadId.make("thread-2")]); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index beaad93d5eef..110a499d37c9 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -18,7 +18,7 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( +function findThreadById( readModel: OrchestrationReadModel, threadId: ThreadId, ): OrchestrationThread | undefined { diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts new file mode 100644 index 000000000000..58a7f5c054ec --- /dev/null +++ b/apps/server/src/orchestration/decider.active-order.test.ts @@ -0,0 +1,200 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The Effect test clock starts at the epoch. +const BEFORE_NOW = "1969-12-30T00:00:00.000Z"; +const SNOOZED_AT = "1969-12-31T00:00:00.000Z"; +const FUTURE_WAKE = "1970-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeReadModel(overrides: Partial = {}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + activeOrderKey: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + }, + ], + updatedAt: NOW, + }; +} + +const reorderCommand = { + type: "thread.active.reorder", + commandId: CommandId.make("cmd-active-reorder"), + threadId: THREAD_ID, + orderKey: "m", +} as const; + +it.layer(NodeServices.layer)("active thread ordering", (it) => { + it.effect("persists changed and repeated slots without changing thread activity timestamps", () => + Effect.gen(function* () { + let readModel = makeReadModel({ unsettledAt: BEFORE_NOW }); + for (const orderKey of ["m", "m", "g"]) { + const decided = yield* decideOrchestrationCommand({ + command: { ...reorderCommand, orderKey }, + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, activeOrderKey: orderKey, updatedAt: NOW }, + }); + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]).toMatchObject({ + activeOrderKey: orderKey, + updatedAt: NOW, + createdAt: NOW, + unsettledAt: BEFORE_NOW, + }); + } + }), + ); + + for (const [label, overrides] of [ + ["archived", { archivedAt: NOW }], + ["deleted", { deletedAt: NOW }], + ["pinned", { pinnedAt: NOW }], + ["settled", { settledOverride: "settled", settledAt: NOW }], + ] satisfies ReadonlyArray]>) { + it.effect(`rejects reordering a ${label} thread`, () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: reorderCommand, + readModel: makeReadModel(overrides), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + } + + it.effect("reorders a running thread without affecting its session", () => + Effect.gen(function* () { + const readModel = makeReadModel({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect( + "changes a snoozed thread's retained slot without waking it or changing timestamps", + () => + Effect.gen(function* () { + const readModel = makeReadModel({ + activeOrderKey: "g", + snoozedAt: SNOOZED_AT, + snoozedUntil: FUTURE_WAKE, + unsettledAt: BEFORE_NOW, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect("keeps placement through metadata, pin and snooze, then resets it on settlement", () => + Effect.gen(function* () { + let readModel = makeReadModel(); + const steps = [ + [reorderCommand, "m"], + [{ type: "thread.meta.update", title: "Renamed" }, "m"], + [{ type: "thread.pin", orderKey: "g" }, "m"], + [{ type: "thread.snooze", snoozedUntil: FUTURE_WAKE }, "m"], + [{ type: "thread.unsnooze", reason: "user" }, "m"], + [{ type: "thread.unpin" }, "m"], + [{ type: "thread.settle" }, null], + [{ type: "thread.unsettle", reason: "user" }, null], + [{ type: "thread.active.reorder", orderKey: "s" }, "s"], + ] as const; + for (const [index, [step, expectedKey]] of steps.entries()) { + const command: OrchestrationCommand = { + ...step, + commandId: CommandId.make(`lifecycle-${index}`), + threadId: THREAD_ID, + }; + const decided = yield* decideOrchestrationCommand({ command, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]?.activeOrderKey, command.type).toBe(expectedKey); + } + expect(readModel.threads[0]).toMatchObject({ + title: "Renamed", + settledOverride: "active", + settledAt: null, + snoozedUntil: null, + pinnedAt: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000000..c809c733800a --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +it.layer(NodeServices.layer)("thread history import", (it) => { + it.effect("marks imported thread creation without changing live creation", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const projectId = ProjectId.make("project-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + const makeCreateCommand = (threadId: ThreadId) => ({ + type: "thread.create" as const, + commandId: CommandId.make(`command-create-${threadId}`), + threadId, + projectId, + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }); + + const imported = yield* decideOrchestrationCommand({ + command: { + ...makeCreateCommand(ThreadId.make("import:codex:session-1")), + historyImport: true, + }, + readModel, + }); + const live = yield* decideOrchestrationCommand({ + command: makeCreateCommand(ThreadId.make("live-thread")), + readModel, + }); + + expect(imported).toMatchObject({ + type: "thread.created", + metadata: { historyImport: true }, + }); + expect(live).toMatchObject({ type: "thread.created" }); + expect(live).not.toMatchObject({ metadata: { historyImport: true } }); + }), + ); + + it.effect("settles imported messages at the latest absolute timestamp", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:30:00.000+02:00"; + const threadId = ThreadId.make("import:codex:session-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-import-history"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt, + }, + { + messageId: MessageId.make(`${threadId}:000001`), + role: "assistant", + text: "Fixed", + createdAt: "2026-08-24T09:00:00.000Z", + }, + ], + }, + readModel, + }); + + expect(events).toMatchObject([ + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, + }, + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, + }, + { + type: "thread.settled", + metadata: { historyImport: true }, + occurredAt: "2026-08-24T09:00:00.000Z", + payload: { + settledAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + }, + }, + ]); + + let projected = readModel; + const plannedEvents = Array.isArray(events) ? events : [events]; + for (const [index, event] of plannedEvents.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); + } + projected = yield* projectEvent(projected, { + sequence: 5, + eventId: EventId.make("event-import-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.reverted", + occurredAt: "2026-08-24T10:02:00.000Z", + commandId: CommandId.make("command-import-reverted"), + causationEventId: null, + correlationId: CommandId.make("command-import-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ + "Fix the bug", + "Fixed", + ]); + }), + ); + + it.effect("allows a thread with a newly imported user message to be settled", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); + const threadId = ThreadId.make("import:codex:session-1"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-import-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-import-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-import-user-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: createdAt, + commandId: CommandId.make("command-import-user-message"), + causationEventId: null, + correlationId: CommandId.make("command-import-user-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:session-1:0"), + role: "user", + text: "Existing prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("command-settle-imported-thread"), + threadId, + }, + readModel, + }); + + expect(result).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("rejects history import after a client message reaches the thread", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const liveMessageAt = "2026-08-24T10:02:00.000Z"; + const threadId = ThreadId.make("import:codex:client-race"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-client-race-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-client-race-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-client-race-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: liveMessageAt, + commandId: CommandId.make("command-client-race-message"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-message"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("client-race-message"), + role: "user", + text: "Start live work", + turnId: null, + streaming: false, + createdAt: liveMessageAt, + updatedAt: liveMessageAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-client-race-import"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); + }), + ); + + for (const requestKind of ["approval.requested", "user-input.requested"] as const) { + it.effect(`rejects history import with an open ${requestKind} activity`, () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make(`import:codex:${requestKind}`); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make(`event-${requestKind}-thread-created`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}-thread-created`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}-thread-created`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make(`event-${requestKind}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.activity-appended", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${requestKind}`), + tone: "approval", + kind: requestKind, + summary: "Pending request", + payload: { requestId: "request-1" }, + turnId: null, + createdAt, + }, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make(`command-import-${requestKind}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + }), + ); + } + + it.effect("rejects a live user message in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("command-live-import-id"), + threadId, + message: { + messageId: MessageId.make("import:forged-live-message"), + role: "user", + text: "Live work", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + }), + ); + + it.effect("rejects live assistant messages in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-assistant-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-assistant-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-assistant-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-assistant-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const commandType of [ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ] as const) { + const command = + commandType === "thread.message.assistant.delta" + ? { + type: commandType, + commandId: CommandId.make("command-live-assistant-delta-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + delta: "Live work", + createdAt, + } + : { + type: commandType, + commandId: CommandId.make("command-live-assistant-complete-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + createdAt, + }; + const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b336053ac9e1..9dc55e1194cc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,13 +1,16 @@ import { EventId, MessageId, + ThreadLinkedPullRequest, UserInputRequestedPayload, + isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -36,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); +const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); /** * Blocked-on-you work derived from the thread's retained activities: an @@ -98,7 +102,7 @@ function hasQueuedTurnStartForThread( let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { - if (message.role !== "user") continue; + if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { @@ -347,6 +351,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, + ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { @@ -783,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.active.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Snooze retains this slot. Changing it cannot wake the thread, and + // accepting it handles races with snooze and retained wake timestamps. + if ( + thread.deletedAt !== null || + thread.pinnedAt != null || + thread.settledOverride === "settled" + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not active and cannot be reordered`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + activeOrderKey: command.orderKey, + // Arranging the list is not thread activity or a lifecycle transition. + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -833,6 +874,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pull-request.sync": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.deletedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was deleted before pull request discovery`, + }); + } + if ( + thread.projectId !== command.projectId || + thread.branch !== command.expected.branch || + thread.worktreePath !== command.expected.worktreePath || + !threadPullRequestLinksEqual( + thread.linkedPullRequest ?? null, + command.expected.linkedPullRequest, + ) || + !threadPullRequestLinksEqual( + thread.branchPullRequest ?? null, + command.expected.branchPullRequest, + ) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} changed before pull request discovery`, + }); + } + const project = yield* requireProject({ readModel, command, projectId: command.projectId }); + if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `project ${command.projectId} changed before pull request discovery`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + branchPullRequest: command.branchPullRequest, + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, @@ -905,6 +1003,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } const targetThread = yield* requireThread({ readModel, command, @@ -1272,6 +1376,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.delta": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1299,6 +1409,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.complete": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1325,6 +1441,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.messages.length > 0 || + thread.latestTurn !== null || + thread.session !== null || + hasOpenBlockingRequest(thread) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, + }); + } + const firstMessage = command.messages[0]; + if (firstMessage === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history imports require at least one message.", + }); + } + + const events: Array = []; + for (const message of command.messages) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: message.createdAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + const settledAt = command.messages.reduce( + (latest, message) => + compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, + firstMessage.createdAt, + ); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: settledAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }); + return events; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f9..e973b523275f 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -7,6 +7,7 @@ import { type OrchestrationEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { it as effectIt } from "@effect/vitest"; import { describe, expect, it } from "vite-plus/test"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -85,10 +86,12 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + branchPullRequest: null, latestTurn: null, createdAt: now, updatedAt: now, archivedAt: null, + activeOrderKey: null, settledOverride: null, settledAt: null, unsettledAt: null, @@ -104,6 +107,67 @@ describe("orchestration projector", () => { ]); }); + effectIt.effect("sets and clears branch pull requests without changing manual links", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const eventFields = { + aggregateKind: "thread" as const, + aggregateId: "thread-1", + occurredAt: now, + commandId: null, + }; + let model = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + ...eventFields, + sequence: 1, + type: "thread.created", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "Pull request thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: "feature", + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const linkedPullRequest = { + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const updates = [ + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, + { payload: { branchPullRequest: null }, expected: null }, + ]; + + for (const [index, update] of updates.entries()) { + model = yield* projectEvent( + model, + makeEvent({ + ...eventFields, + sequence: index + 2, + type: "thread.meta-updated", + payload: { threadId: "thread-1", updatedAt: now, ...update.payload }, + }), + ); + expect(model.threads[0]?.branchPullRequest).toEqual(update.expected); + expect(model.threads[0]?.linkedPullRequest).toEqual(linkedPullRequest); + } + }), + ); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); @@ -239,110 +303,234 @@ describe("orchestration projector", () => { expect(next.threads).toEqual([]); }); - it("tracks latest turn id from session lifecycle events", async () => { - const createdAt = "2026-02-23T08:00:00.000Z"; - const startedAt = "2026-02-23T08:00:05.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = await Effect.runPromise( - projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ), - ); + effectIt.effect.each([ + ["ready", "completed"], + ["interrupted", "interrupted"], + ] as const)( + "preserves the turn state after a %s session captures its checkpoint", + ([status, state]) => + Effect.gen(function* () { + const createdAt = "2026-02-23T08:00:00.000Z"; + const startedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); - const settledAt = "2026-02-23T08:01:00.000Z"; - const [afterRunning, afterReady] = await Effect.runPromise( - Effect.flatMap( - projectEvent( - afterCreate, + const afterCreate = yield* projectEvent( + model, makeEvent({ - sequence: 2, - type: "thread.session-set", + sequence: 1, + type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", - occurredAt: startedAt, - commandId: "cmd-running", + occurredAt: createdAt, + commandId: "cmd-create", payload: { threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "codex", - providerSessionId: "session-1", - providerThreadId: "provider-thread-1", - runtimeMode: "approval-required", - activeTurnId: "turn-1", - lastError: null, - updatedAt: startedAt, + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, }, }), - ), - (running) => - Effect.map( - projectEvent( - running, - makeEvent({ - sequence: 3, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: settledAt, - commandId: "cmd-ready", - payload: { + ); + + const settledAt = "2026-02-23T08:01:00.000Z"; + const [afterRunning, afterReady] = yield* Effect.flatMap( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: startedAt, + commandId: "cmd-running", + payload: { + threadId: "thread-1", + session: { threadId: "thread-1", - session: { + status: "running", + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "approval-required", + activeTurnId: "turn-1", + lastError: null, + updatedAt: startedAt, + }, + }, + }), + ), + (running) => + Effect.map( + projectEvent( + running, + makeEvent({ + sequence: 3, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: settledAt, + commandId: "cmd-ready", + payload: { threadId: "thread-1", - status: "ready", - providerName: "codex", - providerSessionId: "session-1", - providerThreadId: "provider-thread-1", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: settledAt, + session: { + threadId: "thread-1", + status, + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: settledAt, + }, }, - }, - }), + }), + ), + (ready) => [running, ready] as const, ), - (ready) => [running, ready] as const, - ), - ), - ); + ); - const thread = afterRunning.threads[0]; - expect(thread?.latestTurn?.turnId).toBe("turn-1"); - expect(thread?.session?.status).toBe("running"); - - // Leaving the "running" session status settles the running turn with the - // session timestamp as the turn end. - const settledThread = afterReady.threads[0]; - expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); - expect(settledThread?.latestTurn?.state).toBe("completed"); - expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); - }); + const thread = afterRunning.threads[0]; + expect(thread?.latestTurn?.turnId).toBe("turn-1"); + expect(thread?.session?.status).toBe("running"); + + // Leaving the "running" session status settles the running turn with the + // session timestamp as the turn end. + const settledThread = afterReady.threads[0]; + expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); + expect(settledThread?.latestTurn?.state).toBe(state); + expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); + + const captured = yield* projectEvent( + afterReady, + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: settledAt, + commandId: "cmd-final-checkpoint", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant:turn-1", + completedAt: settledAt, + }, + }), + ); + expect(captured.threads[0]?.latestTurn?.state).toBe(state); + expect(captured.threads[0]?.checkpoints[0]?.status).toBe("ready"); + }), + ); + + effectIt.effect.each([null, "ready", "interrupted", "stopped"] as const)( + "replaces a missing checkpoint without inventing interruption for a %s session", + (sessionStatus) => + Effect.gen(function* () { + const now = "2026-09-04T23:00:00.000Z"; + const threadId = "thread-placeholder"; + const event = (sequence: number, type: OrchestrationEvent["type"], payload: unknown) => + makeEvent({ + sequence, + type, + payload, + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: `placeholder-${sequence}`, + }); + let model = yield* projectEvent( + createEmptyReadModel(now), + event(1, "thread.created", { + threadId, + projectId: "project-1", + title: "Placeholder", + modelSelection: { instanceId: "codex", model: "test" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }), + ); + const checkpoint = { + threadId, + turnId: "turn-placeholder", + checkpointTurnCount: 1, + checkpointRef: "provider-diff:placeholder", + files: [], + assistantMessageId: "assistant:placeholder", + completedAt: now, + }; + if (sessionStatus === "interrupted" || sessionStatus === "stopped") { + model = yield* projectEvent( + model, + event(2, "thread.session-set", { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: "turn-placeholder", + lastError: null, + updatedAt: now, + }, + }), + ); + } + model = yield* projectEvent( + model, + event(3, "thread.turn-diff-completed", { + ...checkpoint, + status: "missing", + }), + ); + if (sessionStatus !== null) { + model = yield* projectEvent( + model, + event(4, "thread.session-set", { + threadId, + session: { + threadId, + status: sessionStatus, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + } + model = yield* projectEvent( + model, + event(5, "thread.turn-diff-completed", { + ...checkpoint, + status: "ready", + checkpointRef: "refs/t3/checkpoints/thread-placeholder/turn/1", + }), + ); + expect(model.threads[0]?.latestTurn?.state).toBe( + sessionStatus === "interrupted" || sessionStatus === "stopped" + ? "interrupted" + : "completed", + ); + }), + ); it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 3cea194bbb44..c048247f4128 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,10 +1,12 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -64,7 +66,7 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) { function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; - if (status === "missing") return "interrupted" as const; + // Match SQL and client projections: a missing git ref is not an interruption. return "completed" as const; } @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); continue; } @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { @@ -326,6 +336,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + branchPullRequest: null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -333,6 +344,7 @@ export function projectEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -395,6 +407,7 @@ export function projectEvent( settledOverride: "settled", settledAt: payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: payload.updatedAt, }), })), @@ -489,6 +502,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.activeOrderKey !== undefined + ? { activeOrderKey: payload.activeOrderKey } + : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), @@ -500,6 +516,9 @@ export function projectEvent( ...(payload.linkedPullRequest !== undefined ? { linkedPullRequest: payload.linkedPullRequest } : {}), + ...(payload.branchPullRequest !== undefined + ? { branchPullRequest: payload.branchPullRequest } + : {}), updatedAt: payload.updatedAt, }), })), @@ -746,7 +765,11 @@ export function projectEvent( ? thread.latestTurn : { turnId: payload.turnId, - state: checkpointStatusToLatestTurnState(payload.status), + state: + thread.latestTurn?.turnId === payload.turnId && + thread.latestTurn.state === "interrupted" + ? "interrupted" + : checkpointStatusToLatestTurnState(payload.status), requestedAt: thread.latestTurn?.turnId === payload.turnId ? thread.latestTurn.requestedAt diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c8..5d40974b174d 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -9,6 +9,7 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import { BRAND } from "@q1code/core/brand"; // fork: base import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { @@ -105,7 +106,7 @@ export const expandHomePath = Effect.fn(function* (input: string) { export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(NodeOS.homedir(), ".t3"); + return join(NodeOS.homedir(), BRAND.homeDirName); // fork: base } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index adc3ca40cbb5..68ceb8573b75 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,25 +1,280 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + TurnId, + ProviderInstanceId, + OrchestrationProposedPlanId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Statement from "effect/unstable/sql/Statement"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ); projectionRepositoriesLayer("Projection repositories", (it) => { + it.effect("selects the latest-turn plan before checking implementation status", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const threadId = ThreadId.make("thread-plan-status"); + const latestTurnId = TurnId.make("turn-plan-status-current"); + const firstPlan = { + planId: "plan-status-first", + threadId, + turnId: latestTurnId, + planMarkdown: "# First plan", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: "2026-03-24T00:00:01.000Z", + }; + yield* plans.upsert(firstPlan); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-status-implemented", + implementedAt: "2026-03-24T00:00:02.000Z", + createdAt: "2026-03-24T00:00:02.000Z", + updatedAt: "2026-03-24T00:00:02.000Z", + }); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-status-other-turn", + turnId: TurnId.make("turn-plan-status-old"), + updatedAt: "2026-03-24T00:00:10.000Z", + }); + + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + + yield* plans.upsert({ ...firstPlan, updatedAt: "2026-03-24T00:00:03.000Z" }); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + }), + ); + + it.effect("falls back within the thread when the latest turn has no plan", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const threadId = ThreadId.make("thread-plan-fallback"); + const latestTurnId = TurnId.make("turn-plan-fallback-missing"); + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + + const firstPlan = { + planId: "plan-fallback-without-turn", + threadId, + turnId: null, + planMarkdown: "# Old plan", + implementedAt: "2026-03-24T00:00:01.000Z", + implementationThreadId: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: "2026-03-24T00:00:01.000Z", + }; + yield* plans.upsert(firstPlan); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-fallback-with-turn", + turnId: TurnId.make("turn-plan-fallback-old"), + implementedAt: null, + updatedAt: "2026-03-24T00:00:02.000Z", + }); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-fallback-other-thread", + threadId: ThreadId.make("thread-plan-fallback-other"), + turnId: latestTurnId, + updatedAt: "2026-03-24T00:00:03.000Z", + }); + + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + }), + ); + + it.effect("preserves locale ordering and stable ties when selecting plan status", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const timestamp = "2026-03-24T00:00:00.000Z"; + const cases = [ + { + name: "mixed-case-ids", + expected: "plan-A".localeCompare("plan-a") > 0, + rows: [ + { + planId: "plan-a", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }, + { planId: "plan-A", implementedAt: null, createdAt: timestamp, updatedAt: timestamp }, + ], + }, + { + name: "equivalent-ids", + expected: true, + rows: [ + { + planId: "plan-\u00e9", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + planId: "plan-e\u0301", + implementedAt: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: timestamp, + }, + ], + }, + { + name: "timestamp-formats", + expected: "2026-03-24T00:00:00+00:00".localeCompare("2026-03-24T00:00:00-01:00") > 0, + rows: [ + { + planId: "plan-minus", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: "2026-03-24T00:00:00-01:00", + }, + { + planId: "plan-plus", + implementedAt: null, + createdAt: timestamp, + updatedAt: "2026-03-24T00:00:00+00:00", + }, + ], + }, + ]; + for (const testCase of cases) { + const threadId = ThreadId.make(`thread-plan-order-${testCase.name}`); + const latestTurnId = TurnId.make(`turn-plan-order-${testCase.name}`); + for (const plan of testCase.rows) { + yield* plans.upsert({ + ...plan, + planId: `${testCase.name}-${plan.planId}`, + threadId, + turnId: latestTurnId, + planMarkdown: "# Plan", + implementationThreadId: null, + }); + } + assert.strictEqual( + yield* plans.hasActionableByThreadId({ threadId, latestTurnId }), + testCase.expected, + testCase.name, + ); + assert.strictEqual( + yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null }), + testCase.expected, + testCase.name, + ); + } + }), + ); + + it.effect("returns only current-turn status metadata when old plans have large bodies", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-plan-metadata"); + const latestTurnId = TurnId.make("turn-plan-metadata-current"); + const updatedAt = "2026-03-24T00:00:00.000Z"; + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 256 + ) + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) + SELECT 'plan-metadata-old-' || n, ${threadId}, 'turn-plan-metadata-old', + ${"# Old plan\n".repeat(1024)}, ${updatedAt}, NULL, ${updatedAt}, ${updatedAt} + FROM history + `; + yield* plans.upsert({ + planId: "plan-metadata-current", + threadId, + turnId: latestTurnId, + planMarkdown: "# Current plan", + implementedAt: null, + implementationThreadId: null, + createdAt: updatedAt, + updatedAt, + }); + const statements: Array> = []; + const actionable = yield* plans.hasActionableByThreadId({ threadId, latestTurnId }).pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + statements.push(statement); + return Effect.succeed(statement); + }), + ); + assert.isTrue(actionable); + assert.strictEqual(statements.length, 1); + const statement = statements[0]; + if (statement === undefined) return yield* Effect.die("Expected a plan status query."); + assert.deepEqual(yield* statement, [ + { planId: "plan-metadata-current", implementedAt: null, updatedAt }, + ]); + }), + ); + + it.effect("reads only the requested plan in its thread", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("plan-query-thread"); + const planId = OrchestrationProposedPlanId.make("plan-query-target"); + yield* plans.upsert({ + planId, + threadId, + turnId: null, + planMarkdown: "Keep this plan", + implementedAt: "2026-03-01T00:01:00.000Z", + implementationThreadId: ThreadId.make("implementation-thread"), + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }); + // An unrelated old row must not be loaded or decoded by the exact lookup. + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) VALUES ( + 'unrelated-plan', ${threadId}, NULL, '', NULL, NULL, + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z' + ) + `; + const plan = Option.getOrThrow(yield* plans.getByPlanId({ threadId, planId })); + assert.equal(plan.planMarkdown, "Keep this plan"); + assert.equal(plan.implementedAt, "2026-03-01T00:01:00.000Z"); + assert.isTrue( + Option.isNone( + yield* plans.getByPlanId({ + threadId: ThreadId.make("another-thread"), + planId, + }), + ), + ); + }), + ); + it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { const projects = yield* ProjectionProjectRepository; @@ -207,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); - it.effect("round-trips a linked pull request through the thread row", () => + it.effect("round-trips manual and branch pull requests through the thread row", () => Effect.gen(function* () { const threads = yield* ProjectionThreadRepository; const linkedPullRequest = { @@ -216,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* threads.upsert({ threadId: ThreadId.make("thread-linked-pr"), @@ -230,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { branch: null, worktreePath: null, linkedPullRequest, + branchPullRequest, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -249,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); + assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest); + + const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId }); + assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest); const row = Option.getOrNull(persisted); if (row === null) return yield* Effect.die("Expected linked thread row to exist."); @@ -256,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); + assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest); + + yield* threads.upsert({ ...row, branchPullRequest: null }); + const branchCleared = yield* threads.getById({ threadId: row.threadId }); + assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null); + assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts new file mode 100644 index 000000000000..d92ed97ea6fa --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts @@ -0,0 +1,100 @@ +import { EventId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadActivityRepository", (it) => { + it.effect("reads only the latest matching task activity", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadActivityRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-latest-task-activity"); + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'activity-task-unrelated-tool', ${threadId}, NULL, 'tool', 'tool.completed', + 'large tool output', 'not-json', 1, '2026-03-01T00:00:00.000Z' + ), + ( + 'activity-task-started', ${threadId}, NULL, 'info', 'task.started', + 'started', '{"taskId":"task-1","title":"Initial title"}', 2, + '2026-03-01T00:00:01.000Z' + ), + ( + 'activity-task-progress', ${threadId}, NULL, 'info', 'task.progress', + 'progress', '{"taskId":"task-1","title":"Updated title"}', 3, + '2026-03-01T00:00:02.000Z' + ), + ( + 'activity-task-other', ${threadId}, NULL, 'info', 'task.progress', + 'other', '{"taskId":"task-2","title":"Other title"}', 4, + '2026-03-01T00:00:03.000Z' + ) + `; + + yield* repository.upsert({ + activityId: EventId.make("activity-task-untitled"), + threadId, + turnId: null, + tone: "info", + kind: "task.progress", + summary: "Still running", + payload: { taskId: "task-1" }, + sequence: 5, + createdAt: "2026-03-01T00:00:04.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("activity-task-blank-title"), + threadId, + turnId: null, + tone: "info", + kind: "task.progress", + summary: "Still running", + payload: { taskId: "task-1", title: " \t\n\u00a0" }, + sequence: 6, + createdAt: "2026-03-01T00:00:05.000Z", + }); + + const recent = yield* repository.listByThreadId({ + threadId, + activityKinds: ["task.progress"], + limit: 2, + }); + assert.deepEqual( + recent.map((entry) => entry.activityId), + ["activity-task-untitled", "activity-task-blank-title"], + ); + + const activity = yield* repository.getLatestTaskActivity({ + threadId, + taskId: "task-1", + }); + assert.equal(activity._tag, "Some"); + if (activity._tag === "Some") { + assert.equal(activity.value.activityId, EventId.make("activity-task-progress")); + assert.deepEqual(activity.value.payload, { + taskId: "task-1", + title: "Updated title", + }); + } + + assert.equal( + (yield* repository.getLatestTaskActivity({ threadId, taskId: "missing" }))._tag, + "None", + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index fa3c948e4f3d..9b6d44d170ee 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -3,6 +3,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { NonNegativeInt } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadActivitiesInput, ListProjectionThreadActivitiesInput, + GetLatestProjectionThreadTaskActivityInput, ProjectionThreadActivity, ProjectionThreadActivityRepository, type ProjectionThreadActivityRepositoryShape, @@ -23,10 +25,10 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); -const mapActivityRows = ( - rows: ReadonlyArray>, -): ReadonlyArray => - rows.map((row) => ({ +function toProjectionThreadActivity( + row: Schema.Schema.Type, +): ProjectionThreadActivity { + return { activityId: row.activityId, threadId: row.threadId, turnId: row.turnId, @@ -36,7 +38,8 @@ const mapActivityRows = ( payload: row.payload, ...(row.sequence !== null ? { sequence: row.sequence } : {}), createdAt: row.createdAt, - })); + }; +} function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => @@ -45,6 +48,10 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } +// Match String.trim so blank saved titles cannot hide an earlier task name. +const taskTitleWhitespace = + "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; + const makeProjectionThreadActivityRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -90,7 +97,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { const listProjectionThreadActivityRows = SqlSchema.findAll({ Request: ListProjectionThreadActivitiesInput, Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, activityKinds, limit }) => sql` SELECT activity_id AS "activityId", @@ -102,8 +109,14 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT * + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ${activityKinds === undefined ? sql`` : sql`AND ${sql.in("kind", activityKinds)}`} + ORDER BY sequence DESC, created_at DESC, activity_id DESC + ${limit === undefined ? sql`` : sql`LIMIT ${limit}`} + ) AS recent_activities ORDER BY CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, sequence ASC, @@ -142,6 +155,40 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const getLatestProjectionThreadTaskActivityRow = SqlSchema.findOneOption({ + Request: GetLatestProjectionThreadTaskActivityInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, taskId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ('task.started', 'task.progress') + AND json_extract(payload_json, '$.taskId') = ${taskId} + AND length(trim( + CASE + WHEN json_type(payload_json, '$.title') = 'text' + THEN json_extract(payload_json, '$.title') + WHEN kind = 'task.started' AND json_type(payload_json, '$.detail') = 'text' + THEN json_extract(payload_json, '$.detail') + ELSE '' + END, + ${taskTitleWhitespace} + )) > 0 + ORDER BY sequence DESC, created_at DESC, activity_id DESC + LIMIT 1 + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -169,7 +216,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map(mapActivityRows), + Effect.map((rows) => rows.map(toProjectionThreadActivity)), ); const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = @@ -181,9 +228,22 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", ), ), - Effect.map(mapActivityRows), + Effect.map((rows) => rows.map(toProjectionThreadActivity)), ); + const getLatestTaskActivity: ProjectionThreadActivityRepositoryShape["getLatestTaskActivity"] = ( + input, + ) => + getLatestProjectionThreadTaskActivityRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.getLatestTaskActivity:query", + "ProjectionThreadActivityRepository.getLatestTaskActivity:decodeRow", + ), + ), + Effect.map(Option.map(toProjectionThreadActivity)), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -195,6 +255,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { upsert, listByThreadId, listUserInputLifecycleByThreadId, + getLatestTaskActivity, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c8fa16158bae..12f4db91fe0b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -1,4 +1,4 @@ -import { MessageId, ThreadId } from "@t3tools/contracts"; +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, @@ -219,4 +231,49 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.deepEqual(rows[0]?.attachments, []); }), ); + + it.effect("checks assistant turn state without hydrating message text", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-assistant-turn-state"); + const turnId = TurnId.make("turn-assistant-state"); + const createdAt = "2026-03-01T00:00:00.000Z"; + + yield* repository.upsert({ + messageId: MessageId.make("message-assistant-turn-state"), + threadId, + turnId, + role: "assistant", + text: "large text that the existence query must not select", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId, + streamingOnly: false, + }), + true, + ); + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId, + streamingOnly: true, + }), + false, + ); + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId: TurnId.make("turn-assistant-state-missing"), + streamingOnly: false, + }), + false, + ); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index ce28e11b8601..eae7189de5b2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -11,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; import { AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, + HasProjectionThreadAssistantMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -24,6 +25,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionThreadMessageExistsDbRowSchema = Schema.Struct({ exists: Schema.Number }); function toProjectionThreadMessage( row: Schema.Schema.Type, @@ -161,6 +163,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const hasProjectionThreadAssistantMessageRow = SqlSchema.findOne({ + Request: HasProjectionThreadAssistantMessageInput, + Result: ProjectionThreadMessageExistsDbRowSchema, + execute: ({ threadId, turnId, streamingOnly }) => + sql` + SELECT EXISTS ( + SELECT 1 + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + AND role = 'assistant' + AND (${streamingOnly ? 1 : 0} = 0 OR is_streaming = 1) + LIMIT 1 + ) AS "exists" + `, + }); + const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -191,6 +210,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); @@ -223,6 +243,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map(Option.map(toProjectionThreadMessage)), ); + const hasAssistantMessageForTurn: ProjectionThreadMessageRepositoryShape["hasAssistantMessageForTurn"] = + (input) => + hasProjectionThreadAssistantMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.hasAssistantMessageForTurn:query", + ), + ), + Effect.map((row) => row.exists === 1), + ); + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -252,6 +283,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, appendStreaming, getByMessageId, + hasAssistantMessageForTurn, listByThreadId, getLatestUserMessageAt, deleteByThreadId, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 63aed1a16704..816ef0a055dd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1,11 +1,14 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadProposedPlansInput, + HasActionableProjectionThreadProposedPlanInput, + GetProjectionThreadProposedPlanInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, @@ -50,6 +53,24 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const getProjectionThreadProposedPlanRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadProposedPlanInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId, planId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND plan_id = ${planId} + `, + }); + const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ Request: ListProjectionThreadProposedPlansInput, Result: ProjectionThreadProposedPlan, @@ -77,11 +98,67 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const listPlanStatusCandidates = SqlSchema.findAll({ + Request: HasActionableProjectionThreadProposedPlanInput, + Result: Schema.Struct({ + planId: ProjectionThreadProposedPlan.fields.planId, + implementedAt: ProjectionThreadProposedPlan.fields.implementedAt, + updatedAt: ProjectionThreadProposedPlan.fields.updatedAt, + }), + execute: ({ threadId, latestTurnId }) => sql` + SELECT + plan_id AS "planId", + implemented_at AS "implementedAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + AND ( + turn_id = ${latestTurnId} + OR NOT EXISTS ( + SELECT 1 FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND turn_id = ${latestTurnId} + ) + ) + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const hasActionableByThreadId = Effect.fn( + "ProjectionThreadProposedPlanRepository.hasActionableByThreadId", + )( + function* (input: HasActionableProjectionThreadProposedPlanInput) { + const candidates = yield* listPlanStatusCandidates(input); + let selected: (typeof candidates)[number] | undefined; + // Timestamps and IDs use localeCompare, not SQLite byte order. Replace + // equal candidates to preserve the stable order of listByThreadId. + for (const candidate of candidates) { + if ( + selected === undefined || + (candidate.updatedAt.localeCompare(selected.updatedAt) || + candidate.planId.localeCompare(selected.planId)) >= 0 + ) { + selected = candidate; + } + } + return selected?.implementedAt === null; + }, + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.hasActionableByThreadId:query"), + ), + ); + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), ); + const getByPlanId: ProjectionThreadProposedPlanRepositoryShape["getByPlanId"] = (input) => + getProjectionThreadProposedPlanRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.getByPlanId:query"), + ), + ); + const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => listProjectionThreadProposedPlanRows(input).pipe( Effect.mapError( @@ -101,6 +178,8 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { return { upsert, listByThreadId, + hasActionableByThreadId, + getByPlanId, deleteByThreadId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index d5653a2c8b42..6406e8237bc9 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path, linked_pull_request_json, + branch_pull_request_json, latest_turn_id, created_at, updated_at, @@ -52,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, pinned_at, pin_order_key, + active_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -70,6 +73,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.branch}, ${row.worktreePath}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, + ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -81,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.pinnedAt}, ${row.pinOrderKey ?? null}, + ${row.activeOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -99,6 +104,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch = excluded.branch, worktree_path = excluded.worktree_path, linked_pull_request_json = excluded.linked_pull_request_json, + branch_pull_request_json = excluded.branch_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -110,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, + active_order_key = excluded.active_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -135,6 +142,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -146,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -173,6 +182,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -184,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 92dc18291057..c95f746d3648 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,6 +59,8 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; +import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -118,6 +120,8 @@ export const migrationEntries = [ [45, "ProjectionProjectsAutoPull", Migration0045], [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], + [48, "ProjectionThreadBranchPullRequest", Migration0048], + [49, "ProjectionThreadsActiveOrderKey", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts new file mode 100644 index 000000000000..49870b59635d --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "branch_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN branch_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts new file mode 100644 index 000000000000..138d25754d7e --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts"; + +it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => { + it.effect("migrates old threads without changing their timestamps or assigning an order", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 49 }); + const migrated = yield* sql<{ readonly activeOrderKey: string | null }>` + SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ activeOrderKey: null }]); + // Recovery may run the same migration against a database that already + // has the column, including a placement written after the upgrade. + yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`; + yield* migrateActiveOrderKey; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts new file mode 100644 index 000000000000..6f40ec38d081 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "active_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN active_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522f..d73f56aab9e0 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { + AgentSessionImportSource, IsoDateTime, ProviderInstanceId, ProviderSessionRuntimeStatus, @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const RecordImportedTranscriptInput = Schema.Struct({ + threadId: ThreadId, + source: AgentSessionImportSource, +}); +export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; + +export interface ProviderSessionRuntimeUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< /** * Insert or replace a provider runtime row. * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, retaining imported transcript records + * from the current database row. */ readonly upsert: ( runtime: ProviderSessionRuntime, + options?: ProviderSessionRuntimeUpsertOptions, + ) => Effect.Effect; + + /** Record one source file without replacing the current session state. */ + readonly recordImportedTranscript: ( + input: RecordImportedTranscriptInput, ) => Effect.Effect; /** @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; +const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( + Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), +); + function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Runtime writes can carry stale payloads. Only recordImportedTranscript may + // change source records, so restore that field from the row being updated. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, - ${runtime.runtimePayload} + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END ) ON CONFLICT (thread_id) DO UPDATE SET @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json + runtime_payload_json = CASE + WHEN json_type( + CASE + WHEN json_valid(provider_session_runtime.runtime_payload_json) + THEN provider_session_runtime.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts' + ) IS NOT NULL + THEN json_set( + CASE + WHEN json_type(excluded.runtime_payload_json) = 'object' + THEN excluded.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts', + json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') + ) + ELSE excluded.runtime_payload_json + END + `, + }); + + const insertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END + ) + ON CONFLICT (thread_id) DO NOTHING + `, + }); + + const recordImportedTranscriptRow = SqlSchema.void({ + Request: RecordImportedTranscriptRequestSchema, + execute: ({ threadId, source }) => + sql` + WITH current_runtime AS ( + SELECT CASE + WHEN json_valid(runtime_payload_json) THEN CASE + WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json + ELSE '{}' + END + ELSE '{}' + END AS payload + FROM provider_session_runtime + WHERE thread_id = ${threadId} + ) + UPDATE provider_session_runtime + SET runtime_payload_json = ( + SELECT json_set( + payload, + '$.importedTranscripts', + json(( + SELECT json_group_array(json(value)) + FROM ( + SELECT value + FROM json_each(CASE + WHEN json_type(payload, '$.importedTranscripts') = 'array' + THEN json_extract(payload, '$.importedTranscripts') + ELSE '[]' + END) + WHERE CASE + WHEN type = 'object' THEN + json_extract(value, '$.providerInstanceId') + IS NOT json_extract(${source}, '$.providerInstanceId') + OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') + ELSE 0 + END + UNION ALL + SELECT ${source} AS value + ) + )) + ) + FROM current_runtime + ) + WHERE thread_id = ${threadId} `, }); @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { `, }); - const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => + (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = + (input) => + recordImportedTranscriptRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.recordImportedTranscript:query", + "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( Effect.mapError( @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getByThreadId, list, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index e8c1e47a328b..85e9d368df4c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -17,6 +17,7 @@ import { import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; export const ListProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, + activityKinds: Schema.optional(Schema.Array(Schema.String)), + limit: Schema.optional(NonNegativeInt), }); export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; +export const GetLatestProjectionThreadTaskActivityInput = Schema.Struct({ + threadId: ThreadId, + taskId: Schema.String, +}); +export type GetLatestProjectionThreadTaskActivityInput = + typeof GetLatestProjectionThreadTaskActivityInput.Type; + export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, }); @@ -61,7 +71,7 @@ export interface ProjectionThreadActivityRepositoryShape { * List projected thread activity rows for a thread. * * Returned in ascending runtime sequence order (or creation order when - * sequence is unavailable). + * sequence is unavailable). A limit selects the newest matching rows. */ readonly listByThreadId: ( input: ListProjectionThreadActivitiesInput, @@ -76,6 +86,13 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the latest task-start or task-progress activity with a usable title. + */ + readonly getLatestTaskActivity: ( + input: GetLatestProjectionThreadTaskActivityInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index a41737564382..a7e258ad5dc0 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -51,6 +51,14 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ }); export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; +export const HasProjectionThreadAssistantMessageInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + streamingOnly: Schema.Boolean, +}); +export type HasProjectionThreadAssistantMessageInput = + typeof HasProjectionThreadAssistantMessageInput.Type; + export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -81,6 +89,13 @@ export interface ProjectionThreadMessageRepositoryShape { input: GetProjectionThreadMessageInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Check for an assistant message in a turn without hydrating message text. + */ + readonly hasAssistantMessageForTurn: ( + input: HasProjectionThreadAssistantMessageInput, + ) => Effect.Effect; + /** * List projected thread messages for a thread. * diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index b4bc2bcc3289..db8a36015505 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -8,6 +8,7 @@ import { import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -29,6 +30,19 @@ export const ListProjectionThreadProposedPlansInput = Schema.Struct({ export type ListProjectionThreadProposedPlansInput = typeof ListProjectionThreadProposedPlansInput.Type; +export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ + threadId: ThreadId, + latestTurnId: Schema.NullOr(TurnId), +}); +export type HasActionableProjectionThreadProposedPlanInput = + typeof HasActionableProjectionThreadProposedPlanInput.Type; + +export const GetProjectionThreadProposedPlanInput = Schema.Struct({ + threadId: ThreadId, + planId: OrchestrationProposedPlanId, +}); +export type GetProjectionThreadProposedPlanInput = typeof GetProjectionThreadProposedPlanInput.Type; + export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ threadId: ThreadId, }); @@ -39,9 +53,16 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly upsert: ( proposedPlan: ProjectionThreadProposedPlan, ) => Effect.Effect; + /** Read one plan without loading the thread's other plans. */ + readonly getByPlanId: ( + input: GetProjectionThreadProposedPlanInput, + ) => Effect.Effect, ProjectionRepositoryError>; readonly listByThreadId: ( input: ListProjectionThreadProposedPlansInput, ) => Effect.Effect, ProjectionRepositoryError>; + readonly hasActionableByThreadId: ( + input: HasActionableProjectionThreadProposedPlanInput, + ) => Effect.Effect; readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a70548bc110c..0a8b2e31c5ab 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -46,6 +47,7 @@ export const ProjectionThread = Schema.Struct({ snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index fbde96ff58b1..184c8b519a96 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -45,7 +45,6 @@ export { ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, - isExternalLauncherError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000000..38d6ad1d4331 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,1213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + AgentSessionImportProjectChangedError, + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThread, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/project-from-server"; +const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ + source, + providerInstanceId: ProviderInstanceId.make(source), + providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + title: `Imported ${source} thread`, + model: null, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:01:00.000Z", + messages: [ + { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, + { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, + ], +}); + +const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => + ({ + _tag: "Importable", + thread, + source: { + provider: thread.source, + providerInstanceId: thread.providerInstanceId, + providerSessionId: thread.providerSessionId, + filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, + size: 0, + mtimeMs: 0, + device: 0, + inode: 0, + birthtimeMs: 0, + }, + }) satisfies AgentSessionScanner.AgentSessionRecentThread; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", +}); + +const makeProjectedThread = (input: { + readonly source: "codex" | "claudeAgent"; + readonly projectId?: ProjectId; + readonly imported?: boolean; + readonly includeFollowup?: boolean; +}): OrchestrationThread => { + const sourceThread = makeThread(input.source); + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + return { + id: threadId, + projectId: input.projectId ?? PROJECT_ID, + title: sourceThread.title, + modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: sourceThread.createdAt, + updatedAt: sourceThread.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.imported + ? [ + { + id: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:00:00.000Z", + }, + ...(input.includeFollowup + ? [ + { + id: MessageId.make("user-followup"), + role: "user" as const, + text: "Keep going", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:02:00.000Z", + updatedAt: "2026-08-24T10:02:00.000Z", + }, + ] + : []), + ] + : [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +}; + +const makeSnapshotsLayer = (input: { + readonly project?: OrchestrationProjectShell; + readonly getThread?: (threadId: ThreadId) => Option.Option; +}) => + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getProjectShellById: () => + Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), + getImportedAgentSessionSources: () => Effect.succeed([]), + getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), + }); + +const runImport = (input: { + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly snapshots: ReturnType; + readonly expectedWorkspaceRoot?: string; +}) => + importRecentAgentThreads({ + projectId: PROJECT_ID, + ...(input.expectedWorkspaceRoot === undefined + ? {} + : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provide(input.snapshots), + ); + +it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { + describe("importRecentAgentThreads", () => { + it.effect("uses the project root and stores provider-specific resume cursors", () => + Effect.gen(function* () { + const commands: Array = []; + const bindings: Array = []; + let scannedRoot: string | undefined; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: (workspaceRoot) => { + scannedRoot = workspaceRoot; + return Stream.concat( + Stream.succeed(makeThreadOutcome(makeThread("codex"))), + Stream.fromEffect( + Effect.sync(() => { + expect(bindings).toHaveLength(1); + return makeThreadOutcome(makeThread("claudeAgent")); + }), + ), + ); + }, + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, + }); + + expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); + expect(scannedRoot).toBe(WORKSPACE_ROOT); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + "thread.create", + "thread.history.import", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ + { historyImport: true }, + { historyImport: true }, + ]); + expect( + commands + .filter((command) => command.type === "thread.history.import") + .flatMap((command) => command.messages.map((message) => message.messageId)), + ).toEqual([ + "import:codex:codex-session:000000", + "import:codex:codex-session:000001", + `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, + `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, + ]); + expect(bindings).toMatchObject([ + { + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + { + provider: "claudeAgent", + providerInstanceId: "claudeAgent", + resumeCursor: { + threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, + resume: CLAUDE_SESSION_ID, + }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + }), + ); + + it.effect("rejects a changed project root before scanning or writing", () => + Effect.gen(function* () { + const recentThreads = vi.fn(() => Stream.empty); + const error = yield* importRecentAgentThreads({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: WORKSPACE_ROOT, + }).pipe( + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("must not scan a changed project"), + recentThreads, + }), + ), + Effect.provide( + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), + makeSnapshotsLayer({ + project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, + }), + ), + ), + Effect.flip, + ); + + expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); + expect(recentThreads).not.toHaveBeenCalled(); + }), + ); + + it.effect("counts scanner skips without writing a thread or binding", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed({ _tag: "Skipped" }), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not dispatch for a scanner skip"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind a scanner skip"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.die("must not read a scanner skip binding"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + }), + ); + + it.effect("recovers after a rejected history receipt and a failed binding write", () => + Effect.gen(function* () { + let threadCreated = false; + let historyImported = false; + let historyAttemptCount = 0; + let bindingAttemptCount = 0; + const rejectedCommandIds = new Set(); + const bindings: Array = []; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => { + if (rejectedCommandIds.has(command.commandId)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Previously rejected.", + }), + ); + } + if (command.type === "thread.create") threadCreated = true; + if (command.type === "thread.history.import") { + historyAttemptCount += 1; + if (historyAttemptCount === 1) { + rejectedCommandIds.add(command.commandId); + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Temporary history import failure.", + }), + ); + } + historyImported = true; + } + return Effect.succeed({ sequence: 1 }); + }, + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => { + bindingAttemptCount += 1; + if (bindingAttemptCount === 1) { + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "upsert", + detail: "Temporary session storage failure.", + }), + ); + } + bindings.push(binding); + return Effect.void; + }, + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => + Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const snapshots = makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + threadCreated + ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) + : Option.none(), + }); + const importOnce = () => runImport({ scanner, engine, directory, snapshots }); + + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + const historyAttemptsAfterCompletion = historyAttemptCount; + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); + expect(historyAttemptCount).toBe(2); + expect(bindings).toHaveLength(1); + }), + ); + + it.effect("does not replace completed history or an active binding on retry", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: ThreadId.make("import:codex:codex-session"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "newer-codex-session" }, + }; + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not replace an active binding"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.some(runningBinding)), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not replay history or settle active work"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + Option.some( + makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), + ), + }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + }), + ); + + it.effect("skips malformed Claude ids and wrong-project thread collisions", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.fromIterable([ + makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), + makeThreadOutcome(makeThread("codex")), + ]), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: (threadId) => + threadId === "import:codex:codex-session" + ? Option.some( + makeProjectedThread({ + source: "codex", + projectId: ProjectId.make("project-other"), + }), + ) + : Option.none(), + }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); + expect(commands).toHaveLength(0); + }), + ); + }); +}); + +const integrationThread = { + ...makeThread("codex"), + updatedAt: "2026-08-24T10:00:00.000Z", + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Message ${index}`, + createdAt: "2026-08-24T10:00:00.000Z", + })), +}; +const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), +}); +const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-agent-session-importer-test-", +}); +const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), +); +const integrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + integrationRuntimeRepository, + ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), + Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), +).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(integrationServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect("imports once after the real engine persists an old rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:codex-session"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-integration-project"), + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const rejected = yield* Effect.result( + engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`agent-session:history:${threadId}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }), + ); + expect(rejected._tag).toBe("Failure"); + + const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); + const importedThread = yield* snapshots.getThreadDetailById(threadId); + const binding = yield* directory.getBinding(threadId); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); + expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); + expect(Option.getOrThrow(binding)).toMatchObject({ + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }); + + yield* engine.dispatch({ + type: "thread.revert.complete", + commandId: CommandId.make("revert-imported-thread-to-baseline"), + threadId, + turnCount: 0, + createdAt: "2026-08-24T10:05:00.000Z", + }); + const afterRevert = yield* snapshots.getThreadDetailById(threadId); + expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + }), + ); + + it.effect( + "retries a bounded import after scanner restart without rereading completed transcripts", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-import-retry-", + }); + const workspaceRoot = path.join(fixtureDir, "workspace"); + const claudeHomePath = path.join(fixtureDir, "claude"); + const codexHomePath = path.join(fixtureDir, "codex"); + const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* fileSystem.makeDirectory(workspaceRoot); + yield* fileSystem.makeDirectory(claudeHomePath); + yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); + + const projectId = ProjectId.make("project-bounded-import-retry"); + const transcripts = Array.from({ length: 101 }, (_, index) => { + const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; + return { + providerSessionId, + threadId: ThreadId.make(`import:codex:${providerSessionId}`), + filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), + }; + }); + for (const [index, transcript] of transcripts.entries()) { + yield* fileSystem.writeFileString( + transcript.filePath, + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: `Prompt ${transcript.providerSessionId}`, + }, + }), + ].join("\n"), + ); + const seconds = nowMs / 1_000 - index; + yield* fileSystem.utimes(transcript.filePath, seconds, seconds); + } + const legacy = transcripts[0]!; + const failed = transcripts[1]!; + const remaining = transcripts[100]!; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-bounded-import-project"), + projectId, + title: "Bounded import", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + // This completed import predates persisted transcript source metadata. + yield* directory.upsert({ + threadId: legacy.threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "legacy-current-session" }, + runtimePayload: { cwd: workspaceRoot }, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-bounded-import"), + threadId: legacy.threadId, + projectId, + title: "Legacy import", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + historyImport: true, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("import-legacy-bounded-history"), + threadId: legacy.threadId, + messages: [ + { + messageId: MessageId.make(`${legacy.threadId}:000000`), + role: "user", + text: "Legacy imported history", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }); + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); + + let failHistory = true; + const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ + ...engine, + dispatch: (command) => { + if ( + failHistory && + command.type === "thread.history.import" && + command.threadId === failed.threadId + ) { + failHistory = false; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected history import failure.", + }), + ); + } + return engine.dispatch(command); + }, + }); + const settingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { homePath: claudeHomePath }, + codex: { homePath: codexHomePath }, + }, + }); + const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); + const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( + completedPaths: ReadonlySet, + ) { + const openCounts = new Map(); + const fullReads: string[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => + Effect.suspend(() => { + if (transcriptPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + // A fresh scanner first opens each file for project discovery. + if (count > 1) { + fullReads.push(filePath); + if (completedPaths.has(filePath)) { + return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); + } + } + } + return fileSystem.open(filePath, options); + }), + }); + const result = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provide( + Layer.fresh(AgentSessionScanner.layer).pipe( + Layer.provide(settingsLayer), + Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), + ), + ), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), + ); + return { result, fullReads, openCounts }; + }); + + const first = yield* runAttempt(new Set()); + expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); + expect(failHistory).toBe(false); + expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); + expect(first.openCounts.get(remaining.filePath)).toBe(1); + const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); + expect(completedSources).toHaveLength(99); + expect(completedSources).toContainEqual({ + threadId: legacy.threadId, + source: expect.objectContaining({ filePath: legacy.filePath }), + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, + ).toEqual([]); + expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ + status: "stopped", + resumeCursor: { threadId: failed.providerSessionId }, + }); + expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); + + const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); + const second = yield* runAttempt(completedPaths); + expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); + expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); + for (const transcript of transcripts) { + expect(second.openCounts.get(transcript.filePath)).toBe( + completedPaths.has(transcript.filePath) ? 1 : 2, + ); + } + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Legacy imported history"]); + expect( + Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, + ).toEqual({ + threadId: "legacy-current-session", + }); + for (const transcript of [failed, remaining]) { + expect( + Option.getOrThrow( + yield* snapshots.getThreadDetailById(transcript.threadId), + ).messages.map((message) => message.text), + ).toEqual([`Prompt ${transcript.providerSessionId}`]); + } + }), + ); + + for (const source of ["codex", "claudeAgent"] as const) { + it.effect(`resumes imported ${source} history only after the first prompt`, () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); + const projectId = ProjectId.make(`project-import-resume-${source}`); + const sourceThread = { + ...makeThread(source), + providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, + }; + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + const resumeCursor = + source === "codex" + ? { threadId: sourceThread.providerSessionId } + : { threadId, resume: sourceThread.providerSessionId }; + const provider = ProviderDriverKind.make(source); + const harness = yield* makeTestProviderAdapterHarness({ provider }); + const importSettled = yield* Deferred.make(); + const turnSent = yield* Deferred.make(); + const startSession = vi.fn(harness.adapter.startSession); + const sendTurn = vi.fn((input: ProviderSendTurnInput) => + harness.adapter + .sendTurn(input) + .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ + [provider]: { ...harness.adapter, startSession, sendTurn }, + }), + ), + ), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide(AnalyticsService.layerTest), + ); + const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(providerLayer), + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + // Acknowledge the imported settlement before draining the reactor. + getThreadShellById: (requestedThreadId) => + snapshots + .getThreadShellById(requestedThreadId) + .pipe( + Effect.tap(() => + requestedThreadId === threadId + ? Deferred.succeed(importSettled, undefined) + : Effect.void, + ), + ), + }), + ), + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), + Layer.provide(makeProviderRegistryLayer()), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), + Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(ServerSettingsService.layerTest()), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`create-import-resume-project-${source}`), + projectId, + title: "Import resume", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* harness.queueTurnResponseForNextSession({ events: [] }); + + yield* Effect.gen(function* () { + const reactor = yield* ProviderCommandReactor; + yield* reactor.start(); + expect(yield* importRecentAgentThreads({ projectId })).toEqual({ + importedCount: 1, + skippedCount: 0, + }); + yield* Deferred.await(importSettled); + yield* reactor.drain; + expect(startSession).not.toHaveBeenCalled(); + expect(sendTurn).not.toHaveBeenCalled(); + const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(importedThread.session).toBeNull(); + expect(importedThread.latestTurn).toBeNull(); + + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`resume-imported-${source}`), + threadId, + message: { + messageId: MessageId.make(`resume-imported-message-${source}`), + role: "user", + text: "Continue this session", + attachments: [], + }, + modelSelection: importedThread.modelSelection, + runtimeMode: importedThread.runtimeMode, + interactionMode: importedThread.interactionMode, + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.await(turnSent); + yield* reactor.drain; + expect(startSession).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + threadId, + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + cwd: workspaceRoot, + }), + ); + expect(sendTurn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ threadId, input: "Continue this session" }), + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + }); + }).pipe( + Effect.provide(reactorLayer), + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), + }), + ), + ); + }), + ); + } + + it.effect("persists the resume cursor before publishing a new imported thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-binding-race"); + const workspaceRoot = "/tmp/project-import-binding-race"; + const providerSessionId = "codex-binding-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-binding-race-project"), + projectId, + title: "Binding race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + + yield* Effect.raceFirst( + Deferred.await(importerAtBindingWrite), + Fiber.join(importFiber).pipe( + Effect.flatMap((result) => + Effect.die( + new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), + ), + ), + ), + ); + expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(integrationThread.messages.map((message) => message.text)); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + }), + ); + + it.effect("does not import history over a turn started on a partial thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-turn-race"); + const workspaceRoot = "/tmp/project-import-turn-race"; + const providerSessionId = "codex-turn-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-turn-race-project"), + projectId, + title: "Turn race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-import-turn-race-thread"), + threadId, + projectId, + title: "Turn race", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + yield* Deferred.await(importerAtBindingWrite); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-turn-during-import"), + threadId, + message: { + messageId: MessageId.make("message-during-import"), + role: "user", + text: "Continue while import waits", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Continue while import waits"]); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000000..3820c2cf411a --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,297 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionSource, + AgentSessionScanError, + isImportedAgentSessionMessageId, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( + "AgentSessionUnresumableSessionError", + { + source: AgentSessionSource, + providerSessionId: Schema.String, + }, +) { + override get message(): string { + return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; + } +} + +class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()( + "AgentSessionThreadProjectConflictError", + { + threadId: ThreadId, + expectedProjectId: ProjectId, + actualProjectId: ProjectId, + }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; + } +} + +class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()( + "AgentSessionThreadModifiedError", + { threadId: ThreadId }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' changed before its history import completed.`; + } +} + +function hasImportedHistory(thread: OrchestrationThread): boolean { + return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); +} + +function hasImportBlockingActivity( + thread: OrchestrationThread, + importedHistoryPresent: boolean, +): boolean { + return ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.latestTurn !== null || + thread.session !== null || + thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || + thread.proposedPlans.length > 0 || + thread.activities.length > 0 || + thread.checkpoints.length > 0 || + thread.snoozedUntil != null || + thread.snoozedAt != null || + thread.pinnedAt != null || + thread.pinOrderKey != null || + thread.titleRegeneration != null || + thread.linkedPullRequest != null || + thread.unsettledAt != null || + (importedHistoryPresent + ? thread.settledOverride !== "settled" + : thread.settledOverride !== null || thread.settledAt !== null) + ); +} + +/** Import recent transcript text and persist the cursor needed to resume its provider session. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const crypto = yield* Crypto.Crypto; + const project = yield* snapshots.getProjectShellById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const workspaceRoot = project.workspaceRoot; + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* snapshots + .getImportedAgentSessionSources(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const threads = scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); + const importedThreadIds = new Set(); + let importedCount = 0; + let skippedCount = 0; + + yield* Stream.runForEach(threads, (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { + const threadId = ThreadId.make( + `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, + ); + if (outcome._tag === "AlreadyImported") { + importedThreadIds.add(threadId); + importedCount += 1; + } else if (importedThreadIds.has(threadId)) { + const recorded = yield* directory + .recordImportedTranscript({ threadId, source: outcome.source }) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + skippedCount += 1; + yield* Effect.logWarning("Could not record an imported transcript copy", { + threadId, + cause: recorded.failure, + }); + } + } + return; + } + const thread = outcome.thread; + const threadId = ThreadId.make( + `import:${thread.providerInstanceId}:${thread.providerSessionId}`, + ); + const imported = yield* Effect.gen(function* () { + const provider = ProviderDriverKind.make(thread.source); + const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const existingThread = yield* snapshots.getThreadDetailById(threadId); + const existingBinding = yield* directory.getBinding(threadId); + + if ( + thread.source === "claudeAgent" && + !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) + ) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { + return yield* new AgentSessionThreadProjectConflictError({ + threadId, + expectedProjectId: input.projectId, + actualProjectId: existingThread.value.projectId, + }); + } + + const importedHistoryPresent = Option.isSome(existingThread) + ? hasImportedHistory(existingThread.value) + : false; + if ( + Option.isSome(existingThread) && + importedHistoryPresent && + Option.isSome(existingBinding) + ) { + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + return true; + } + + if ( + Option.isSome(existingThread) && + hasImportBlockingActivity(existingThread.value, importedHistoryPresent) + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + if ( + Option.isSome(existingBinding) && + (existingBinding.value.provider !== provider || + existingBinding.value.providerInstanceId !== thread.providerInstanceId || + existingBinding.value.status !== "stopped") + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + // Install the cursor before the thread becomes visible. A concurrent + // real session can replace it, while insert-ignore keeps this import + // from replacing that newer binding. + if (Option.isNone(existingBinding)) { + yield* directory.upsert( + { + threadId, + provider, + providerInstanceId: thread.providerInstanceId, + status: "stopped", + runtimeMode: DEFAULT_RUNTIME_MODE, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + }, + { onConflict: "ignore" }, + ); + } + + if (Option.isNone(existingThread)) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title: thread.title, + modelSelection: { instanceId: thread.providerInstanceId, model }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: thread.createdAt, + historyImport: true, + }); + } + + if (!importedHistoryPresent) { + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + messages: thread.messages.map((message, index) => ({ + messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }); + } + + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session", { + provider: thread.source, + sessionId: thread.providerSessionId, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (imported) { + importedThreadIds.add(threadId); + importedCount += 1; + } else { + skippedCount += 1; + } + }), + ); + + return { importedCount, skippedCount } satisfies AgentSessionImportResult; +}); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000000..aee64cf4b5d6 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3089 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type OrchestrationProjectShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Only `getShellSnapshot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.succeed([]), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Skipped", + ]); + expect(fullReadBytes).toBe(64 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("reports an eligible transcript over 16 MiB as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ] + .join("\n") + .padEnd(16 * 1024 * 1024 + 1, " "); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000000..bc6d093d42a1 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1313 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_BYTES = 64 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: { + readonly contents: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + function* decodedRecords() { + for (const line of lines) { + const decoded = decodeTranscriptRecord(line); + if (Option.isSome(decoded)) yield decoded.value; + } + } + + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const excludedProjectRoots = new Set( + [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** Check the open file before and after reading, without reading past its reserved byte budget. */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const decoder = new TextDecoder(); + let contents = ""; + let bytesRead = 0; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + contents += decoder.decode(next.value, { stream: true }); + } + + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? contents + decoder.decode() + : null; + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + })); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + } + >(); + const directoryKeys = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const contents = yield* readTranscript(transcript.filePath, identity); + if (contents === null) { + return Option.some({ _tag: "Skipped" }); + } + const lines = splitTranscriptRecords(contents, recordsRemaining + 1); + if (lines.length > recordsRemaining) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= lines.length; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const line of lines) { + snapshotCwd = extractCwd(line); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionTranscript( + { + contents, + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + lines, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 46fee8d8add6..3a8c3ad71e69 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -41,9 +42,11 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -67,13 +70,73 @@ const makeTerminalManagerLayer = ( const testLayer = ( project: OrchestrationProject, terminal: Pick, + settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provide(settings), ); describe("ProjectSetupScriptRunner", () => { + it.effect("runs the inherited machine setup action in the checkout's worktree", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toMatchObject({ status: "started", scriptId: "default-setup" }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + data: "npm install\r", + }); + }).pipe( + Effect.provide( + testLayer( + makeProject([]), + { open, write }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf489..6a79c853dc99 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 7c73752ee831..ac1cbfb44d0e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -38,15 +38,16 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { - it.effect("reuses the cached Git root for repeated workspace lookups", () => { + it.effect("refreshes the Git root only when requested", () => { const calls: Array> = []; + let rootPath = "/repo"; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => Effect.sync(() => { calls.push(input.args); return { stdout: input.args.includes("rev-parse") - ? "/repo\n" + ? `${rootPath}\n` : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", stderr: "", code: ChildProcessSpawner.ExitCode(0), @@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const first = yield* resolver.resolve("/repo/packages/web"); + rootPath = "/repo/packages/web"; const second = yield* resolver.resolve("/repo/packages/web"); expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], ]); + + const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); + expect(refreshed?.rootPath).toBe("/repo/packages/web"); + expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); + expect(calls.slice(2)).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "remote", "-v"], + ]); }).pipe(Effect.provide(resolverLayer)); }); @@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("prefers upstream over origin when both remotes are configured", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-upstream-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); - yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + it.effect.each(["add", "replace"] as const)( + "refreshes the primary upstream after %s before cache expiry", + (change) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const identity = yield* resolver.resolve(cwd); + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + if (change === "replace") { + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]); + } - expect(identity).not.toBeNull(); - expect(identity?.locator.remoteName).toBe("upstream"); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("t3tools/t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity?.canonicalKey).toBe( + change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous", + ); + + yield* git(cwd, [ + "remote", + change === "add" ? "add" : "set-url", + "upstream", + "git@github.com:T3Tools/t3code.git", + ]); + expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity); + const identity = yield* resolver.resolve(cwd, { refresh: true }); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(yield* resolver.resolve(cwd)).toEqual(identity); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index bf3c570c3cac..755008f6ded1 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, { - readonly resolve: (cwd: string) => Effect.Effect; + readonly resolve: ( + cwd: string, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; } >()("t3/project/RepositoryIdentityResolver") {} @@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", - )(function* (cwd) { + )(function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); const cacheKey = yield* Cache.get(repositoryRootCache, cwd); if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index f009255e90a2..88961641b248 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -61,7 +61,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( } = {}, ) { const authenticated = yield* Deferred.make(); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: @@ -224,6 +224,33 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect( + "distinguishes a post-authentication session failure without exposing its payload", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.fail( + harness.discovered, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Internal error ${callbackUrl}`, + method: "session/new", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Antigravity authenticated, but could not initialize a session or load models.", + ); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + it.effect("does not call callback HTTP success a successful Google sign-in", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c7118bccad83..170e5c32f47a 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -114,6 +114,9 @@ function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): str if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { return "Google sign-in was not approved. Start sign-in again."; } + if (error.value.method === "session/new" && error.value.code === -32603) { + return "Antigravity authenticated, but could not initialize a session or load models."; + } if (!usesBrowser && error.value.code === -32602) { return "Antigravity rejected the configured credentials. Check the provider settings."; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2bb9647bd414..1c2439a9ad9a 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -22,7 +22,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const browserToolInstructions = (browserToolsAvailable: boolean): string => browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; -export const codexPlanModeDeveloperInstructions = ( +const codexPlanModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Plan Mode (Conversational) @@ -155,7 +155,7 @@ If the user stays in Plan mode and asks for revisions after a prior \``; -export const codexDefaultModeDeveloperInstructions = ( +const codexDefaultModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Collaboration Mode: Default diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 80d8586a91b7..65a8c97fe668 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,5 @@ import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -49,7 +50,7 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver !enabled ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, profileDirectory }).pipe( + : discoverAntigravitySkills({ cwd, userHome }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index bb3381216d72..180ef9968fac 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { }); return { cwd: path.join(temporaryDirectory, "workspace"), - profileDirectory: path.join(temporaryDirectory, "profile"), + userHome: path.join(temporaryDirectory, "home"), }; }); it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { + it.effect("does not read user skills from a nested project or from ~/.agents", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") }; + const skillPath = yield* writeSkill( + path.join(input.userHome, ".gemini", "config", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + yield* writeSkill( + path.join(input.userHome, ".agents", "skills", "ignored"), + "---\nname: ignored\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(nested), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "user", + enabled: true, + }, + ]); + // A project rooted at the home directory sees ~/.agents/skills as its own. + assert.deepEqual( + (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [ + skill.name, + skill.scope, + ]), + [ + ["ignored", "project"], + ["review", "user"], + ], + ); + }), + ); + it.effect("reads skill names, descriptions and paths from the current native roots", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" }, { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, { - directory: path.join(input.profileDirectory, "antigravity-cli", "skills"), + directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), scope: "user", }, { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, @@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - path.join(input.profileDirectory, "config", "skills"), + path.join(input.userHome, ".gemini", "config", "skills"), path.join(input.cwd, ".gemini", "skills"), - path.join(input.profileDirectory, "antigravity-cli", "skills"), + path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), path.join(input.cwd, ".agents", "skills"), path.join(input.cwd, ".agent", "skills"), ]; @@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const input = yield* makeWorkspace(); const root = path.join(input.cwd, ".agents", "skills"); yield* writeSkill( - path.join(input.profileDirectory, "config", "skills", "review"), + path.join(input.userHome, ".gemini", "config", "skills", "review"), "---\nname: [invalid\n---\n", ); const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; @@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const input = yield* makeWorkspace(); - const sourceDirectory = path.join(input.profileDirectory, "shared-review"); + const sourceDirectory = path.join(input.userHome, "shared-review"); yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); const root = path.join(input.cwd, ".agents", "skills"); const linkedDirectory = path.join(root, "review"); @@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); }); + +it("resolves the home the agent expands ~ against", () => { + assert.equal( + resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "/home/user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "C:\\Users\\user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + "D:\\Users\\alice", + ); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); +}); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index a8b206a89279..bb238e4e0fa6 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,6 +9,45 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; +/** + * The home directory the agent expands `~` against, matching Python's + * `os.path.expanduser` in the launch environment T3 hands the process: + * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` + * elsewhere. Values are used verbatim; a path may contain spaces. + */ +export function resolveAntigravityUserHome( + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string { + if (platform === "win32") { + if (environment.USERPROFILE) return environment.USERPROFILE; + if (environment.HOMEDRIVE && environment.HOMEPATH) { + return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; + } + return NodeOS.homedir(); + } + return environment.HOME || NodeOS.homedir(); +} + +/** + * The agent's two user-global skill directories under a Gemini home, in + * native precedence order: `config/skills` is shared with the Antigravity IDE + * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs + * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a + * private profile, so the profile links these back to the user's `~/.gemini`. + * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a + * project directory. + */ +export function antigravityUserSkillDirectories( + path: Path.Path, + geminiHome: string, +): readonly [configSkills: string, cliSkills: string] { + return [ + path.join(geminiHome, "config", "skills"), + path.join(geminiHome, "antigravity-cli", "skills"), + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; @@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; - readonly profileDirectory: string; + readonly userHome: string; }): Effect.fn.Return< ReadonlyArray, AntigravitySkillsProbeError, @@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); const roots = [ - { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: configSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { - directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"), - scope: "user", - }, + { directory: cliSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, ]; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 56cb1cb06f08..324284a3a4c7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; @@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 003c6f53d317..bac34db452fd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -17,6 +20,11 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { @@ -102,4 +110,263 @@ it.layer(testLayer)("CodexDriver", (it) => { expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), ); + + for (const fixture of [ + { + name: "leaves mise npm-backend installations manual-only", + installSegments: ["mise", "installs", "npm-openai-codex", "0.110.0"], + npmOwned: false, + }, + { + name: "leaves mise tool aliases backed by npm manual-only", + installSegments: ["mise", "installs", "codex", "0.110.0"], + npmOwned: false, + }, + { + name: "keeps npm updates for globals in a mise Node installation", + installSegments: ["mise", "installs", "node", "24.0.0"], + npmOwned: true, + }, + { + name: "keeps npm updates for ordinary global installations", + installSegments: ["npm-global"], + npmOwned: true, + }, + ] as const) { + it.effect.skipIf(windowsHost)(fixture.name, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }); + const installPath = NodePath.join(tempDir, ...fixture.installSegments); + const realBinaryPath = NodePath.join( + installPath, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-installer"), + displayName: "Codex installer test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + const update = (yield* instance.snapshot.resolveMaintenance()).update; + if (fixture.npmOwned) { + expect(update).toMatchObject({ + executable: "npm", + args: [ + "install", + "-g", + "--prefix", + installPath, + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + }); + } else { + expect(update).toBeNull(); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } + + for (const layout of ["direct", "wrapper"] as const) { + it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }); + const binaryPath = + layout === "direct" + ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") + : NodePath.join(tempDir, "omarchy", "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString( + binaryPath, + layout === "direct" + ? "#!/bin/sh\n" + : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', + ); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), + displayName: "Codex mise test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index d7fd1e9c5698..071fb20674a8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; @@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ddfda0f9133c..5466af802e50 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -29,6 +29,7 @@ import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, + makeCursorModelDiscovery, enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; @@ -136,7 +137,12 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + discoverModels, + ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35de3..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d346..53a20049ad5d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index aa7d4c6a6755..97925cd4912d 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1245,6 +1245,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 363afbee1106..34fd81a424ca 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -264,6 +264,23 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("publishes the configured sign-in method before any account is checked", () => + Effect.scoped( + Effect.gen(function* () { + const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { + stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), + probe: Effect.succeed(initializeResult), + supportsTextGeneration: Effect.succeed(true), + auth: { type: "gemini-api-key", label: "Gemini API key" }, + }); + expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ + status: "unknown", + type: "gemini-api-key", + }); + }), + ), + ); + it.effect("treats initialize as installation proof, not account or model discovery", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index bd886a123808..956a5d81d3f4 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -144,7 +144,9 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func installed: false, version: null, status: "warning", - auth: { status: "unknown" }, + // The configured method rides along so the registry can tell a saved + // account for this method from one left by a previous configuration. + auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, message: settings.enabled ? "Checking Antigravity availability." : "Antigravity is disabled in T3 Code settings.", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 84cc03b75a6d..13b44c1669fa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2253,6 +2253,54 @@ describe("ClaudeAdapterLive", () => { return Effect.forEach(reasons, runDeadTurn, { discard: true }); }); + it.effect.each(["success", "error_during_execution"] as const)( + "preserves %s behavior for an unknown runtime terminal reason", + (subtype) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + // An installed CLI can send a terminal reason newer than the bundled SDK. + harness.query.emit({ + type: "result", + subtype, + is_error: subtype !== "success", + result: "", + errors: subtype === "success" ? [] : ["Provider error detail"], + stop_reason: null, + terminal_reason: "future_terminal_reason", + session_id: "sdk-session-future-reason", + uuid: "result-future-reason", + } as unknown as SDKMessage); + const completed = yield* Fiber.join(completionFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal( + completed.value.payload.state, + subtype === "success" ? "completed" : "failed", + ); + assert.equal( + completed.value.payload.errorMessage, + subtype === "success" ? undefined : "Provider error detail", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("fails a turn when a success result reports a 529 overload", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a005f583066f..1ecde618e191 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -434,26 +434,9 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } -/** - * First user-facing error from a non-success result. "[ede_diagnostic] ..." - * entries are CLI-internal telemetry (the CLI hides them from its own UI too), - * so they must never become the error banner. - */ -function resultUserFacingError(result: SDKResultMessage): string | undefined { - const listed = - result.subtype === "success" || !Array.isArray(result.errors) - ? undefined - : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); - if (listed) { - return listed; - } - // Structured failure markers for results whose error list is empty or - // diagnostic-only: an overloaded API (529) and the terminal reasons the - // CLI stamps when it gives up on a turn. - if (isOverloadedResult(result)) { - return "Claude API is overloaded (529). Try again shortly."; - } - switch (result.terminal_reason) { +/** Failure text for structured terminal reasons, including success-tagged failures. */ +function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined { + switch (reason) { case "api_error": return "Claude gave up after repeated API errors."; case "malformed_tool_use_exhausted": @@ -1559,27 +1542,6 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( return buildUserMessage({ sdkContent }); }); -/** - * terminal_reason values the CLI classifies as dead turns: the turn died - * rather than finished, even when the result subtype is success and the - * error list is empty. Kept in sync with the messages in - * resultUserFacingError. - */ -const FAILED_TERMINAL_REASONS: ReadonlySet> = - new Set([ - "api_error", - "malformed_tool_use_exhausted", - "budget_exhausted", - "structured_output_retry_exhausted", - "tool_deferred_unavailable", - "turn_setup_failed", - "blocking_limit", - "rapid_refill_breaker", - "prompt_too_long", - "image_error", - "model_error", - ]); - /** * The CLI reports repeated 529 overload failures as a success-subtype result * with api_error_status 529 and an empty error list; the status code is the @@ -1589,25 +1551,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { return result.subtype === "success" && result.api_error_status === 529; } -function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { - if ( - isOverloadedResult(result) || - (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason)) - ) { - return "failed"; - } - if (result.subtype === "success") { - return "completed"; - } - - const errors = resultErrorsText(result); - if (isInterruptedResult(result)) { - return "interrupted"; - } - if (errors.includes("cancel")) { - return "cancelled"; - } - return "failed"; +/** Derives turn status and its error from the same provider result. */ +function resultOutcome(result: SDKResultMessage): { + status: ProviderRuntimeTurnStatus; + errorMessage: string | undefined; +} { + const structuredError = isOverloadedResult(result) + ? "Claude API is overloaded (529). Try again shortly." + : terminalResultError(result.terminal_reason); + // CLI diagnostic entries must not become the error banner. + const listedError = + result.subtype === "success" || !Array.isArray(result.errors) + ? undefined + : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + const errorMessage = listedError || structuredError; + if (structuredError !== undefined) return { status: "failed", errorMessage }; + if (result.subtype === "success") return { status: "completed", errorMessage }; + if (isInterruptedResult(result)) return { status: "interrupted", errorMessage }; + return { + status: resultErrorsText(result).includes("cancel") ? "cancelled" : "failed", + errorMessage, + }; } function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind { @@ -3268,8 +3232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - const status = turnStatusFromResult(message); - const errorMessage = resultUserFacingError(message); + const { status, errorMessage } = resultOutcome(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -5112,6 +5075,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( capabilities: { sessionModelSwitch: "in-session", }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2ca44a2a5f0a..ef6e97d8993d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), @@ -352,7 +353,8 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { Stream.runHead, Effect.forkChild, ); - yield* adapter.compactThread!(threadId); + NodeAssert.ok(adapter.compaction?.type === "native"); + yield* adapter.compaction.start(threadId); yield* runtime.emit({ id: asEventId("evt-compaction-item-completed"), kind: "notification", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d1981b33d47d..5e2244336afc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2504,14 +2504,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const compactThread: NonNullable = Effect.fn("compactThread")( - function* (threadId) { - const session = yield* requireSession(threadId); - yield* session.runtime.compactThread.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), - ); - }, - ); + const compactThread = Effect.fn("compactThread")(function* (threadId: ThreadId) { + const session = yield* requireSession(threadId); + yield* session.runtime.compactThread.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + ); + }); const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( @@ -2658,7 +2656,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }, startSession, sendTurn, - compactThread, + compaction: { type: "native", start: compactThread }, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fd4d66dd497b..0136c3fbf170 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /T3 Code/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -484,7 +480,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -513,11 +509,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -526,10 +522,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); @@ -543,7 +537,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index a8aea90e8972..1ed9648a50b6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1212,6 +1212,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, + compaction: { type: "slash-command", command: "/compress" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78edd8acbd45..adda9f44d465 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,7 +16,7 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, - getCursorFallbackModels, + makeCursorModelDiscovery, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -475,16 +475,6 @@ describe("Cursor skills", () => { }); }); -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildCursorProviderSnapshot", () => { it("downgrades ready status to warning when ACP model discovery times out", () => { expect( @@ -638,6 +628,42 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("reuses successful discovery until the CLI version or account changes", async () => { + await runNode( + Effect.gen(function* () { + const { requestLogPath, wrapperPath } = yield* makeProviderStatusEnvFixture(); + const fileSystem = yield* FileSystem.FileSystem; + const settings = { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }; + const discover = yield* makeCursorModelDiscovery(settings, { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const about = { + version: "2026.08.11", + auth: { status: "authenticated" as const, label: "first@example.test" }, + }; + const first = yield* discover(about); + expect(first.length).toBeGreaterThan(0); + yield* fileSystem.writeFileString(requestLogPath, ""); + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* discover({ ...about, version: "2026.08.12" }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); + yield* discover({ + version: "2026.08.12", + auth: { ...about.auth, label: "second@example.test" }, + }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + }), + ); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e6c7853844e0..cf4f00ac967a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -10,6 +10,8 @@ import type { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -572,7 +574,24 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); -export function getCursorFallbackModels( +// Each driver instance owns its cache; version and account changes invalidate it. +export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, +) { + const cache = yield* Cache.makeWith( + (_key: string) => discoverCursorModelsViaAcp(cursorSettings, environment), + { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, + }, + ); + return (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])); +}); + +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); @@ -989,6 +1008,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + discoverModels?: (about: CursorAboutResult) => ReturnType, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1086,9 +1106,10 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - ), + (discoverModels + ? discoverModels(parsed) + : discoverCursorModelsViaAcp(cursorSettings, environment) + ).pipe(Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS)), ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Cursor ACP model discovery failed", { diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index dae7c2ca08f6..25188adcffcc 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -2128,6 +2128,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index fb0e9aa9ef2d..ee5767f9d356 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { beforeEach } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; import type { Event as OpenCodeEvent, PermissionRequest, @@ -43,7 +43,6 @@ import { type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; import { - appendOpenCodeAssistantTextDelta, isOpenCodeNotFound, isSameOpenCodeDirectory, makeOpenCodeAdapter, @@ -84,6 +83,7 @@ const runtimeMock = { | ((sessionID: string) => Promise>) | null, closeCalls: [] as string[], + revertMessageID: undefined as string | undefined, revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, @@ -143,6 +143,7 @@ const runtimeMock = { this.state.sessionChildrenById.clear(); this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; + this.state.revertMessageID = undefined; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; this.state.messageFailures = 0; @@ -263,6 +264,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { data: { id: sessionID, + ...(runtimeMock.state.revertMessageID + ? { revert: { messageID: runtimeMock.state.revertMessageID } } + : {}), ...(directory ? { directory } : {}), ...(parentID ? { parentID } : {}), }, @@ -372,17 +376,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ...(messageID ? { messageID } : {}), }); if (!messageID) { - runtimeMock.state.messages = []; - return; + throw new Error("Expected messageID"); + } + let lastUserID: string | undefined; + for (const entry of runtimeMock.state.messages) { + if (entry.info.role === "user") lastUserID = entry.info.id; + if (entry.info.id === messageID && entry.parts.length > 0) { + runtimeMock.state.revertMessageID = lastUserID ?? messageID; + break; + } } - - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; }, }, event: { @@ -517,6 +520,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), @@ -574,6 +578,18 @@ function promiseWithResolvers() { return { promise, resolve, reject }; } +function makeOpenCodeEventQueue() { + let pending = promiseWithResolvers(); + const events = [pending.promise]; + runtimeMock.state.subscribedEvents = events; + return (event: unknown) => { + const current = pending; + pending = promiseWithResolvers(); + events.push(pending.promise); + current.resolve(event); + }; +} + const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ id, sessionID, @@ -1064,7 +1080,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { threadId, runtimeMode: "full-access", }); - yield* adapter.compactThread!( + NodeAssert.ok(adapter.compaction?.type === "native"); + yield* adapter.compaction.start( threadId, createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), ); @@ -6316,7 +6333,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.provide(adapterLayer)); }); - it.effect("reverts the full thread when rollback removes every assistant turn", () => + it.effect("reverts the first removed assistant message and returns only retained turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); @@ -6327,22 +6344,62 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, { info: { id: "assistant-1", role: "assistant" }, - parts: [], + parts: [{ id: "part-1", type: "text", text: "first answer" }], }, + { info: { id: "user-2", role: "user" }, parts: [] }, { info: { id: "assistant-2", role: "assistant" }, - parts: [], + parts: [{ id: "part-2", type: "text", text: "second answer" }], }, ]; - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session" }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); + for (const numTurns of [0, 1, 2, 3]) { + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.revertCalls.length = 0; + const snapshot = yield* adapter.rollbackThread(threadId, numTurns); + NodeAssert.deepEqual( + runtimeMock.state.revertCalls, + numTurns === 0 + ? [] + : [ + { + sessionID: "http://127.0.0.1:9999/session", + messageID: numTurns === 1 ? "assistant-2" : "assistant-1", + }, + ], + ); + NodeAssert.deepEqual( + snapshot.turns.map((turn) => turn.id), + ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), + ); + } + runtimeMock.state.revertMessageID = undefined; + for (const remaining of [1, 0]) { + const snapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(snapshot.turns.length, remaining); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); + } + NodeAssert.deepEqual( + runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), + ["assistant-2", "assistant-1"], + ); + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.messages = runtimeMock.state.messages.filter( + (entry) => entry.info.id !== "user-2", + ); + const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1"); + NodeAssert.deepEqual(sharedUserSnapshot.turns, []); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); + + runtimeMock.state.messages = []; + runtimeMock.state.revertCalls.length = 0; + const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); + NodeAssert.deepEqual(emptySnapshot.turns, []); }), ); @@ -6430,20 +6487,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.scoped), ); - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => + it.effect("reconciles assistant text snapshots", () => Effect.sync(() => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); - NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], - ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(firstUpdate, { + latestText: "Hello", + deltaToEmit: "Hello", + }); NodeAssert.deepEqual(appendedUpdate, { latestText: "Hello world", deltaToEmit: " world", @@ -6667,6 +6721,301 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("processes late assistant metadata without visiting completed turns", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-indexed-opencode-parts"); + const sessionID = "http://127.0.0.1:9999/session"; + const enqueue = makeOpenCodeEventQueue(); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + for (let index = 0; index < 24; index += 1) { + const completed = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.sendTurn({ + threadId, + input: `Complete turn ${index}`, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + enqueue({ + type: "message.updated", + properties: { sessionID, info: { id: `history-message-${index}`, role: "assistant" } }, + }); + enqueue({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: `history-part-${index}`, + messageID: `history-message-${index}`, + sessionID, + type: "text", + text: `Completed turn ${index}`, + time: { start: 1, end: 2 }, + }, + }, + }); + enqueue({ + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + yield* Fiber.join(completed); + } + + let visitedHistoryParts = 0; + const values = Map.prototype.values; + yield* Effect.acquireRelease( + Effect.sync(() => + vi + .spyOn(Map.prototype, "values") + .mockImplementation(function (this: Map) { + const iterator = values.call(this); + const next = iterator.next.bind(iterator); + iterator.next = () => { + const result = next(); + const value: unknown = result.value; + if ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.startsWith("history-part-") + ) { + visitedHistoryParts += 1; + } + return result; + }; + return iterator; + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + + const stepProcessed = yield* Deferred.make(); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.tap((event) => + event.type === "thread.state.changed" + ? Deferred.succeed(stepProcessed, undefined) + : Effect.void, + ), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ + threadId, + input: "Process late metadata", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const promptMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + const part = { + id: "current-part", + messageID: "current-message", + sessionID, + type: "text", + text: "Current response", + time: { start: 3, end: 4 }, + }; + const step = { + id: "current-step", + messageID: "current-message", + sessionID, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 40, output: 10, reasoning: 2, cache: { read: 5, write: 1 } }, + }; + enqueue({ type: "message.part.updated", properties: { sessionID, part } }); + enqueue({ type: "message.part.updated", properties: { sessionID, part: step } }); + enqueue({ type: "session.compacted", properties: { sessionID } }); + yield* Deferred.await(stepProcessed); + yield* adapter.sendTurn({ + threadId, + input: "Steer before metadata arrives", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + for (const parentID of ["", promptMessageId]) { + enqueue({ + type: "message.updated", + properties: { sessionID, info: { id: "current-message", role: "assistant", parentID } }, + }); + } + enqueue({ type: "message.part.updated", properties: { sessionID, part } }); + enqueue({ type: "message.part.updated", properties: { sessionID, part: step } }); + enqueue({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "history-part-0", + messageID: "history-message-0", + sessionID, + type: "text", + text: "Completed turn zero", + time: { start: 1, end: 2 }, + }, + }, + }); + enqueue({ type: "session.status", properties: { sessionID, status: { type: "idle" } } }); + + const events = yield* Fiber.join(eventsFiber); + NodeAssert.equal(visitedHistoryParts, 0); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => event.payload.delta), + ["Current response", "zero"], + ); + NodeAssert.equal(events.filter((event) => event.type === "item.completed").length, 1); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.deepEqual(completed?.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 46, + cachedInputTokens: 5, + cacheCreationTokens: 1, + outputTokens: 12, + reasoningTokens: 2, + hasSubagents: false, + }); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("keeps completed text edits and clears removed parts across reconnects", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-text-retention"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "retained-message"; + const metadata = { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }; + const snapshot = ( + text: string, + id = "retained-part", + type: "text" | "reasoning" = "text", + ) => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { id, sessionID, messageID, type, text, time: { start: 1, end: 2 } }, + }, + }); + const delta = (text: string) => ({ + type: "message.part.delta", + properties: { sessionID, messageID, partID: "retained-part", field: "text", delta: text }, + }); + const nonTextReplacement = { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "retained-part", + sessionID, + messageID, + type: "file", + mime: "text/plain", + url: "file:///repo/result.txt", + }, + }, + }; + runtimeMock.state.subscribedEvents = [ + snapshot("Replaced before metadata"), + nonTextReplacement, + metadata, + snapshot("Thinking", "reasoning-part", "reasoning"), + snapshot("Hello world"), + { type: "server.connected", properties: {} }, + metadata, + snapshot("Thinking", "reasoning-part", "reasoning"), + snapshot("Thinking more", "reasoning-part", "reasoning"), + snapshot("Hello world"), + snapshot("Hello"), + snapshot("Hello there"), + delta(" again"), + snapshot("Hello there again"), + nonTextReplacement, + delta("ignored while file"), + metadata, + snapshot("Hello there again!"), + { + type: "message.part.removed", + properties: { sessionID, messageID, partID: "retained-part" }, + }, + delta("removed part"), + metadata, + snapshot("Fresh"), + snapshot("Second", "second-part"), + { type: "message.removed", properties: { sessionID, messageID } }, + delta("removed message"), + metadata, + snapshot("New thoughts", "reasoning-part", "reasoning"), + snapshot("New"), + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => [event.payload.streamKind, event.payload.delta]), + [ + ["reasoning_text", "Thinking"], + ["assistant_text", "Hello world"], + ["reasoning_text", " more"], + ["assistant_text", "there"], + ["assistant_text", " again"], + ["assistant_text", "!"], + ["assistant_text", "Fresh"], + ["assistant_text", "Second"], + ["reasoning_text", "New thoughts"], + ["assistant_text", "New"], + ], + ); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "item.completed") + .map((event) => event.payload.detail), + ["Hello world", "Fresh", "Second", "New"], + ); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("maps native task progress only while a turn is active", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index ea777d1e15f9..742ee9b86d6a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -4,6 +4,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, + type ProviderSendTurnInput, type ProviderSession, RuntimeItemId, RuntimeRequestId, @@ -322,6 +323,16 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +type OpenCodeTextPart = Extract; + +type OpenCodeTextPartState = Pick & { + text: string | undefined; + emittedText: string | undefined; + completed: boolean; +}; + +type OpenCodeStepUsage = Pick, "id" | "tokens">; + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -336,9 +347,9 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; - readonly partById: Map; - readonly emittedTextByPartId: Map; - readonly completedAssistantPartIds: Set; + // OpenCode permits edits to completed parts. Keep text for snapshot comparison + // until native removal or session teardown, but do not retain other part payloads. + readonly textPartsByMessageId: Map>; turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; activeAgent: string | undefined; @@ -374,7 +385,8 @@ interface OpenCodeTurnTokenUsageAccumulator { readonly partIds: Set; readonly promptMessageIds: Set; readonly assistantOwnershipByMessageId: Map; - readonly unresolvedStepPartIds: Set; + // Native removal does not undo usage. Keep unresolved counts until this turn settles. + readonly unresolvedStepsByMessageId: Map>; inputTokens: number; cachedInputTokens: number; cacheCreationTokens: number; @@ -389,7 +401,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula partIds: new Set(), promptMessageIds: new Set(), assistantOwnershipByMessageId: new Map(), - unresolvedStepPartIds: new Set(), + unresolvedStepsByMessageId: new Map(), inputTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0, @@ -402,7 +414,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula function accumulateOpenCodeStepUsage( accumulator: OpenCodeTurnTokenUsageAccumulator, - part: Extract, + part: OpenCodeStepUsage, ): void { if (accumulator.partIds.has(part.id)) return; accumulator.partIds.add(part.id); @@ -428,7 +440,9 @@ function takeOpenCodeTurnTokenUsage( } return { usageStatus: - complete && usage.complete && usage.unresolvedStepPartIds.size === 0 ? "complete" : "partial", + complete && usage.complete && usage.unresolvedStepsByMessageId.size === 0 + ? "complete" + : "partial", usageScope: "main_agent", inputTokens: usage.inputTokens, cachedInputTokens: usage.cachedInputTokens, @@ -579,18 +593,29 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray): "assistant_text" | "reasoning_text" { + return part.type === "reasoning" ? "reasoning_text" : "assistant_text"; } -function textFromPart(part: Part): string | undefined { - switch (part.type) { - case "text": - case "reasoning": - return part.text; - default: - return undefined; - } +function retainOpenCodeTextPart( + context: OpenCodeSessionContext, + part: OpenCodeTextPart, +): OpenCodeTextPartState { + const parts = + context.textPartsByMessageId.get(part.messageID) ?? new Map(); + const previous = parts.get(part.id); + const state = { + id: part.id, + messageID: part.messageID, + type: part.type, + text: part.text, + ...(part.time !== undefined ? { time: part.time } : {}), + emittedText: previous?.emittedText, + completed: previous?.completed ?? false, + }; + parts.set(part.id, state); + context.textPartsByMessageId.set(part.messageID, parts); + return state; } function commonPrefixLength(left: string, right: string): number { @@ -626,7 +651,7 @@ export function mergeOpenCodeAssistantText( }; } -export function appendOpenCodeAssistantTextDelta( +function appendOpenCodeAssistantTextDelta( previousText: string, delta: string, ): { @@ -1362,6 +1387,7 @@ export function makeOpenCodeAdapter( if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { promptAdmission.messageObserved = true; context.messageRoleById.set(promptAdmission.messageId, "user"); + context.textPartsByMessageId.delete(promptAdmission.messageId); } } @@ -1568,35 +1594,23 @@ export function makeOpenCodeAdapter( /** Emit content.delta and item.completed events for an assistant text part. */ const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, - part: Part, + part: OpenCodeTextPartState, turnId: TurnId | undefined, raw: unknown, ) { - const text = textFromPart(part); - if (text === undefined) { + if (part.text === undefined) { return; } - const previousText = context.emittedTextByPartId.get(part.id); - const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); - context.emittedTextByPartId.set(part.id, latestText); - if (latestText !== text) { - context.partById.set( - part.id, - (part.type === "text" || part.type === "reasoning" - ? { ...part, text: latestText } - : part) satisfies Part, - ); - } + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(part.emittedText, part.text); + part.emittedText = latestText; + part.text = latestText; if (deltaToEmit.length > 0) { yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, itemId: part.id, - createdAt: - (part.type === "text" || part.type === "reasoning") && part.time !== undefined - ? isoFromEpochMs(part.time.start) - : undefined, + createdAt: part.time !== undefined ? isoFromEpochMs(part.time.start) : undefined, raw, })), type: "content.delta", @@ -1607,12 +1621,8 @@ export function makeOpenCodeAdapter( }); } - if ( - part.type === "text" && - part.time?.end !== undefined && - !context.completedAssistantPartIds.has(part.id) - ) { - context.completedAssistantPartIds.add(part.id); + if (part.type === "text" && part.time?.end !== undefined && !part.completed) { + part.completed = true; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -2315,6 +2325,9 @@ export function makeOpenCodeAdapter( } } context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "user") { + context.textPartsByMessageId.delete(event.properties.info.id); + } if (event.properties.info.role === "assistant") { const usage = context.turnTokenUsage; const parentMessageId = @@ -2337,15 +2350,19 @@ export function makeOpenCodeAdapter( : priorOwnership; if (usage) { usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership); - } - for (const part of context.partById.values()) { - if (part.messageID !== event.properties.info.id) { - continue; - } - if (usage && part.type === "step-finish") { - if (ownership !== "unknown") usage.unresolvedStepPartIds.delete(part.id); - if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); + if (ownership !== "unknown") { + const steps = usage.unresolvedStepsByMessageId.get(event.properties.info.id); + if (ownership === "owned" && steps) { + for (const step of steps.values()) { + accumulateOpenCodeStepUsage(usage, step); + } + } + usage.unresolvedStepsByMessageId.delete(event.properties.info.id); } + } + for (const part of context.textPartsByMessageId + .get(event.properties.info.id) + ?.values() ?? []) { yield* emitAssistantTextDelta(context, part, turnId, event); } } @@ -2354,16 +2371,24 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); + context.textPartsByMessageId.delete(event.properties.messageID); + break; + } + + case "message.part.removed": { + const parts = context.textPartsByMessageId.get(event.properties.messageID); + parts?.delete(event.properties.partID); + if (parts?.size === 0) { + context.textPartsByMessageId.delete(event.properties.messageID); + } break; } case "message.part.delta": { - const existingPart = context.partById.get(event.properties.partID); - if ( - !existingPart || - (existingPart.type !== "text" && existingPart.type !== "reasoning") || - event.properties.field !== "text" - ) { + const existingPart = context.textPartsByMessageId + .get(event.properties.messageID) + ?.get(event.properties.partID); + if (existingPart?.text === undefined || event.properties.field !== "text") { break; } const role = messageRoleForPart(context, existingPart); @@ -2375,21 +2400,13 @@ export function makeOpenCodeAdapter( if (delta.length === 0) { break; } - const previousText = - context.emittedTextByPartId.get(event.properties.partID) ?? - textFromPart(existingPart) ?? - ""; + const previousText = existingPart.emittedText ?? existingPart.text; const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); if (deltaToEmit.length === 0) { break; } - context.emittedTextByPartId.set(event.properties.partID, nextText); - if (existingPart.type === "text" || existingPart.type === "reasoning") { - context.partById.set(event.properties.partID, { - ...existingPart, - text: nextText, - }); - } + existingPart.emittedText = nextText; + existingPart.text = nextText; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -2408,29 +2425,38 @@ export function makeOpenCodeAdapter( case "message.part.updated": { const part = event.properties.part; - // Tool events use the incoming part and do not need a cached copy. - if (part.type !== "tool") { - context.partById.set(part.id, part); - } const messageRole = messageRoleForPart(context, part); if (turnId && part.type === "step-finish" && context.turnTokenUsage) { - const ownership = context.turnTokenUsage.assistantOwnershipByMessageId.get( - part.messageID, - ); + const usage = context.turnTokenUsage; + const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); if (ownership === "owned") { - accumulateOpenCodeStepUsage(context.turnTokenUsage, part); + accumulateOpenCodeStepUsage(usage, part); } else if ( ownership === "unknown" || (ownership === undefined && context.messageRoleById.get(part.messageID) !== "assistant") ) { - context.turnTokenUsage.unresolvedStepPartIds.add(part.id); + const steps = + usage.unresolvedStepsByMessageId.get(part.messageID) ?? + new Map(); + steps.set(part.id, { id: part.id, tokens: part.tokens }); + usage.unresolvedStepsByMessageId.set(part.messageID, steps); } } - if (messageRole === "assistant") { - yield* emitAssistantTextDelta(context, part, turnId, event); + if ((part.type === "text" || part.type === "reasoning") && messageRole !== "user") { + const state = retainOpenCodeTextPart(context, part); + if (messageRole === "assistant") { + yield* emitAssistantTextDelta(context, state, turnId, event); + } + } else { + const previous = context.textPartsByMessageId.get(part.messageID)?.get(part.id); + if (previous) { + // A non-text PATCH removes the current snapshot. Keep emitted text + // so a later text PATCH still emits only the changed suffix. + previous.text = undefined; + } } if (part.type === "tool") { @@ -2963,10 +2989,8 @@ export function makeOpenCodeAdapter( requestRelationRetries: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), - partById: new Map(), - emittedTextByPartId: new Map(), + textPartsByMessageId: new Map(), messageRoleById: new Map(), - completedAssistantPartIds: new Set(), turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, @@ -3403,9 +3427,10 @@ export function makeOpenCodeAdapter( ); }); - const compactThread: NonNullable = Effect.fn( - "compactThread", - )(function* (threadId, requestedModelSelection) { + const compactThread = Effect.fn("compactThread")(function* ( + threadId: ThreadId, + requestedModelSelection?: ProviderSendTurnInput["modelSelection"], + ) { const context = yield* ensureSessionContext(sessions, threadId); yield* awaitOpenCodeContextReady(context); const modelSelection = @@ -3750,6 +3775,9 @@ export function makeOpenCodeAdapter( const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* ensureSessionContext(sessions, threadId); + const session = yield* runOpenCodeSdk("session.get", () => + context.client.session.get({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const messages = yield* runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, @@ -3758,6 +3786,7 @@ export function makeOpenCodeAdapter( const turns: Array = []; for (const entry of messages.data ?? []) { + if (entry.info.id === session.data?.revert?.messageID) break; if (entry.info.role === "assistant") { turns.push({ id: TurnId.make(entry.info.id), @@ -3776,25 +3805,21 @@ export function makeOpenCodeAdapter( const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); + const snapshot = yield* readThread(threadId); + const targetIndex = Math.max(0, snapshot.turns.length - numTurns); + const target = snapshot.turns[targetIndex]; + if (target) { + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + messageID: target.id, + }), + ).pipe(Effect.mapError(toRequestError)); + // Native revert can move the boundary to the preceding user message. + return yield* readThread(threadId); + } - return yield* readThread(threadId); + return snapshot; }, ); @@ -3820,7 +3845,7 @@ export function makeOpenCodeAdapter( }, startSession, sendTurn, - compactThread, + compaction: { type: "native", start: compactThread }, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index af43e039e652..25dafa5ba040 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -24,7 +24,6 @@ */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -35,9 +34,12 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cf3fe15ea9a5..988c89e1e679 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -43,7 +43,6 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, mergeProviderSnapshot, upsertProviderWorkspaceSnapshot, ProviderRegistryLive, @@ -555,39 +554,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); - it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), @@ -1213,6 +1179,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); }); + describe("Antigravity saved account", () => { + const signedIn = { + instanceId: ProviderInstanceId.make("antigravity-personal"), + driver: ProviderDriverKind.make("antigravity"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, + checkedAt: "2026-09-05T00:00:00.000Z", + version: "agy_acp_server_1.1.1", + models: [ + { + slug: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [{ name: "plan" }], + skills: [], + } as const satisfies ServerProvider; + const uncheckedMessage = + "Antigravity is installed. Google account access is not checked yet."; + const restartProbe = { + ...signedIn, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-09-05T00:01:00.000Z", + message: uncheckedMessage, + models: [], + } as const satisfies ServerProvider; + + it("keeps the saved Google account through restart health checks", () => { + const merged = mergeProviderSnapshot(signedIn, restartProbe); + const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; + assert.deepStrictEqual(merged, { + ...probeWithoutMessage, + status: "ready", + auth: signedIn.auth, + models: signedIn.models, + }); + assert.equal("message" in merged, false); + // The next periodic probe reads the merged snapshot as its previous state. + assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged); + }); + + it("carries the account through the boot probe and a failed probe without hiding them", () => { + const booting = { + ...restartProbe, + installed: false, + version: null, + message: "Checking Antigravity availability.", + } satisfies ServerProvider; + assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), { + ...booting, + auth: signedIn.auth, + models: signedIn.models, + }); + + const failed = { + ...restartProbe, + status: "error", + message: "Antigravity did not respond to its local health check within 90 seconds.", + } satisfies ServerProvider; + assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), { + ...failed, + auth: signedIn.auth, + models: signedIn.models, + }); + }); + + it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { + const untouched = [ + { ...restartProbe, auth: { status: "unauthenticated" } }, + { ...restartProbe, status: "disabled", enabled: false }, + { ...restartProbe, status: "error", installed: false }, + { ...restartProbe, driver: ProviderDriverKind.make("codex") }, + // The instance was rebuilt with another sign-in method. + { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, + ] satisfies ReadonlyArray; + for (const next of untouched) { + const merged = mergeProviderSnapshot(signedIn, next); + assert.deepStrictEqual(merged.auth, next.auth); + assert.equal(merged.status, next.status); + assert.equal(merged.message, next.message); + } + assert.deepStrictEqual( + mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth, + { status: "unknown" }, + ); + assert.equal( + mergeProviderSnapshot( + { ...signedIn, driver: ProviderDriverKind.make("codex") }, + restartProbe, + ).auth.status, + "unknown", + ); + assert.deepStrictEqual( + mergeProviderSnapshot(signedIn, { + ...restartProbe, + auth: { status: "unknown", type: "oauth-personal" }, + }).auth, + signedIn.auth, + ); + }); + }); + it("fills missing capabilities from the previous provider snapshot", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2fd4278f3c57..a8e6caf95aa7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -162,33 +162,71 @@ const mergeProviderModels = ( : mergedModels; }; +/** + * Antigravity's health check only initializes the agent, so after a server + * restart it reports the account as unchecked. The saved Google login still + * works, and the previous snapshot proves it. Carry that account state until + * a session, refresh, or sign-out reports something new. A confirmed missing + * installation, sign-out, disabled instance, or a changed sign-in method is + * never overridden. + */ +const carrySavedAntigravityAccount = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): Pick | undefined => { + const antigravity = ProviderDriverKind.make("antigravity"); + if ( + nextProvider.driver !== antigravity || + previousProvider.driver !== antigravity || + !nextProvider.enabled || + nextProvider.auth.status !== "unknown" || + previousProvider.auth.status !== "authenticated" || + (nextProvider.auth.type !== undefined && + nextProvider.auth.type !== previousProvider.auth.type) || + (!nextProvider.installed && nextProvider.status !== "warning") + ) { + return undefined; + } + // The pending boot probe (`installed: false`, warning) and a failed probe + // keep their own status; only a passed health check reads as ready. + const status = + nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; + return { auth: previousProvider.auth, status }; +}; + export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, -): ServerProvider => - !previousProvider - ? nextProvider - : { - ...nextProvider, - models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), - ...(nextProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: nextProvider.workspaceSnapshots } - : previousProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: previousProvider.workspaceSnapshots } - : {}), - ...(shouldRetainMissingOpenCodeMetadata(nextProvider) - ? { - slashCommands: - nextProvider.slashCommands.length === 0 - ? previousProvider.slashCommands - : nextProvider.slashCommands, - skills: - nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, - } - : {}), - }; +): ServerProvider => { + if (!previousProvider) { + return nextProvider; + } + const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + // "Google account access is not checked yet" describes the probe, not the + // account; it must not outlive the state it explained. + const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; + return { + ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), + ...savedAccount, + models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), + ...(shouldRetainMissingOpenCodeMetadata(nextProvider) + ? { + slashCommands: + nextProvider.slashCommands.length === 0 + ? previousProvider.slashCommands + : nextProvider.slashCommands, + skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, + } + : {}), + }; +}; -export const haveProvidersChanged = ( +const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f5b9be91650f..4d17aabaa5f5 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -19,6 +19,8 @@ import { EnvironmentId, EventId, MessageId, + OrchestrationThreadShell, + ProjectId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderDriverKind, ProviderInstanceId, @@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( @@ -187,7 +190,7 @@ function makeFakeCodexAdapter( Effect.void, ); - const compactThread = vi.fn((threadId: ThreadId) => + const compactThread = vi.fn((threadId: ThreadId): Effect.Effect => Effect.sync(() => emit({ type: "thread.state.changed", @@ -275,7 +278,13 @@ function makeFakeCodexAdapter( }, startSession, sendTurn, - ...(provider === CODEX_DRIVER ? { compactThread } : {}), + ...(provider === CODEX_DRIVER + ? { compaction: { type: "native", start: compactThread } } + : provider === CURSOR_DRIVER + ? { compaction: { type: "slash-command", command: "/compress" } } + : provider === CLAUDE_AGENT_DRIVER + ? { compaction: { type: "slash-command", command: "/compact" } } + : {}), interruptTurn, respondToRequest, respondToUserInput, @@ -978,6 +987,125 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); +const nativeCompactionInstanceId = ProviderInstanceId.make("native-compaction"); +const slashCompactionInstanceId = ProviderInstanceId.make("slash-compaction"); +const unsupportedCompactionInstanceId = ProviderInstanceId.make("unsupported-compaction"); +const customNativeCompaction = makeFakeCodexAdapter(customCompactionDriver); +const customSlashCompaction = makeFakeCodexAdapter(customCompactionDriver); +const unsupportedCompaction = makeFakeCodexAdapter(customCompactionDriver); +const declaredCompaction = makeProviderServiceLayer({ + registry: makeStaticInstanceRegistry([ + [ + nativeCompactionInstanceId, + { + ...customNativeCompaction.adapter, + compaction: { type: "native", start: customNativeCompaction.compactThread }, + }, + ], + [ + slashCompactionInstanceId, + { + ...customSlashCompaction.adapter, + compaction: { type: "slash-command", command: "/reduce-context" }, + }, + ], + [unsupportedCompactionInstanceId, unsupportedCompaction.adapter], + ]), +}); + +declaredCompaction.layer("ProviderService declared compaction", (it) => { + it.effect("starts declared native compaction instead of sending a prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-native-compaction"); + const requestId = MessageId.make("custom-native-request"); + yield* provider.startSession(threadId, { + providerInstanceId: nativeCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const compactedEventFiber = yield* provider.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.state.changed", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* advanceTestClock(50); + yield* provider.compactThread(threadId, undefined, requestId); + const compacted = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); + assert.equal(compacted.requestId, String(requestId)); + assert.equal(customNativeCompaction.compactThread.mock.calls.length, 1); + assert.equal(customNativeCompaction.sendTurn.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("sends the declared slash command as the compaction turn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-slash-compaction"); + const requestId = MessageId.make("custom-slash-request"); + const modelSelection = createModelSelection(slashCompactionInstanceId, "custom-model"); + yield* provider.startSession(threadId, { + providerInstanceId: slashCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const compactedEventFiber = yield* provider.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.state.changed", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const compactFiber = yield* provider + .compactThread(threadId, modelSelection, requestId) + .pipe(Effect.forkChild); + yield* advanceTestClock(50); + customSlashCompaction.emit({ + type: "turn.completed", + eventId: asEventId("custom-slash-completed"), + provider: customCompactionDriver, + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId: asTurnId(`turn-${threadId}`), + payload: { state: "completed" }, + }); + yield* Fiber.join(compactFiber); + const compacted = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); + assert.equal(compacted.requestId, String(requestId)); + assert.equal(customSlashCompaction.compactThread.mock.calls.length, 0); + assert.equal(customSlashCompaction.sendTurn.mock.calls.length, 1); + assert.equal(customSlashCompaction.sendTurn.mock.calls[0]?.[0].input, "/reduce-context"); + assert.deepEqual( + customSlashCompaction.sendTurn.mock.calls[0]?.[0].modelSelection, + modelSelection, + ); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("rejects compaction for adapters without a declared strategy", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-unsupported-compaction"); + yield* provider.startSession(threadId, { + providerInstanceId: unsupportedCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const failure = yield* provider.compactThread(threadId).pipe(Effect.flip); + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.message, "does not support context compaction"); + assert.equal(unsupportedCompaction.sendTurn.mock.calls.length, 0); + assert.equal(unsupportedCompaction.compactThread.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); +}); + const antigravityDriver = ProviderDriverKind.make("antigravity"); const replacementAntigravity = makeFakeCodexAdapter(antigravityDriver); const originalAntigravityInstanceId = ProviderInstanceId.make("antigravity-personal"); @@ -4271,6 +4399,7 @@ const getBinding = vi.fn((threadId: ThreadId) => const boundedListing = makeProviderServiceLayer({ directory: { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), getBinding, listThreadIds, @@ -4301,10 +4430,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); +const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); + describe("agent browser access", () => { const revokedThreads: Array = []; + const projectId = ProjectId.make("project-browser-access"); - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + projectOverride?: boolean, + ) => Effect.gen(function* () { const issued: Array = []; const codex = makeFakeCodexAdapter(); @@ -4318,6 +4454,50 @@ describe("agent browser access", () => { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); + const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getTurnStartMessage: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + return Option.some( + yield* decodeBrowserAccessThreadShell({ + id: threadId, + projectId, + title: "Browser access test", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }), + ); + }).pipe(Effect.orDie), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { @@ -4328,7 +4508,14 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })), + Layer.provide(projectionLayer), + Layer.provide( + ServerSettings.ServerSettingsService.layerTest({ + enableAgentBrowserAccess, + projectAgentBrowserAccessOverrides: + projectOverride === undefined ? {} : { [projectId]: projectOverride }, + }), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -4386,4 +4573,22 @@ describe("agent browser access", () => { assert.deepEqual(issued, [threadId]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off"); + revokedThreads.length = 0; + const issued = yield* startSessionWith(true, threadId, false); + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("requests an MCP credential when the project overrides browser access to on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-on"); + const issued = yield* startSessionWith(false, threadId, true); + assert.deepEqual(issued, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b853d779763c..2b2719faabd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -32,6 +32,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -72,8 +73,12 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); +/** How long a manual context compaction may run before ProviderService gives up on it. */ +const COMPACTION_COMPLETION_TIMEOUT = "10 minutes"; + interface PendingCompaction { readonly completion: Deferred.Deferred; readonly native: boolean; @@ -323,6 +328,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -714,8 +722,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentBrowserAccess), + const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + // Provider-only runtimes may omit orchestration. An unresolved project + // must not bypass an explicit browser override. + if (Option.isNone(projectionQuery)) return false; + const thread = yield* projectionQuery.value.getThreadShellById(threadId); + if (Option.isNone(thread)) return false; + return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + }, Effect.catch((cause) => Effect.logWarning( "Could not read server settings; withholding agent browser access for this session.", @@ -726,7 +745,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { + if (!(yield* agentBrowserAccessEnabled(threadId))) { // Revoke as well as clear. Every other prepare path reaches // `issueActiveMcpCredential`, which revokes the thread first, so // skipping it here would leave a previously issued bearer token valid @@ -1506,18 +1525,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": threadId, }); yield* McpSessionRegistry.touchActiveMcpThread(threadId); - const nativeCompaction = routed.adapter.compactThread; + const compaction = routed.adapter.compaction; + if (compaction === undefined) { + return yield* toValidationError( + "ProviderService.compactThread", + `Provider '${routed.adapter.provider}' does not support context compaction.`, + ); + } const completion = yield* Deferred.make(); const pending: PendingCompaction = { completion, - native: nativeCompaction !== undefined, + native: compaction.type === "native", providerInstanceId: routed.instanceId, requestId, earlyEvents: [], compactedEventObserved: false, expectedTurnId: undefined, }; - if (nativeCompaction !== undefined && timedOutNativeCompactions.has(threadId)) { + if (compaction.type === "native" && timedOutNativeCompactions.has(threadId)) { return yield* new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "thread/compact", @@ -1542,14 +1567,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( pendingCompactions.delete(threadId); } }); - const nativeCompletionTimeout = - routed.adapter.provider === "codex" || routed.adapter.provider === "opencode" - ? "10 minutes" - : "30 seconds"; const awaitNativeCompaction = (start: Effect.Effect) => start.pipe( Effect.andThen(Deferred.await(completion)), - Effect.timeout(nativeCompletionTimeout), + Effect.timeout(COMPACTION_COMPLETION_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => Effect.sync(() => { timedOutNativeCompactions.add(threadId); @@ -1559,7 +1580,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "thread/compact", - detail: `Provider did not report completed context compaction within ${nativeCompletionTimeout}.`, + detail: `Provider did not report completed context compaction within ${COMPACTION_COMPLETION_TIMEOUT}.`, cause, }), ), @@ -1568,24 +1589,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); const awaitFallbackCompaction = Deferred.await(completion).pipe( - Effect.timeout("10 minutes"), + Effect.timeout(COMPACTION_COMPLETION_TIMEOUT), Effect.mapError( (cause) => new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "turn/start", - detail: "Provider did not finish context compaction within 10 minutes.", + detail: `Provider did not finish context compaction within ${COMPACTION_COMPLETION_TIMEOUT}.`, cause, }), ), ); const terminal = yield* ( - nativeCompaction - ? awaitNativeCompaction(nativeCompaction(routed.threadId, modelSelection)) + compaction.type === "native" + ? awaitNativeCompaction(compaction.start(routed.threadId, modelSelection)) : Effect.gen(function* () { const turn = yield* sendTurn({ threadId, - input: routed.adapter.provider === "cursor" ? "/compress" : "/compact", + input: compaction.command, ...(modelSelection !== undefined ? { modelSelection } : {}), }).pipe( Effect.onError(() => @@ -1605,7 +1626,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (terminal !== "completed") { return yield* new ProviderAdapterRequestError({ provider: routed.adapter.provider, - method: nativeCompaction ? "thread/compact" : "turn/start", + method: compaction.type === "native" ? "thread/compact" : "turn/start", detail: `Context compaction ended with ${terminal}.`, }); } diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebfd..8b41bd3e518c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { it, assert } from "@effect/vitest"; -import { assertSome } from "@effect/vitest/utils"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentSessionImportSource, +} from "@t3tools/contracts"; +import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const importedSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "provider-session", + filePath: "/tmp/provider-session.jsonl", + size: 100, + mtimeMs: 1_000, + device: 1, + inode: 123, + birthtimeMs: 500, +} satisfies AgentSessionImportSource; + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( + persistenceLayer, runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, @@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts and reads thread bindings", () => + it.effect("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: initialThreadId, }); const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(initialThreadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId: initialThreadId, provider: ProviderDriverKind.make("codex"), }); @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } const threadIds = yield* directory.listThreadIds(); - assert.deepEqual(threadIds, [nextThreadId]); - })); + expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); + }), + ); - it("persists runtime fields and merges payload updates", () => + it.effect("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "starting", resumeCursor: { @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "running", runtimePayload: { @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL activeTurnId: "turn-1", }); } - })); + }), + ); + + it.effect("keeps the existing binding when an insert conflicts", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-insert-conflict"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + + yield* directory.upsert( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped", + resumeCursor: { threadId: "stale-provider-thread" }, + }, + { onConflict: "ignore" }, + ); + + const binding = yield* directory.getBinding(threadId); + expect(Option.getOrThrow(binding)).toMatchObject({ + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + }), + ); + + it.effect("records source files without replacing the current provider session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = { ...importedSource, providerSessionId: "record-source" }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-current"), + status: "running", + resumeCursor: { resume: "current-native-session" }, + runtimePayload, + }); + const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + + yield* directory.recordImportedTranscript({ threadId, source }); + const replacement = { ...source, size: 200, mtimeMs: 2_000 }; + yield* directory.recordImportedTranscript({ threadId, source: replacement }); + const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondFile }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...before, + runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, + }); + }), + ); + + it.effect("does not create a binding when recording an imported transcript", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:missing-source-binding"); + + yield* directory.recordImportedTranscript({ threadId, source: importedSource }); + + expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); + }), + ); + + it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const firstSource = { ...importedSource, providerSessionId: "stale-source" }; + const threadId = ThreadId.make( + `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, + ); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "original-native-session" }, + runtimePayload: { cwd: "/tmp/stale-source-project" }, + }); + yield* directory.recordImportedTranscript({ threadId, source: firstSource }); + const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondSource }); + + yield* repository.upsert({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + runtimePayload: { + cwd: "/tmp/stale-source-project", + importedTranscripts: [firstSource, secondSource], + }, + }); + }), + ); + + it.effect("reserves imported source records for the atomic recording method", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + for (const onConflict of ["update", "ignore"] as const) { + const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const binding = { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }; + yield* directory.upsert( + { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, + { onConflict }, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + cwd: "/tmp/project", + }); + + yield* directory.recordImportedTranscript({ threadId, source }); + yield* directory.upsert({ ...binding, runtimePayload: null }); + + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + importedTranscripts: [source], + }); + } + }), + ); - it("lists persisted bindings with metadata in oldest-first order", () => + it.effect("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }); - const bindings = yield* directory.listBindings(); + const bindings = (yield* directory.listBindings()).filter( + (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, + ); assert.deepEqual(bindings, [ { threadId: olderThreadId, provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL { threadId: newerThreadId, provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }, ]); - })); + }), + ); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const threadId = ThreadId.make("thread-provider-change"); + it.effect( + "resets adapterKey to the new provider when provider changes without an explicit adapter key", + () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-change"); - yield* runtimeRepository.upsert({ - threadId, - providerName: "claudeAgent", - providerInstanceId: null, - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-01-01T00:00:00.000Z", - resumeCursor: null, - runtimePayload: null, - }); + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); - yield* directory.upsert({ - provider: ProviderDriverKind.make("codex"), - threadId, - }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + }); - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "codex"); + assert.equal(runtime.value.adapterKey, "codex"); + } + }), + ); - it("rehydrates persisted mappings across layer restart", () => + it.effect("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(threadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId, provider: ProviderDriverKind.make("codex"), }); @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); NodeFS.rmSync(tempDir, { recursive: true, force: true }); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 253a954d2102..29ec8d2ed168 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) { const existing = yield* repository .getByThreadId({ threadId: binding.threadId }) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { }); } yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) + .upsert( + { + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }, + options, + ) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( + input, + ) => + repository + .recordImportedTranscript(input) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1777544d8fb0..4998f710c29b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -217,9 +217,11 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: (threadId) => Effect.succeed( input.readModel.threads.find((thread) => thread.id === threadId) diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 73049ad01c30..bb592a0a6fc8 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -17,7 +17,6 @@ import { make, resolveProviderCatalog, type ModelManifestData, - manifestUpdatedAtMs, encodeManifestCache, } from "./ModelManifest.ts"; @@ -382,7 +381,6 @@ describe("ModelManifest service", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; - assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); const cachePath = path.join(config.stateDir, "model-manifest.json"); // A cache of the manifest as it was before the release edited it. The // fetch time is irrelevant: the remote may be unreachable now, so diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c3cb36566c02..67a7334f613d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -138,7 +138,7 @@ export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ -export function manifestUpdatedAtMs(manifest: ModelManifestData): number { +function manifestUpdatedAtMs(manifest: ModelManifestData): number { if (manifest.updatedAt === undefined) return 0; const parsed = Date.parse(manifest.updatedAt); return Number.isNaN(parsed) ? 0 : parsed; diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837d..7d6bbe61a2aa 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..77c0c6c2dc88 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 350d8c73c150..320aa332c937 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { - it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( - "identifies the %s harness and describes media embedding", - (harness) => { - const instructions = buildRuntimeInstructions({ harness }); - expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); - expect(instructions).toContain("embed images and videos"); - expect(instructions).toContain("Markdown with absolute file paths"); - expect(instructions).not.toContain("undefined"); - }, - ); - it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 0e4d696335b0..c9b62fd79525 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -27,6 +27,21 @@ import type * as Stream from "effect/Stream"; export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; +/** + * How ProviderService runs manual context compaction for an adapter. + * Native adapters expose a start call and must emit a compacted thread state + * when they finish. Slash-command adapters get the command sent as a turn. + */ +export type ProviderCompaction = + | { + readonly type: "native"; + readonly start: ( + threadId: ThreadId, + modelSelection?: ProviderSendTurnInput["modelSelection"], + ) => Effect.Effect; + } + | { readonly type: "slash-command"; readonly command: `/${string}` }; + export interface ProviderAdapterCapabilities { /** * Declares whether changing the model on an existing session is supported. @@ -70,10 +85,8 @@ export interface ProviderAdapterShape { input: ProviderSendTurnInput, ) => Effect.Effect; - readonly compactThread?: ( - threadId: ThreadId, - modelSelection?: ProviderSendTurnInput["modelSelection"], - ) => Effect.Effect; + /** Omitted when this adapter does not support manual context compaction. */ + readonly compaction?: ProviderCompaction; /** * Interrupt an active turn. diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..9dbafd3e804e 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + AgentSessionImportSource, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; +export interface ProviderSessionDirectoryUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + export interface ProviderSessionDirectoryShape { readonly upsert: ( binding: ProviderRuntimeBinding, + options?: ProviderSessionDirectoryUpsertOptions, ) => Effect.Effect; + /** Record an imported file without changing the current provider session. */ + readonly recordImportedTranscript: (input: { + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 03189018bd31..7f065caa4f6a 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER, @@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "links the user's global skill directories into the profile without touching real content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + // Only the skill directories are shared; the rest of the profile stays private. + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); + + // A stale link is repointed; a real directory the user placed there is kept. + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + }), + ); + it.effect("rewrites the GCP block on every launch and never stores the API key", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index dd55df5973ba..8e0040ae0c3e 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,4 +1,6 @@ import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. +import * as NodeFSP from "node:fs/promises"; // @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. import * as NodePath from "node:path"; @@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import { + antigravityUserSkillDirectories, + resolveAntigravityUserHome, +} from "./Drivers/AntigravitySkills.ts"; export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = "Open the following link to authenticate the ACP server: "; @@ -219,6 +225,56 @@ function antigravityEnvironment( }; } +/** + * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points + * at the private profile. Link the two skill directories back to the user's + * real `~/.gemini` so global skills load, while MCP servers, hooks, and + * credentials stay isolated. Best effort: a link that cannot be made only + * costs global skills, never the session. A real directory at the link path + * is the user's own content and is left alone. + */ +const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { + readonly profileDirectory: string; + readonly userHome: string; + readonly platform: NodeJS.Platform; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const links = antigravityUserSkillDirectories(path, input.profileDirectory); + const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); + for (const [link, target] of [ + [links[0], targets[0]], + [links[1], targets[1]], + ] as const) { + yield* Effect.gen(function* () { + const existing = yield* fs.readLink(link).pipe( + Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (existing === target) return; + if (existing !== undefined) { + yield* fs.remove(link); + } + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* Effect.tryPromise(() => + NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), + ); + }).pipe( + // A non-symlink at the link path fails `readLink`; anything else is a + // filesystem refusal. Both leave the profile usable. + Effect.catch((error) => + Effect.logWarning("Antigravity user skills are not linked into the profile.", { + link, + target, + error, + }), + ), + ); + } +}); + /** Prepares a private profile without reading or copying Google credentials. */ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { readonly profileDirectory: string; @@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( readonly runtimeExecutablePath?: string; readonly platform?: NodeJS.Platform; readonly auth?: AntigravityAuthConfig; + /** Home the agent expands `~` against. Defaults to the launch environment's. */ + readonly userHome?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = input.platform ?? (yield* HostProcessPlatform); + const userHome = + input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; @@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( authSupportError("The Antigravity profile settings could not be written."), ), ); + yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); return profile; }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 60e3402eed42..5d08f317b3b1 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { withPrismProvider } from "../fork/prism/PrismProviderDriver.ts"; // fork: prism import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -47,8 +48,8 @@ export type BuiltInDriversEnv = * iteration order has no functional effect on instance lookup. */ export const BUILT_IN_DRIVERS: ReadonlyArray> = [ - CodexDriver, - ClaudeDriver, + withPrismProvider(CodexDriver), // fork: prism + withPrismProvider(ClaudeDriver), // fork: prism CursorDriver, GrokDriver, OpenCodeDriver, diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 94fc376f227d..2ceaf21996bf 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index a09c94b61f60..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -240,6 +240,12 @@ export function npmGlobalPrefixFromCommandPath( if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) { return null; } + // Mise's npm backend uses a global-looking layout inside a tool version. + // Globals under its Node installation still belong to npm. + const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1]; + if (miseTool && miseTool !== "node") { + return null; + } return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); } @@ -426,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 4e6d1b261947..fa567d75cf8a 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 51d8f74bbc45..000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; - -describe("azure devops viewer permissions", () => { - it("offers every action to whoever is signed in, because Azure names no permission", () => { - // The same answer for a viewer who can write, one who can only read, and an author with read - // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, - // and an unknown permission is granted rather than guessed away. Azure refuses the ones it - // will not allow, at the moment they are taken, in words this could not have written. - expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // False because the host itself cannot post one, not because this viewer may not. - comment: false, - resolve: false, - verdicts: [], - // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. - requestReviewers: true, - }); - }); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8461e57d5685..ae586ee0a61c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -56,7 +56,7 @@ const CAPABILITIES: PullRequestCapabilities = { * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ -export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq additions: 0, deletions: 0, createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, // Azure keeps labels on work items rather than on the pull request. diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 47d41eeee6d9..3b5b93d11c46 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { deletions: diffStat.deletions, changedFiles: diffStat.changedFiles, body: pullRequest.body, - mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + mergedAt: null, + closedAt: null, reviewers: pullRequest.reviewers, checks, // Bitbucket publishes no per-repository list of allowed strategies, so the ones it diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 1e6ca0ed43a6..f61b7c3233f6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", baseRefName: "main", headRefName: "feat/summary", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }), ); @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", headBranch: "feat/summary", baseBranch: "main", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }); expect(mockedGetPullRequest).toHaveBeenCalledOnce(); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d5c2062c08c..89a50f93ced7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { baseBranch: summary.baseRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 22028ced5ddf..5f1aba8ba9b6 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly additions: number; readonly deletions: number; readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { readonly state: PullRequestState; /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a8d3dc2fdeaf..2229a4f652c0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, })), ); @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, + closedAt: detail.closedAt, + mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 5c79a543fb46..59049500729d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -39,6 +39,7 @@ import { type ProjectionSnapshotQueryShape, } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { + isAgentActivityPublishingEnabledValue, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, @@ -137,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish start intents, streaming content, or non-awareness activity events", () => { + it("does not publish imported, start-intent, streaming, or non-awareness events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -146,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: "thread-1" as ThreadId, occurredAt: now, + metadata: {}, }; expect( @@ -201,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } as unknown as OrchestrationEvent), ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { @@ -220,9 +252,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }); it("requires an explicit opt-in before publishing agent activity", () => { - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); - expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); + expect(isAgentActivityPublishingEnabledValue(null)).toBe(false); + expect(isAgentActivityPublishingEnabledValue("false")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("TRUE")).toBe(false); + expect(isAgentActivityPublishingEnabledValue("true")).toBe(true); }); it("redacts failed activity details and caps other relay detail", () => { @@ -398,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ); - it.effect("keeps the orchestration listener armed until relay config is installed", () => + it.effect("keeps the listener armed and skips imported thread work", () => Effect.scoped( Effect.gen(function* () { const events = yield* Queue.unbounded(); const threadShellRequested = yield* Deferred.make(); + const releaseThreadShell = yield* Deferred.make(); + const threadShellRequests: Array = []; + let fetchCallCount = 0; const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; const threadId = "thread-1" as ThreadId; + const importedThreadId = "import:codex:session-1" as ThreadId; const environmentId = "env-1" as EnvironmentId; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + fetchCallCount += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + const project = { id: projectId, title: "T3 Code", @@ -471,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, - projects: [project], - threads: [thread], + projects: [], + threads: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => - Deferred.succeed(threadShellRequested, undefined).pipe( - Effect.ignore, - Effect.as(Option.some(thread)), - ), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.gen(function* () { + threadShellRequests.push(requestedThreadId); + if (requestedThreadId !== threadId) return Option.none(); + yield* Deferred.succeed(threadShellRequested, undefined); + yield* Deferred.await(releaseThreadShell); + return Option.some(thread); + }), getProjectShellById: () => Effect.succeed(Option.some(project)), } as unknown as ProjectionSnapshotQueryShape; @@ -509,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Effect.gen(function* () { const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; yield* relay.start(); - yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); yield* Queue.offer(events, { - type: "thread.activity-appended", + type: "thread.created", sequence: 1, + eventId: "evt-import-created", + commandId: CommandId.make("cmd-import-created"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.settled", + sequence: 2, + eventId: "evt-import-settled", + commandId: CommandId.make("cmd-import-settled"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 3, eventId: "evt-1", commandId: CommandId.make("cmd-1"), aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { @@ -530,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } as unknown as OrchestrationEvent); yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + expect(threadShellRequests).toEqual([threadId]); + expect(fetchCallCount).toBe(0); + yield* Deferred.succeed(releaseThreadShell, undefined); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -690,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..8d7b9e98361e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { } export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + if (event.metadata.historyImport === true) { + return false; + } switch (event.type) { case "thread.message-sent": case "thread.turn-start-requested": @@ -102,10 +105,6 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } -export function isAgentActivityPublishingEnabled(value: string | null): boolean { - return isAgentActivityPublishingEnabledValue(value); -} - export function resolveAgentActivityPublishingStartupState(input: { readonly relayConfigured: boolean; readonly publishEnabled: boolean; @@ -322,7 +321,7 @@ export const make = Effect.gen(function* () { }); const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( - Effect.map(isAgentActivityPublishingEnabled), + Effect.map(isAgentActivityPublishingEnabledValue), ); const makeRelayClient = (relayConfig: { diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 000000000000..032832dd4869 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/resourceTelemetry/Model.test.ts b/apps/server/src/resourceTelemetry/Model.test.ts index 94690e3967bc..6f759ac9744f 100644 --- a/apps/server/src/resourceTelemetry/Model.test.ts +++ b/apps/server/src/resourceTelemetry/Model.test.ts @@ -39,7 +39,7 @@ function nativeSnapshot( sequence = 1, ): ResourceMonitorSnapshotEvent { return { - version: 2, + version: 3, type: "snapshot", sequence, sampledAtUnixMs, diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 8a595bc8b480..7d365d0c0bac 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,6 +1,5 @@ import type { HostPowerSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -9,12 +8,9 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import { - NativeTelemetryRequestTimedOut, - NativeTelemetryStreamClosed, canCommandNativeTelemetrySidecar, canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - nativeTelemetrySupervisorFailureMessage, retainRecentNativeTelemetryFailures, resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, @@ -82,44 +78,6 @@ describe("canCommandNativeTelemetrySidecar", () => { }); }); -describe("NativeTelemetryRequestTimedOut", () => { - it("models history and sample request deadlines without a fabricated cause", () => { - const historyTimeout = new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: 15_000, - }); - const sampleTimeout = new NativeTelemetryRequestTimedOut({ - operation: "sampleNow", - timeoutMs: 5_000, - }); - - expect(historyTimeout.message).toBe( - "Resource monitor 'readHistory' request timed out after 15000ms.", - ); - expect(sampleTimeout.message).toBe( - "Resource monitor 'sampleNow' request timed out after 5000ms.", - ); - expect("cause" in historyTimeout).toBe(false); - expect("cause" in sampleTimeout).toBe(false); - }); -}); - -describe("native telemetry supervisor failures", () => { - it("distinguishes a closed event stream from a process exit", () => { - expect(new NativeTelemetryStreamClosed().message).toBe( - "Resource monitor event stream closed unexpectedly.", - ); - }); - - it("keeps defect details out of the caller-visible health message", () => { - const secret = "credential=do-not-expose"; - const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); - - expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); - expect(message).not.toContain(secret); - }); -}); - describe("retainRecentNativeTelemetryFailures", () => { it("expires old failures so an isolated crash restarts from the initial backoff", () => { expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 232079d9dc9b..9ddc72d61ebd 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -5,6 +5,7 @@ import type { ResourceMonitorEvent, ResourceMonitorExternalProcess, ResourceMonitorHelloEvent, + ResourceMonitorProcessTableEntry, ResourceMonitorSnapshotEvent, ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; @@ -44,6 +45,7 @@ const BATTERY_SAMPLE_INTERVAL_MS = 5_000; const CONSTRAINED_SAMPLE_INTERVAL_MS = 15_000; const HANDSHAKE_TIMEOUT = Duration.seconds(5); const SAMPLE_REQUEST_TIMEOUT = Duration.seconds(5); +const PROCESS_TABLE_REQUEST_TIMEOUT = Duration.seconds(5); const HISTORY_REQUEST_TIMEOUT = Duration.seconds(15); const INITIAL_RESTART_DELAY = Duration.millis(500); const MAX_RESTART_DELAY = Duration.seconds(10); @@ -73,10 +75,10 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( +class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( "NativeTelemetryRequestTimedOut", { - operation: Schema.Literals(["readHistory", "sampleNow"]), + operation: Schema.Literals(["processTable", "readHistory", "sampleNow"]), timeoutMs: Schema.Number, }, ) { @@ -131,7 +133,7 @@ export class NativeTelemetryExited extends Schema.TaggedErrorClass()( +class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( "NativeTelemetryStreamClosed", {}, ) { @@ -192,6 +194,10 @@ export class NativeTelemetryClient extends Context.Service< snapshot: HostPowerSnapshot, ) => Effect.Effect; readonly sampleNow: Effect.Effect; + readonly processTable: Effect.Effect< + ReadonlyArray, + NativeTelemetryClientError + >; readonly retry: Effect.Effect; readonly health: Effect.Effect; readonly subscribeHealth: Effect.Effect< @@ -340,10 +346,6 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { - return "Resource monitor supervisor stopped unexpectedly."; -} - export function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, @@ -386,6 +388,12 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu const pendingSamples = yield* Ref.make( new Map>(), ); + const pendingProcessTables = yield* Ref.make( + new Map< + string, + Deferred.Deferred, NativeTelemetryClientError> + >(), + ); const pendingHistories = yield* Ref.make(new Map()); const snapshots = yield* PubSub.sliding(8); const healthChanges = yield* PubSub.sliding(4); @@ -403,10 +411,14 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu const failPending = (error: NativeTelemetryClientError) => Effect.gen(function* () { const samples = yield* Ref.getAndSet(pendingSamples, new Map()); + const processTables = yield* Ref.getAndSet(pendingProcessTables, new Map()); const histories = yield* Ref.getAndSet(pendingHistories, new Map()); yield* Effect.forEach(samples.values(), (deferred) => Deferred.fail(deferred, error), { discard: true, }); + yield* Effect.forEach(processTables.values(), (deferred) => Deferred.fail(deferred, error), { + discard: true, + }); yield* Effect.forEach( histories.values(), (request) => Deferred.fail(request.deferred, error), @@ -485,6 +497,21 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu } } }); + case "processTable": + return Ref.modify(pendingProcessTables, (pending) => { + const next = new Map(pending); + const deferred = next.get(event.requestId); + next.delete(event.requestId); + return [Option.fromUndefinedOr(deferred), next] as const; + }).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (deferred) => Deferred.succeed(deferred, event.processes), + }), + ), + Effect.asVoid, + ); case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); @@ -734,7 +761,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ...current, status: "unavailable" as const, hello: Option.none(), - lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + lastError: Option.some("Resource monitor supervisor stopped unexpectedly."), })).pipe( Effect.andThen(publishHealth), Effect.andThen( @@ -940,6 +967,60 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ); }); + const processTable: NativeTelemetryClient["Service"]["processTable"] = Effect.gen(function* () { + const current = yield* Ref.get(state); + if (!canCommandNativeTelemetrySidecar(current.status, Option.isSome(current.handle))) { + return yield* new NativeTelemetryUnavailable({ + reason: Option.getOrElse(current.lastError, () => "sidecar is not running"), + }); + } + + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => new NativeTelemetryCommandFailed({ operation: "createRequestId", cause }), + ), + ); + const deferred = yield* Deferred.make< + ReadonlyArray, + NativeTelemetryClientError + >(); + yield* Ref.update(pendingProcessTables, (pending) => { + const next = new Map(pending); + next.set(requestId, deferred); + return next; + }); + return yield* writeCommand(Option.getOrThrow(current.handle), { + version: RESOURCE_MONITOR_PROTOCOL_VERSION, + type: "processTable", + requestId, + }).pipe( + Effect.andThen( + Deferred.await(deferred).pipe( + Effect.timeoutOption(PROCESS_TABLE_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new NativeTelemetryRequestTimedOut({ + operation: "processTable", + timeoutMs: Duration.toMillis(PROCESS_TABLE_REQUEST_TIMEOUT), + }), + ), + onSome: Effect.succeed, + }), + ), + ), + ), + Effect.ensuring( + Ref.update(pendingProcessTables, (pending) => { + const next = new Map(pending); + next.delete(requestId); + return next; + }), + ), + ); + }); + const health = currentHealth; return NativeTelemetryClient.of({ @@ -961,6 +1042,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu setExternalProcesses, setHostPowerState, sampleNow, + processTable, retry: Ref.get(state).pipe( Effect.flatMap((current) => !canRequestNativeTelemetryRetry(current.status, Option.isSome(current.handle)) @@ -1014,6 +1096,11 @@ export const layerTest = ( reason: "No resource monitor sample was configured for this test.", }), ), + processTable: Effect.fail( + new NativeTelemetryUnavailable({ + reason: "No resource monitor process table was configured for this test.", + }), + ), retry: Effect.succeed(false), health, subscribeHealth: diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts index a96423607baf..9c371078332d 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts @@ -82,7 +82,7 @@ function nativeSnapshot(input: { }), ]; return { - version: 2, + version: 3, type: "snapshot", sequence: input.sequence, sampledAtUnixMs: input.sampledAtUnixMs, @@ -497,7 +497,7 @@ describe("ResourceTelemetry", () => { const nativeHealth = yield* Ref.make({ status: "healthy", hello: Option.some({ - version: 2, + version: 3, type: "hello", sidecarVersion: "0.1.0", sidecarPid: 9_000, diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts index 879c83d86dee..fff4588c8468 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetryHistory.test.ts @@ -64,7 +64,7 @@ function snapshot( }), ]; return { - version: 2, + version: 3, type: "snapshot", sequence, sampledAtUnixMs, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bebde7eded76..64bfea1b9805 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -36,8 +36,10 @@ import { type ProviderInstallState, ProviderSetupError, ResolvedKeybindingRule, + type ServerLifecycleStreamEvent, ThreadId, TurnId, + UsageLimitSourceId, WS_METHODS, WsRpcGroup, EditorId, @@ -91,6 +93,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationShellSnapshot), ); +const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -127,6 +130,7 @@ import { AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -161,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -495,6 +500,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; providerInstanceRegistry?: Partial; @@ -513,6 +519,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; @@ -750,8 +759,9 @@ const buildAppUnderTest = (options?: { }), Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), - streamChanges: Stream.empty, + streamChanges: Stream.make([]), refresh: Effect.void, + ...options?.layers?.usageLimitSources, }), ), ), @@ -785,6 +795,13 @@ const buildAppUnderTest = (options?: { managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), ), ), Layer.provide( @@ -829,7 +846,8 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide( + Layer.provide([ + HostResources.layer, Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ readHistory: (input) => Effect.succeed({ @@ -844,7 +862,7 @@ const buildAppUnderTest = (options?: { error: Option.none(), }), }), - ), + ]), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => @@ -973,6 +991,7 @@ const buildAppUnderTest = (options?: { }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.succeed([]), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -5332,6 +5351,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps agent session import project failures structured over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const projectId = ProjectId.make("missing-import-project"); + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), + ), + ); + + assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); + if (error._tag === "AgentSessionImportProjectNotFoundError") { + assert.equal(error.projectId, projectId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns scanner skip counts over websocket rpc", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-codex-", + }); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-workspace-", + }); + const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); + const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); + yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + transcriptPath, + encodeTestJson({ + timestamp: "2026-08-31T12:00:00.000Z", + type: "session_meta", + payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, + }), + ); + yield* fileSystem.utimes(transcriptPath, 0, 0); + + const projectId = ProjectId.make("agent-import-rpc-project"); + const project = { + id: projectId, + title: "Agent import RPC", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-31T12:00:00.000Z", + updatedAt: "2026-08-31T12:00:00.000Z", + } as const; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === projectId ? Option.some(project) : Option.none(), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const scan = yield* client[WS_METHODS.agentSessionsScan]({}); + assert.deepEqual( + scan.candidates.map((candidate) => candidate.path), + [workspaceRoot], + ); + return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); + }), + ), + ); + + assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads Codex thread feedback through websocket rpc", () => Effect.gen(function* () { const input = { @@ -6040,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns cached whole-host resources over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const [first, second] = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all( + [ + client[WS_METHODS.serverGetHostResources]({}), + client[WS_METHODS.serverGetHostResources]({}), + ], + { concurrency: "unbounded" }, + ), + ), + ); + assert.deepEqual(first, second); + assert.isAtLeast(first.sampledAt, 0); + assert.isAbove(first.cpuCount, 0); + assert.isAbove(first.totalMemoryBytes, 0); + assert.isAtLeast(first.availableMemoryBytes, 0); + assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => + Effect.gen(function* () { + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(commandCalls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + + "Pages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* hostResources.read, first); + assert.equal(yield* Ref.get(commandCalls), 1); + }).pipe(TestClock.withLive), + ); + + it.effect("retries host sampling immediately after its caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return ( + "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + ); + }), + }), + ), + ); + const firstRead = yield* hostResources.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(firstRead); + const recovered = yield* hostResources.read; + assert.equal(recovered.availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(commandCalls), 2); + }).pipe(TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -6135,10 +6339,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => - Effect.gen(function* () { - const nextProviders = [ - { + it.effect.each([false, true])( + "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)", + (hasLimits) => + Effect.gen(function* () { + const nextProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(hasLimits + ? { + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + } + : {}), + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([]), + streamChanges: Stream.succeed(nextProviders), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: hasLimits + ? [ + { + ...nextProviders[0], + slashCommands: [ + { + name: "usage-limits", + description: "Show this provider's usage limits", + }, + ], + }, + ] + : nextProviders, + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it", + () => + Effect.gen(function* () { + const codex = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), enabled: true, @@ -6150,43 +6438,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => { models: [], slashCommands: [], skills: [], - }, - ] as const; - - yield* buildAppUnderTest({ - layers: { - keybindings: { - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - streamChanges: Stream.empty, + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }], }, - providerRegistry: { - getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(nextProviders), + }; + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]), + }, }, - }, - }); + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), - ), - ); + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); - const [first, second] = Array.from(events); - assert.equal(first?.type, "snapshot"); - if (first?.type === "snapshot") { - assert.deepEqual(first.config.providers, []); - } - assert.deepEqual(second, { - version: 1, - type: "providerStatuses", - payload: { providers: nextProviders }, - }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { providers: [{ ...codex, version: "1.0.1" }] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", + () => + Effect.gen(function* () { + const codex = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-04-11T00:00:00.000Z", + accounts: [ + { + id: "work", + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + }, + ], + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + // The registry emits no change: only the source refresh can carry it. + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.empty, + }, + usageLimitSources: { + current: Effect.succeed([]), + // Replay the empty snapshot, then a later refresh, as the live stream does. + streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + ...codex, + slashCommands: [ + { name: "usage-limits", description: "Show this provider's usage limits" }, + ], + }, + ], + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect( @@ -6239,6 +6613,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const streamSubscribed = yield* Deferred.make(); + const snapshotPublished = yield* Deferred.make(); + const bootstrapProjectId = ProjectId.make("project-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-bootstrap"); + const snapshotEvent = { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "pending" as const, + }, + }; + const gapEvent = { + version: 1 as const, + sequence: 2, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "complete" as const, + bootstrapProjectId, + bootstrapThreadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }, + }; + const sentinelEvent = { + version: 1 as const, + sequence: 3, + type: "ready" as const, + payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, + }; + const liveStream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub); + yield* Deferred.succeed(streamSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: PubSub.publish(pubsub, gapEvent).pipe( + Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), + Effect.as({ sequence: 1, events: [snapshotEvent] }), + ), + stream: liveStream, + }, + }, + }); + + yield* Effect.gen(function* () { + yield* Deferred.await(snapshotPublished); + yield* Deferred.await(streamSubscribed); + yield* PubSub.publish(pubsub, sentinelEvent); + }).pipe(Effect.forkScoped); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + if (first?.type !== "welcome") { + assert.fail("expected the pending bootstrap event"); + } + assert.equal(first.payload.bootstrapStatus, "pending"); + assert.equal(second?.type, "welcome"); + assert.equal(second?.sequence, 2); + if (second?.type !== "welcome") { + assert.fail("expected the bootstrap completion event"); + } + assert.equal(second.payload.bootstrapStatus, "complete"); + assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); + assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); + assert.equal(second.payload.bootstrapProjectCreated, true); + assert.equal(second.payload.bootstrapThreadCreated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..285ae403e197 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; +import { prismRoutesLayer } from "./fork/prism/PrismHttpApi.ts"; // fork: prism import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; @@ -71,6 +72,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -115,6 +117,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -199,6 +202,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -277,6 +281,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + Layer.provideMerge(ThreadPullRequestReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -317,7 +322,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -353,7 +358,9 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)), + ), ), ), ); @@ -368,6 +375,7 @@ const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner. const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), + Layer.provide(NativeTelemetryLayerLive), ); const PreviewLayerLive = Layer.empty.pipe( @@ -539,6 +547,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), + prismRoutesLayer, // fork: prism otlpTracesProxyRouteLayer, assetRouteLayer, attachmentUploadRouteLayer, diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 2c95a6163acd..e46c2a8ca010 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, ), ), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { ); }, upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure }), ), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -596,6 +601,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -754,6 +762,7 @@ for (const scenario of [ Effect.sync(() => { upserts.push(binding); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -827,6 +836,7 @@ for (const preparedStatus of [ if (binding.status !== "starting" || sends.length === 0) return; yield* Deferred.succeed(cleared, undefined); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.sync(() => { binding = next; }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b53e1843c226..0426df44bcea 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -13,17 +13,10 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; @@ -48,7 +41,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -60,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + + pulled.length = 0; + yield* ServerRuntimeStartup.autoPullProjects( + [project("/inherited", false), project("/opted-out"), project("/dirty", false)], + { + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + assert.deepStrictEqual(pulled, ["/inherited"]); }), ); @@ -110,56 +113,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - const countsStarted = yield* Deferred.make(); - - yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getUserInputActivity: () => Effect.die("unused"), - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getCounts: () => - Deferred.succeed(countsStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCounts)), - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadRuntimeContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - searchThreads: () => Effect.succeed({ matches: [] }), - }), - Effect.provideService(AnalyticsService.AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - - // The heartbeat is forked, so the caller is already back here while - // getCounts is still parked. Awaiting countsStarted proves the forked - // work really ran; releaseCounts staying incomplete proves the caller - // never waited for it. - yield* Deferred.await(countsStarted); - assert.equal(yield* Deferred.isDone(releaseCounts), false); - }), - ), -); - it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( @@ -182,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -201,7 +155,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -210,9 +167,11 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -236,13 +195,26 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }); }); -it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => +it.effect.each([ + { existing: false, machineModel: null, projectModel: null }, + { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, +])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => Effect.gen(function* () { + const machineSelection = machineModel + ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } + : null; + const projectSelection = projectModel + ? { instanceId: ProviderInstanceId.make("codex"), model: projectModel } + : null; const dispatchCalls = yield* Ref.make< ReadonlyArray<{ readonly type: string; @@ -251,6 +223,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -264,12 +237,28 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + existing + ? Option.some({ + id: ProjectId.make("existing-project"), + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: projectSelection, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) + : Option.none(), + ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -292,19 +281,81 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapProjectCreated, !existing); + assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), - ["project.create", "thread.create"], + existing ? ["thread.create"] : ["project.create", "thread.create"], ); - assert.equal("defaultModelSelection" in commands[0]!, false); + if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); assert.deepStrictEqual( - commands[1]?.modelSelection, - ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + commands.at(-1)?.modelSelection, + projectSelection ?? + machineSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, ); }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused thread replay stats"), + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadId, undefined); + assert.equal(targets.bootstrapThreadCreated, undefined); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -317,6 +368,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa const dispatchCalls = yield* Ref.make>([]); const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -333,9 +385,11 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -364,3 +418,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.fail("bootstrap failed"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap defect"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index a34d1bdbde91..6f8bbc053b0c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -9,6 +10,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -168,15 +170,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); @@ -202,8 +196,13 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const defaultModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, @@ -215,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); + nextThreadModelSelection = defaultModelSelection; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), @@ -224,45 +223,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, createdAt, }); + bootstrapProjectId = nextProjectId; + bootstrapProjectCreated = true; } else { nextProjectId = existingProject.value.id; + bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); + existingProject.value.defaultModelSelection ?? defaultModelSelection; } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapProjectId = nextProjectId; - bootstrapThreadId = createdThreadId; - } else { - bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; - } + yield* Effect.gen(function* () { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdThreadId = ThreadId.make(yield* randomUUID); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* randomUUID), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + modelSelection: nextThreadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapThreadId = createdThreadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThreadId.value; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), + ), + ); }); } return { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), } as const; }); +export const completeAutoBootstrapWelcome =
( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -709,12 +742,16 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -785,7 +822,11 @@ export const make = (options?: StartupOptions) => const reactorScope = yield* Scope.make("sequential"); const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.flatMap((snapshot) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)), + ), + ), Effect.catch((cause) => Effect.logWarning("Failed to load projects for automatic pull", { cause }), ), @@ -847,36 +888,31 @@ export const make = (options?: StartupOptions) => runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + + yield* Effect.logDebug( + "startup phase: publishing completed bootstrap welcome event", + { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + ...bootstrapCompletion, + }, ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; - } - - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); yield* lifecycleEvents.publish({ version: 1, type: "welcome", payload: { environment, ...welcomeBase, - ...bootstrapTargets, + ...bootstrapCompletion, }, }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), + }).pipe(Effect.ignoreCause({ log: true })), ), ); } @@ -922,7 +958,11 @@ export const make = (options?: StartupOptions) => lifecycleEvents.publish({ version: 1, type: "welcome", - payload: { environment, ...welcomeBase }, + payload: { + environment, + ...welcomeBase, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + }, }), ); yield* options?.activate ?? Effect.void; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..9769ebf0a5f6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -654,6 +656,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("skips a disabled provider instance when picking the text generation fallback", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // The Providers UI writes providerInstances only, so the legacy providers + // map decodes to defaults where codex is enabled and listed first. + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', + ); + + const settings = yield* serverSettings.getSettings; + + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("keeps unused providers disabled in existing sparse settings files", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -1102,4 +1122,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5f2550534883..84e978320a30 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -21,6 +21,7 @@ import { type UsageLimitSourceConfig, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -321,7 +322,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); + // Same precedence as isModelSelectionProviderEnabled: an explicit provider + // instance wins over the legacy providers map, which decodes to defaults + // (codex enabled) when the Providers UI has only written providerInstances. + const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { + const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; + return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); + }); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { return settings; diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f0cb52003029..24b28af13fe4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { }); assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); expect(mockRun).toHaveBeenCalledWith({ operation: "AzureDevOpsCli.execute", command: "az", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index cacdd1a3cd97..8e55d453b224 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), }); @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8a840c524eba..20a74cc8a5d7 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -62,6 +62,8 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -73,6 +75,8 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3f08e92e2c1e..f72259b677eb 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 49b2ea31a08c..85736a95c5dd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7faa2fe351ef..a025ce5ec800 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = baseRefName: "main", headRefName: "feature/merged", state: "merged", + mergedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1a20b587256a..74f08a9a9127 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8..c5f22fe3088f 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", target_branch: "main", source_branch: "feature/mr-threads", - state: "opened", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", source_project_id: 101, target_project_id: 100, source_project: { @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/mr-threads", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { target_branch: " main ", source_branch: " feature/mr-list ", state: "merged", + merged_at: "2026-08-23T11:00:00Z", }, ]), ), @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334..9f76a6182ce4 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665214..3cd442a6e169 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2ec1f9b9a228..28211c6b8509 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8ac682399e1d..24c0e49fd8f4 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 9e4f282e1c8a..822de1e02797 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 3b032e245bbc..0525260df51b 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index ab151821789a..92d3223267d7 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -33,26 +33,6 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); -it("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - it.layer(NodeServices.layer)("telemetry identity", (it) => { it.effect("uses the persisted anonymous id when provider identities are absent", () => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066dff..15d3bf13f782 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -23,7 +23,7 @@ const ClaudeJsonSchema = Schema.Struct({ export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( "TelemetryIdentityReadError", { source: TelemetryIdentitySource, @@ -36,7 +36,7 @@ export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( "TelemetryIdentityDecodeError", { source: Schema.Literals(["codex", "claude"]), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index deea39631788..f631992e7ae3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,9 +7,15 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -23,11 +29,16 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -210,11 +221,18 @@ interface CreateManagerOptions { readonly childCommand: string | null; readonly processIds: ReadonlyArray; }>; + processTable?: Effect.Effect< + ReadonlyArray<{ readonly pid: number; readonly ppid: number; readonly name: string }>, + never + >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -252,6 +270,7 @@ const createManager = ( ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), + ...(options.processTable !== undefined ? { processTable: options.processTable } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), @@ -259,6 +278,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1176,6 +1198,96 @@ it.layer( }), ); + it("calculates snapshot failure backoff and success reset delays", () => { + assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 0), 1_000); + assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 1), 2_000); + assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 2), 4_000); + assert.equal(TerminalManager.subprocessSnapshotPollDelayMs(1_000, 30), 60_000); + }); + + it.effect("uses process snapshots from the resource monitor", () => + Effect.gen(function* () { + let snapshotCalls = 0; + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + processTable: Effect.sync(() => { + snapshotCalls += 1; + return [{ pid: 100, ppid: 9000, name: "ping.exe" }]; + }), + }).pipe(Effect.provide(withHostPlatform("win32"))); + + yield* manager.open(openInput()); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && event.hasRunningSubprocess && event.label === "ping", + ), + ), + "1200 millis", + ); + expect(snapshotCalls).toBeGreaterThan(0); + }), + ); + + it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => + Effect.gen(function* () { + const fallbackCalls: Array = []; + const processRunner: ProcessRunner.ProcessRunner["Service"] = { + run: () => + Clock.currentTimeMillis.pipe( + Effect.map((now) => { + fallbackCalls.push(now); + return { + stdout: " 100 9000 vim", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrInvalidUtf8: false, + stdoutInvalidUtf8: false, + stderrTruncated: false, + }; + }), + ), + }; + + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + processTable: Effect.fail("sidecar unavailable").pipe( + Effect.mapError((cause) => cause as never), + ), + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provide(withHostPlatform("linux")), + ); + + yield* manager.open(openInput()); + // The fallback data is still applied while the sidecar is down. + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ), + ), + "1200 millis", + ); + + yield* waitFor( + Effect.sync(() => fallbackCalls.length >= 4), + "2000 millis", + ); + // Four snapshots at the 20 ms base cadence would span ~60 ms. Backoff + // (40 + 80 + 160 ms) stretches the same four snapshots past 150 ms, so + // a stalled sidecar no longer hot-loops the spawned fallback. + const spanMs = fallbackCalls[3]! - fallbackCalls[0]!; + expect(spanMs).toBeGreaterThan(150); + }), + ); + it.effect("caps persisted history to configured line limit", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(3); @@ -1736,6 +1848,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1822,6 +1954,382 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + const updateSecret = (value: string) => + serverSettings.updateSettings({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index f04e3c2d897b..10143b7be20b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -26,11 +28,15 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalResizeInput, + type ResourceMonitorProcessTableEntry, type TerminalRestartInput, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -52,13 +58,20 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; +import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; export { @@ -69,6 +82,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -79,6 +94,7 @@ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024; const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; +const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; @@ -86,12 +102,14 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", { cause: Schema.optional(Schema.Defect()), - command: Schema.Literals(["powershell", "ps"]), + command: Schema.Literals(["powershell", "ps", "resource-monitor"]), exitCode: Schema.optional(Schema.NullOr(Schema.Number)), timedOut: Schema.optional(Schema.Boolean), stdoutTruncated: Schema.optional(Schema.Boolean), @@ -626,6 +644,13 @@ interface TerminalProcessTableSnapshot { readonly commandById: ReadonlyMap; } +export function subprocessSnapshotPollDelayMs( + pollIntervalMs: number, + failureCount: number, +): number { + return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); +} + function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); @@ -645,15 +670,15 @@ function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { return { childrenByParent, commandById }; } -function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot { +function processTableSnapshotFromProcesses( + processes: ReadonlyArray, +): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); - for (const line of stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); + for (const process of processes) { + const { pid, ppid: parentPid, name } = process; if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - commandById.set(pid, nameRaw?.trim() ?? ""); + commandById.set(pid, name.trim()); const children = childrenByParent.get(parentPid) ?? []; children.push(pid); childrenByParent.set(parentPid, children); @@ -748,14 +773,11 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { + const processRunner = yield* ProcessRunner.ProcessRunner; const command = 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner .run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", command], timeout: "1500 millis", @@ -765,16 +787,10 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps }) .pipe( Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - cause, - command: "powershell", - }), + (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - // Not authoritative: an empty or partial table would mark every terminal - // idle and clear its registered process ids. Failing skips the tick. return yield* new TerminalSubprocessCheckError({ command: "powershell", exitCode: result.code, @@ -782,7 +798,15 @@ const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnaps stdoutTruncated: result.stdoutTruncated, }); } - return parseWindowsProcessTable(result.stdout); + const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { + const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) + ? [{ pid, ppid, name }] + : []; + }); + return processTableSnapshotFromProcesses(processes); }, ); @@ -1267,7 +1291,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1294,6 +1319,10 @@ interface TerminalManagerOptions { shellResolver?: () => string; env?: NodeJS.ProcessEnv; subprocessInspector?: TerminalSubprocessInspector; + processTable?: Effect.Effect< + ReadonlyArray, + TerminalSubprocessCheckError + >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; @@ -1306,17 +1335,84 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const nativeTelemetry = yield* NativeTelemetryClient.NativeTelemetryClient; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + processTable: nativeTelemetry.processTable.pipe( + Effect.mapError( + (cause) => new TerminalSubprocessCheckError({ cause, command: "resource-monitor" }), + ), + ), registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1339,26 +1435,81 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). - const fetchProcessTableSnapshot = ( + const fallbackProcessTableSnapshot = ( platform === "win32" ? windowsProcessTableSnapshot() : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); + const fetchProcessTableSnapshot: Effect.Effect< + { + readonly snapshot: TerminalProcessTableSnapshot; + /** + * False when the sidecar snapshot failed and this table came from the + * spawned fallback. The data is still applied, but the tick counts as + * a failure so polling backs off instead of hot-looping the fallback. + */ + readonly snapshotSucceeded: boolean; + }, + TerminalSubprocessCheckError + > = options.processTable + ? options.processTable.pipe( + Effect.map((entries) => ({ + snapshot: processTableSnapshotFromProcesses(entries), + snapshotSucceeded: true, + })), + Effect.catch(() => + fallbackProcessTableSnapshot.pipe( + Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })), + ), + ), + ) + : fallbackProcessTableSnapshot.pipe( + Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })), + ); const customSubprocessInspector = options.subprocessInspector; const acquireSubprocessInspector: Effect.Effect< - TerminalSubprocessInspector, + { + readonly inspector: TerminalSubprocessInspector; + readonly snapshotSucceeded: boolean; + }, TerminalSubprocessCheckError > = customSubprocessInspector !== undefined - ? Effect.succeed(customSubprocessInspector) + ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true }) : Effect.map( fetchProcessTableSnapshot, - (snapshot): TerminalSubprocessInspector => - (terminalPid) => + ({ + snapshot, + snapshotSucceeded, + }): { + readonly inspector: TerminalSubprocessInspector; + readonly snapshotSucceeded: boolean; + } => ({ + inspector: (terminalPid) => Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), + snapshotSucceeded, + }), ); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; @@ -2210,7 +2361,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); if (runningSessions.length === 0) { - return; + return true; } const inspectorOption = yield* acquireSubprocessInspector.pipe( @@ -2218,15 +2369,22 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.catch((reason) => Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { reason, - }).pipe(Effect.as(Option.none())), + }).pipe( + Effect.as( + Option.none<{ + readonly inspector: TerminalSubprocessInspector; + readonly snapshotSucceeded: boolean; + }>(), + ), + ), ), ); if (Option.isNone(inspectorOption)) { - return; + return false; } - const subprocessInspector = inspectorOption.value; + const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value; const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( session: TerminalSessionState & { pid: number }, @@ -2295,6 +2453,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func concurrency: "unbounded", discard: true, }); + return snapshotSucceeded; }); const hasRunningSessions = readManagerState.pipe( @@ -2303,14 +2462,26 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + let subprocessSnapshotFailureCount = 0; yield* Effect.forever( hasRunningSessions.pipe( Effect.flatMap((active) => active ? pollSubprocessActivity().pipe( - Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs)), + Effect.flatMap((snapshotSucceeded) => { + subprocessSnapshotFailureCount = snapshotSucceeded + ? 0 + : Math.min(subprocessSnapshotFailureCount + 1, 30); + const delayMs = subprocessSnapshotPollDelayMs( + subprocessPollIntervalMs, + subprocessSnapshotFailureCount, + ); + return Effect.sleep(delayMs); + }), ) - : Effect.sleep(subprocessPollIntervalMs), + : Effect.sync(() => { + subprocessSnapshotFailureCount = 0; + }).pipe(Effect.flatMap(() => Effect.sleep(subprocessPollIntervalMs))), ), ), ).pipe(Effect.forkIn(workerScope)); @@ -2468,7 +2639,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2485,11 +2659,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2497,11 +2672,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2753,84 +2929,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 253d88779827..05f45b4cb3b0 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this T3 Code thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +176,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +186,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index abe7f8e64999..cf95af2eb991 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -35,6 +35,7 @@ import * as Stream from "effect/Stream"; import { HttpClient, type HttpClientError, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as PrismEnvironment from "../fork/prism/PrismEnvironment.ts"; // fork: prism import { ServerSettingsService } from "../serverSettings.ts"; import { cliproxyStatusToAccounts, decodeCliproxyQuotaStatus } from "./cliproxyUsageLimits.ts"; @@ -147,7 +148,7 @@ export const make = Effect.gen(function* () { ([, config]) => config.enabled, ); const snapshots = yield* Effect.forEach( - entries, + PrismEnvironment.withPrismUsageLimitSource(entries), // fork: prism ([id, config]) => readSource(id as UsageLimitSourceId, config), { concurrency: 4 }, ); @@ -162,6 +163,7 @@ export const make = Effect.gen(function* () { Stream.runForEach(() => refresh), Effect.forkScoped, ); + yield* PrismEnvironment.refreshOnPrismUsageSourceChange(refresh); // fork: prism const interval = settingsService.getSettings.pipe( Effect.map( diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts index 19767f3a9270..5e8d1b1fff7a 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.test.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { }, ]); }); + + it("names a Codex five-hour window `primary`, as the Codex driver does", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "codex-abc-someone@example.com-pro.json": { + provider: "codex", + plan: "pro", + five_hour: { hard_limited: false, known: true, used_percent: 40 }, + }, + }, + }, + checkedAt, + ); + expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); + }); }); describe("accountEmailFromAuthFile", () => { diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts index cd2b1e277da6..47ed200c3f22 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( if (!window || window.known === false) continue; const resetsAt = isoFromHub(window.reset_at); windows.push({ - id: spec.id, + // Codex names its five-hour window by position, so a hub row and a + // native row for the same account pool together. + id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, kind: spec.kind, label: spec.label, windowDurationMins: spec.windowDurationMins, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index d45dfe2dd09b..713d860999cb 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -4,7 +4,6 @@ import { cacheSavingsUsd, createOverrideRateTable, lookupRate, - normalizeModelName, parseRateTable, priceUsage, } from "./usagePricing.ts"; @@ -83,10 +82,6 @@ describe("usage pricing", () => { } }); - it("keeps the existing model-name normalization contract", () => { - expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); - }); - it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 5ca75a68cb32..6c94be424827 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -127,16 +127,6 @@ function normalizeRateKey(model: string): string { return model.trim().toLowerCase(); } -/** - * Canonicalises a model name for lookup. - * - * Strips a `provider/` prefix and lowercases, since transcripts are - * inconsistent about casing. - */ -export function normalizeModelName(model: string): string { - return bareModelName(normalizeRateKey(model)); -} - function bareModelName(key: string): string { const slash = key.lastIndexOf("/"); return slash === -1 ? key : key.slice(slash + 1); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 04f7fcffcda0..88f48e9e8afa 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,22 +14,6 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it("keeps operation context and the original cause on config errors", () => { - const cause = new Error("permission denied"); - const error = new VcsProjectConfig.VcsProjectConfigError({ - operation: "read", - cwd: "/repo/packages/app", - configPath: "/repo/.t3code/vcs.json", - cause, - }); - - assert.equal(error.operation, "read"); - assert.equal(error.cwd, "/repo/packages/app"); - assert.equal(error.configPath, "/repo/.t3code/vcs.json"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); - }); - it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 04d320c03bf9..c00a07f2a7a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..b4255c647816 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, +} from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -53,6 +57,7 @@ import { type RelayClientInstallProgressEvent, ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -96,6 +101,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -119,6 +125,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -127,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; @@ -512,6 +521,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService; const providerInstances = yield* ProviderInstanceRegistry; @@ -560,6 +570,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -595,6 +606,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; @@ -1215,59 +1227,67 @@ const makeWsRpcLayer = ( ); }; - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; - - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - }; - }); + // Only clients that answer /usage-limits themselves see it in the catalogs; + // an older client would send the injected command to the provider. + const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -1744,9 +1764,13 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetConfig, + loadServerConfig({ usageLimitsCommand: false }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -1998,6 +2022,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, @@ -2332,6 +2360,31 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2661,6 +2714,8 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { + const usageLimitsCommand = input.usageLimitsCommand === true; + const config = yield* loadServerConfig({ usageLimitsCommand }); const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2671,7 +2726,33 @@ const makeWsRpcLayer = ( }, })), ); - const providerStatuses = providerRegistry.streamChanges.pipe( + const providerStatuses = Stream.zipLatestWith( + // The registry stream carries changes only. Seed it with the current + // providers so a source refresh that lands before any provider change + // still pairs up and reaches the client. + Stream.concat( + Stream.fromEffect(providerRegistry.getProviders), + providerRegistry.streamChanges, + ), + usageLimitSources.streamChanges.pipe( + // Quota updates already have their own stream. Republish the model + // catalog only when the set of providers offered the command changes. + Stream.changesWith( + usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, + ), + ), + (providers, sources) => + usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + ).pipe( + // Both sides replay their current value, so the first pairing normally + // repeats the snapshot the client already holds. Compare against that + // snapshot rather than dropping blindly: a refresh that landed between + // the snapshot and the subscription still goes out. + (updates) => Stream.concat(Stream.make(config.providers), updates), + Stream.changesWith( + (previous, next) => JSON.stringify(previous) === JSON.stringify(next), + ), + Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, @@ -2732,11 +2813,7 @@ const makeWsRpcLayer = ( ); return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), + Stream.make({ version: 1 as const, type: "snapshot" as const, config }), liveUpdates, ); }), @@ -2746,11 +2823,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); @@ -2877,6 +2961,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS diff --git a/apps/swift-ios/.gitignore b/apps/swift-ios/.gitignore new file mode 100644 index 000000000000..0d801cca14d3 --- /dev/null +++ b/apps/swift-ios/.gitignore @@ -0,0 +1,5 @@ +.derivedData/ +DerivedData/ +*.xcuserstate +xcuserdata/ +Config/Local.xcconfig diff --git a/apps/swift-ios/App/Cloud/T3ConnectAuth.swift b/apps/swift-ios/App/Cloud/T3ConnectAuth.swift new file mode 100644 index 000000000000..5bfd2294c3b9 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectAuth.swift @@ -0,0 +1,74 @@ +import ClerkKit +import Foundation + +public struct T3ConnectAccount: Equatable, Sendable { + public let id: String + public let email: String? + public let imageURL: URL? +} + +public enum T3ConnectAuthError: LocalizedError, Sendable { + case noSession + + public var errorDescription: String? { + switch self { + case .noSession: + "Sign in to your T3 account to use T3 Connect." + } + } +} + +enum T3ConnectAuthCallback { + static let scheme = PlatformRoute.nativeScheme + static let redirectURL = "\(scheme)://clerk-callback" +} + +/// Small ClerkKit boundary. Clerk owns encrypted session persistence and the +/// ASWebAuthenticationSession callback; the app only asks for the relay JWT. +@MainActor +public final class T3ConnectClerkSession { + private let clerk: Clerk + private let jwtTemplate: String + + public init(configuration: T3ConnectConfiguration) { + jwtTemplate = configuration.clerkJWTTemplate + clerk = Clerk.configure( + publishableKey: configuration.clerkPublishableKey, + options: .init( + redirectConfig: .init( + redirectUrl: T3ConnectAuthCallback.redirectURL, + callbackUrlScheme: T3ConnectAuthCallback.scheme + ) + ) + ) + } + + var client: Clerk { clerk } + + public var account: T3ConnectAccount? { + guard let user = clerk.user else { return nil } + return T3ConnectAccount( + id: user.id, + email: user.primaryEmailAddress?.emailAddress, + imageURL: URL(string: user.imageUrl) + ) + } + + public var isLoaded: Bool { clerk.isLoaded } + + public func refresh() async throws { + _ = try await clerk.refreshClient() + } + + public func signOut() async throws { + try await clerk.auth.signOut() + } + + public func relayToken() async throws -> String { + let token = try await clerk.auth.getToken( + .init(template: jwtTemplate, expirationBuffer: 20) + ) + guard let token, !token.isEmpty else { throw T3ConnectAuthError.noSession } + return token + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift new file mode 100644 index 000000000000..353df8937720 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift @@ -0,0 +1,516 @@ +import ClerkKit +import Foundation +import Observation +import OSLog + +public extension Notification.Name { + static let t3ConnectSessionChanged = Notification.Name("T3ConnectSessionChanged") +} + +@MainActor +public protocol T3ConnectCapable: AnyObject { + var t3ConnectController: T3ConnectController { get } + + /// Save and activate the relay-managed environment without treating its + /// bootstrap credential as a bearer token. Implementations prepare the + /// DPoP access token and socket ticket through `managedAuthorizer`. + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws + + /// Ends the account session and removes only relay-managed runtime state. + /// Directly paired environments belong to the device and must survive. + func signOutT3Connect() async +} + +public struct T3ConnectCloudEnvironment: Identifiable, Equatable, Sendable { + public var id: String { environment.environmentId } + + public let environment: T3ConnectRelayEnvironment + public let status: T3ConnectRelayEnvironmentStatus? + public let statusError: String? + + public init( + environment: T3ConnectRelayEnvironment, + status: T3ConnectRelayEnvironmentStatus? = nil, + statusError: String? = nil + ) { + self.environment = environment + self.status = status + self.statusError = statusError + } +} + +@MainActor +protocol T3ConnectDeviceManaging: AnyObject { + var hasActiveAccount: Bool { get } + var currentRegisteredDeviceID: String? { get } + func registeredDevices() async throws -> [T3ConnectRelayDevice] + func unregisterDevice(id: String) async throws +} + +@MainActor +@Observable +public final class T3ConnectController: T3ConnectDeviceManaging { + private static let logger = Logger( + subsystem: "codes.t3.swift-ios", + category: "T3Connect" + ) + public let resolution: T3ConnectConfigurationResolution + public let managedAuthorizer: T3ConnectManagedEnvironmentAuthorizer + + public private(set) var account: T3ConnectAccount? { + didSet { + guard oldValue != account else { return } + var accountIDs: [String: String] = [:] + if let previousAccountID = oldValue?.id { + accountIDs["previousAccountID"] = previousAccountID + } + if let accountID = account?.id { + accountIDs["accountID"] = accountID + } + NotificationCenter.default.post( + name: .t3ConnectSessionChanged, + object: self, + userInfo: accountIDs + ) + } + } + public private(set) var environments: [T3ConnectCloudEnvironment] = [] + public private(set) var isRefreshing = false + public private(set) var busyEnvironmentID: String? + public var errorMessage: String? + + private let auth: T3ConnectClerkSession? + private let relay: T3ConnectRelayClient? + private var registeredDeviceID: String? + private var refreshGeneration: UInt64 = 0 + private var authorizationGeneration: UInt64 = 0 + private var isLocalAuthorizationInvalidated = false + private var isSignOutInProgress = false + private var authorizationOperationCount = 0 + private var authorizationOperationWaiters: [CheckedContinuation] = [] + private var signOutOperation: (@MainActor @Sendable () async throws -> Void)? + + public convenience init( + resolution: T3ConnectConfigurationResolution = T3ConnectConfiguration.resolve(), + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.init( + resolution: resolution, + transport: transport, + signer: signer, + configureAuth: true, + signOutOperation: nil + ) + } + + private init( + resolution: T3ConnectConfigurationResolution, + transport: any HTTPTransport, + signer: T3ConnectDPoPSigner, + configureAuth: Bool, + signOutOperation: (@MainActor @Sendable () async throws -> Void)? + ) { + self.resolution = resolution + self.signOutOperation = signOutOperation + managedAuthorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + guard let configuration = resolution.configuration else { + auth = nil + relay = nil + return + } + auth = configureAuth ? T3ConnectClerkSession(configuration: configuration) : nil + relay = T3ConnectRelayClient( + configuration: configuration, + transport: transport, + signer: signer + ) + } + + convenience init( + resolution: T3ConnectConfigurationResolution, + transport: any HTTPTransport, + signer: T3ConnectDPoPSigner, + signOutOperation: @escaping @MainActor @Sendable () async throws -> Void + ) { + self.init( + resolution: resolution, + transport: transport, + signer: signer, + configureAuth: false, + signOutOperation: signOutOperation + ) + } + + public var unavailableReason: String? { + guard case let .unavailable(reason) = resolution else { return nil } + return reason + } + + public var currentRegisteredDeviceID: String? { registeredDeviceID } + var hasActiveAccount: Bool { account != nil } + + var clerk: Clerk? { auth?.client } + + public func refresh() async { + guard let auth, let relay else { return } + guard !isLocalAuthorizationInvalidated else { return } + refreshGeneration &+= 1 + let generation = refreshGeneration + let authGeneration = authorizationGeneration + isRefreshing = true + defer { + if refreshGeneration == generation { isRefreshing = false } + } + do { + if !auth.isLoaded { try await auth.refresh() } + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + await adoptAccount(auth.account, relay: relay) + guard account != nil else { + environments = [] + return + } + let token = try await auth.relayToken() + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + let records = try await relay.listEnvironments(clerkToken: token) + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + environments = records.map { T3ConnectCloudEnvironment(environment: $0) } + let loaded = await withTaskGroup( + of: T3ConnectCloudEnvironment.self, + returning: [T3ConnectCloudEnvironment].self + ) { group in + for record in records { + group.addTask { + do { + let status = try await relay.status(for: record, clerkToken: token) + return T3ConnectCloudEnvironment( + environment: record, + status: status + ) + } catch { + return T3ConnectCloudEnvironment( + environment: record, + statusError: error.localizedDescription + ) + } + } + } + var loaded: [T3ConnectCloudEnvironment] = [] + for await environment in group { loaded.append(environment) } + return loaded.sorted { + $0.environment.linkedAt > $1.environment.linkedAt + } + } + guard refreshGeneration == generation, + authorizationGeneration == authGeneration, + !isLocalAuthorizationInvalidated else { return } + environments = loaded + } catch { + if refreshGeneration == generation { + errorMessage = error.localizedDescription + } + } + } + + /// A successful authentication flow is the only action that may restore a + /// locally signed-out Clerk session. + public func refreshAfterAuthentication() async { + isLocalAuthorizationInvalidated = false + authorizationGeneration &+= 1 + await refresh() + } + + public func signOut() async { + guard let relay else { return } + guard !isSignOutInProgress else { return } + isSignOutInProgress = true + let deviceID = registeredDeviceID + isLocalAuthorizationInvalidated = true + authorizationGeneration &+= 1 + let signOutAuthorizationGeneration = authorizationGeneration + refreshGeneration &+= 1 + let generation = refreshGeneration + isRefreshing = true + defer { + isSignOutInProgress = false + if refreshGeneration == generation { isRefreshing = false } + } + account = nil + environments = [] + registeredDeviceID = nil + await relay.clearTokenCache() + await waitForAuthorizationOperations() + + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + if let auth, + let deviceID, + let token = try? await auth.relayToken() { + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + // Remote delivery must not outlive the signed-in session on this + // install. A failed best-effort unregister must not trap the user + // in an account they are trying to leave. + try? await relay.unregisterDevice( + deviceID: deviceID, + clerkToken: token + ) + } + await relay.clearTokenCache() + + // Local authorization state is security-sensitive and must be cleared + // before Clerk performs network work. A failed remote sign-out can be + // reported, but it cannot leave relay tokens or managed state usable. + do { + guard authorizationGeneration == signOutAuthorizationGeneration, + isLocalAuthorizationInvalidated else { return } + if let signOutOperation { + try await signOutOperation() + } else if let auth { + try await auth.signOut() + } + Self.logger.info("T3 Connect account session signed out") + } catch { + guard refreshGeneration == generation else { return } + Self.logger.error( + "T3 Connect remote sign-out failed: \(error.localizedDescription, privacy: .private)" + ) + errorMessage = error.localizedDescription + } + } + + public func credential( + for environment: T3ConnectRelayEnvironment, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + busyEnvironmentID = environment.environmentId + defer { busyEnvironmentID = nil } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let credential = try await relay.connect( + to: environment, + clerkToken: token, + deviceID: deviceID ?? registeredDeviceID + ) + try requireCurrentAuthorization(generation) + return credential + } + + /// Reacquires the one-use bootstrap credential needed to refresh a saved + /// managed environment. The current relay record is fetched again so an + /// expired access token never falls back to a manual bearer credential. + public func credential( + forEnvironmentID environmentID: String, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let records = try await relay.listEnvironments(clerkToken: token) + try requireCurrentAuthorization(generation) + guard let environment = records.first(where: { $0.environmentId == environmentID }) else { + throw T3ConnectRelayError.invalidConfiguration( + "This environment is no longer linked to your T3 account." + ) + } + let credential = try await relay.connect( + to: environment, + clerkToken: token, + deviceID: deviceID ?? registeredDeviceID + ) + try requireCurrentAuthorization(generation) + return credential + } + + @discardableResult + public func unlink(_ environment: T3ConnectRelayEnvironment) async -> Bool { + guard let auth, let relay else { return false } + busyEnvironmentID = environment.environmentId + defer { busyEnvironmentID = nil } + do { + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.unlinkEnvironment( + environmentID: environment.environmentId, + clerkToken: token + ) + guard isAuthorizationCurrent(generation) else { return false } + environments.removeAll { $0.id == environment.environmentId } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + public func registerDevice(_ registration: T3ConnectDeviceRegistration) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.registerDevice(registration, clerkToken: token) + guard isAuthorizationCurrent(generation) else { + try? await relay.unregisterDevice( + deviceID: registration.deviceId, + clerkToken: token + ) + await relay.clearTokenCache() + throw T3ConnectAuthError.noSession + } + registeredDeviceID = registration.deviceId + } + + public func registeredDevices() async throws -> [T3ConnectRelayDevice] { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + let devices = try await relay.listDevices(clerkToken: token) + try requireCurrentAuthorization(generation) + return devices + } + + public func unregisterDevice(id: String) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.unregisterDevice(deviceID: id, clerkToken: token) + try requireCurrentAuthorization(generation) + if registeredDeviceID == id { + registeredDeviceID = nil + } + } + + func rememberRegisteredDevice(id: String) { + registeredDeviceID = id + } + + public func registerLiveActivity( + _ registration: T3ConnectLiveActivityRegistration + ) async throws { + guard let auth, let relay else { + throw T3ConnectRelayError.invalidConfiguration( + unavailableReason ?? "T3 Connect is unavailable in this build." + ) + } + let token = try await loadedRelayToken(auth) + let generation = authorizationGeneration + try beginAuthorizationOperation(generation) + defer { endAuthorizationOperation() } + try await relay.registerLiveActivity(registration, clerkToken: token) + guard isAuthorizationCurrent(generation) else { + // Live activity registrations are device-scoped. Removing the + // device compensates for a registration that crossed sign-out. + try? await relay.unregisterDevice( + deviceID: registration.deviceId, + clerkToken: token + ) + await relay.clearTokenCache() + throw T3ConnectAuthError.noSession + } + } + + private func loadedRelayToken(_ auth: T3ConnectClerkSession) async throws -> String { + guard !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + let generation = authorizationGeneration + if !auth.isLoaded { + try await auth.refresh() + } + guard authorizationGeneration == generation, + !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + if let relay { + await adoptAccount(auth.account, relay: relay) + } else { + account = auth.account + } + guard account != nil else { throw T3ConnectAuthError.noSession } + let token = try await auth.relayToken() + guard authorizationGeneration == generation, + !isLocalAuthorizationInvalidated else { throw T3ConnectAuthError.noSession } + return token + } + + private func adoptAccount( + _ nextAccount: T3ConnectAccount?, + relay: T3ConnectRelayClient + ) async { + if account?.id != nextAccount?.id { + environments = [] + registeredDeviceID = nil + await relay.clearTokenCache() + } + account = nextAccount + } + + private func isAuthorizationCurrent(_ generation: UInt64) -> Bool { + authorizationGeneration == generation && !isLocalAuthorizationInvalidated + } + + private func requireCurrentAuthorization(_ generation: UInt64) throws { + guard isAuthorizationCurrent(generation) else { + throw T3ConnectAuthError.noSession + } + } + + private func beginAuthorizationOperation(_ generation: UInt64) throws { + try requireCurrentAuthorization(generation) + authorizationOperationCount += 1 + } + + private func endAuthorizationOperation() { + authorizationOperationCount -= 1 + guard authorizationOperationCount == 0 else { return } + let waiters = authorizationOperationWaiters + authorizationOperationWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func waitForAuthorizationOperations() async { + guard authorizationOperationCount > 0 else { return } + await withCheckedContinuation { continuation in + authorizationOperationWaiters.append(continuation) + } + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift b/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift new file mode 100644 index 000000000000..88de0b30a48d --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectConfiguration.swift @@ -0,0 +1,90 @@ +import Foundation + +public struct T3ConnectConfiguration: Equatable, Sendable { + public static let defaultClerkJWTTemplate = "t3-relay" + + public let clerkPublishableKey: String + public let clerkJWTTemplate: String + public let relayHTTPURL: URL + + public init( + clerkPublishableKey: String, + clerkJWTTemplate: String = Self.defaultClerkJWTTemplate, + relayHTTPURL: URL + ) { + self.clerkPublishableKey = clerkPublishableKey + self.clerkJWTTemplate = clerkJWTTemplate + self.relayHTTPURL = relayHTTPURL + } + + public static func resolve(bundle: Bundle = .main) -> T3ConnectConfigurationResolution { + resolve(infoDictionary: bundle.infoDictionary ?? [:]) + } + + public static func resolve( + infoDictionary: [String: Any] + ) -> T3ConnectConfigurationResolution { + let publishableKey = configuredString( + infoDictionary["T3ConnectClerkPublishableKey"] + ) + let relayHTTPValue = configuredString(infoDictionary["T3ConnectRelayHTTPURL"]) + let jwtTemplate = configuredString(infoDictionary["T3ConnectClerkJWTTemplate"]) + ?? defaultClerkJWTTemplate + + var missingKeys: [String] = [] + if publishableKey == nil { missingKeys.append("Clerk publishable key") } + if relayHTTPValue == nil { missingKeys.append("relay HTTP URL") } + guard missingKeys.isEmpty else { + return .unavailable( + reason: "This build is missing \(missingKeys.joined(separator: ", "))." + ) + } + + guard + let relayHTTPValue, + let relayHTTPURL = URL(string: relayHTTPValue), + relayHTTPURL.scheme?.lowercased() == "https", + relayHTTPURL.host != nil + else { + return .unavailable(reason: "The T3 Connect relay HTTP URL must use HTTPS.") + } + return .available( + T3ConnectConfiguration( + clerkPublishableKey: publishableKey!, + clerkJWTTemplate: jwtTemplate, + relayHTTPURL: normalizedBaseURL(relayHTTPURL) + ) + ) + } + + private static func configuredString(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { return nil } + return trimmed + } + + private static func normalizedBaseURL(_ url: URL) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + components.query = nil + components.fragment = nil + components.path = components.path.replacingOccurrences( + of: #"/+$"#, + with: "", + options: .regularExpression + ) + return components.url ?? url + } +} + +public enum T3ConnectConfigurationResolution: Equatable, Sendable { + case available(T3ConnectConfiguration) + case unavailable(reason: String) + + public var configuration: T3ConnectConfiguration? { + guard case let .available(configuration) = self else { return nil } + return configuration + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift b/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift new file mode 100644 index 000000000000..0257069a03a3 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectDPoP.swift @@ -0,0 +1,259 @@ +import CryptoKit +import Foundation +import Security + +public struct T3ConnectDPoPPublicJWK: Codable, Equatable, Sendable { + public let kty: String + public let crv: String + public let x: String + public let y: String + + fileprivate init(publicKey: P256.Signing.PublicKey) throws { + let representation = publicKey.x963Representation + guard representation.count == 65, representation.first == 0x04 else { + throw T3ConnectDPoPError.invalidPublicKey + } + kty = "EC" + crv = "P-256" + x = Data(representation[1..<33]).base64URLEncodedString() + y = Data(representation[33..<65]).base64URLEncodedString() + } + + public var canonicalThumbprintInput: String { + "{\"crv\":\"\(crv)\",\"kty\":\"\(kty)\",\"x\":\"\(x)\",\"y\":\"\(y)\"}" + } + + public var thumbprint: String { + Data(SHA256.hash(data: Data(canonicalThumbprintInput.utf8))) + .base64URLEncodedString() + } +} + +public struct T3ConnectDPoPProof: Equatable, Sendable { + public let value: String + public let thumbprint: String +} + +public enum T3ConnectDPoPError: LocalizedError, Sendable { + case invalidURL + case invalidPrivateKey + case invalidPublicKey + case invalidStoredKey + case keychain(OSStatus) + case encoding + + public var errorDescription: String? { + switch self { + case .invalidURL: + "The DPoP proof URL is invalid." + case .invalidPrivateKey: + "The DPoP private key is invalid." + case .invalidPublicKey: + "The DPoP public key is invalid." + case .invalidStoredKey: + "The saved DPoP identity is invalid." + case let .keychain(status): + SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)." + case .encoding: + "The DPoP proof could not be encoded." + } + } +} + +/// Owns the proof-of-possession identity used by both relay and environment requests. +/// Rotating this key invalidates every token bound to its JWK thumbprint, so the +/// production initializer persists it in the device-only Keychain. +public actor T3ConnectDPoPSigner { + private let service: String? + private let account: String + private var privateKey: P256.Signing.PrivateKey? + + public init( + service: String = "com.t3tools.t3code.swiftui.t3-connect-dpop", + account: String = "device-proof-key" + ) { + self.service = service + self.account = account + } + + /// Deterministic in-memory identity for focused tests and previews. + public init(privateKeyRawRepresentation: Data) throws { + do { + privateKey = try P256.Signing.PrivateKey(rawRepresentation: privateKeyRawRepresentation) + } catch { + throw T3ConnectDPoPError.invalidPrivateKey + } + service = nil + account = "in-memory" + } + + /// The signing key never rotates (see type docs), so the derived JWK and + /// its SHA-256 thumbprint are stable and cached. Both are recomputed on + /// every managed request otherwise. + private var cachedJWK: T3ConnectDPoPPublicJWK? + private var cachedThumbprint: String? + + public func publicJWK() throws -> T3ConnectDPoPPublicJWK { + if let cachedJWK { return cachedJWK } + let jwk = try T3ConnectDPoPPublicJWK(publicKey: try loadPrivateKey().publicKey) + cachedJWK = jwk + return jwk + } + + public func thumbprint() throws -> String { + if let cachedThumbprint { return cachedThumbprint } + let thumbprint = try publicJWK().thumbprint + cachedThumbprint = thumbprint + return thumbprint + } + + public func proof( + method: String, + url: URL, + accessToken: String? = nil, + issuedAt: Date = Date(), + identifier: UUID = UUID() + ) throws -> T3ConnectDPoPProof { + guard let normalizedURL = Self.normalizedHTU(url) else { + throw T3ConnectDPoPError.invalidURL + } + + let key = try loadPrivateKey() + let jwk = try publicJWK() + let header = Header(typ: "dpop+jwt", alg: "ES256", jwk: jwk) + let payload = Payload( + htm: method.uppercased(), + htu: normalizedURL.absoluteString, + jti: identifier.uuidString.lowercased(), + iat: Int(issuedAt.timeIntervalSince1970.rounded(.down)), + ath: accessToken.map(Self.accessTokenHash) + ) + guard + let headerPart = try? Self.proofEncoder.encode(header).base64URLEncodedString(), + let payloadPart = try? Self.proofEncoder.encode(payload).base64URLEncodedString() + else { + throw T3ConnectDPoPError.encoding + } + let signingInput = "\(headerPart).\(payloadPart)" + let signature = try key.signature(for: Data(signingInput.utf8)) + return T3ConnectDPoPProof( + value: "\(signingInput).\(signature.rawRepresentation.base64URLEncodedString())", + thumbprint: jwk.thumbprint + ) + } + + public static func normalizedHTU(_ url: URL) -> URL? { + guard + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme != nil, + components.host != nil + else { return nil } + components.query = nil + components.fragment = nil + switch (components.scheme?.lowercased(), components.port) { + case ("http", 80), ("https", 443), ("ws", 80), ("wss", 443): + components.port = nil + default: + break + } + return components.url + } + + public static func accessTokenHash(_ accessToken: String) -> String { + Data(SHA256.hash(data: Data(accessToken.utf8))).base64URLEncodedString() + } + + private func loadPrivateKey() throws -> P256.Signing.PrivateKey { + if let privateKey { return privateKey } + guard let service else { throw T3ConnectDPoPError.invalidStoredKey } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess { + guard let data = item as? Data else { throw T3ConnectDPoPError.invalidStoredKey } + do { + let restored = try P256.Signing.PrivateKey(rawRepresentation: data) + privateKey = restored + return restored + } catch { + throw T3ConnectDPoPError.invalidStoredKey + } + } + guard status == errSecItemNotFound else { throw T3ConnectDPoPError.keychain(status) } + + let generated = P256.Signing.PrivateKey() + var insertion = query + insertion.removeValue(forKey: kSecReturnData as String) + insertion.removeValue(forKey: kSecMatchLimit as String) + insertion[kSecValueData as String] = generated.rawRepresentation + insertion[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let insertionStatus = SecItemAdd(insertion as CFDictionary, nil) + if insertionStatus == errSecDuplicateItem { + // Another signer instance won the first-launch race. Preserve that + // identity instead of rotating to this actor's generated key. + return try readExistingPrivateKey(service: service) + } + guard insertionStatus == errSecSuccess else { + throw T3ConnectDPoPError.keychain(insertionStatus) + } + privateKey = generated + return generated + } + + private func readExistingPrivateKey(service: String) throws -> P256.Signing.PrivateKey { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { throw T3ConnectDPoPError.keychain(status) } + guard let data = item as? Data else { throw T3ConnectDPoPError.invalidStoredKey } + do { + let restored = try P256.Signing.PrivateKey(rawRepresentation: data) + privateKey = restored + return restored + } catch { + throw T3ConnectDPoPError.invalidStoredKey + } + } + + private static let proofEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + + private struct Header: Encodable { + let typ: String + let alg: String + let jwk: T3ConnectDPoPPublicJWK + } + + private struct Payload: Encodable { + let htm: String + let htu: String + let jti: String + let iat: Int + let ath: String? + } +} + +extension Data { + fileprivate func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift b/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift new file mode 100644 index 000000000000..6211fb765f26 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectManagedAuthorization.swift @@ -0,0 +1,438 @@ +import Foundation + +public struct T3ConnectPreparedEnvironmentConnection: Sendable { + public let authorization: T3ConnectEnvironmentAccessToken + public let webSocketURL: URL + + public init( + authorization: T3ConnectEnvironmentAccessToken, + webSocketURL: URL + ) { + self.authorization = authorization + self.webSocketURL = webSocketURL + } +} + +/// Converts the relay's short-lived environment bootstrap credential into the +/// DPoP access token and one-time WebSocket ticket understood by a T3 server. +/// The same signer must authorize every later HTTP request for that token. +public actor T3ConnectManagedEnvironmentAuthorizer { + public static let standardScopes = [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ] + + private struct AccessTokenResponse: Decodable, Sendable { + let accessToken: String + let issuedTokenType: String + let tokenType: String + let expiresIn: Double + let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } + } + + private struct WebSocketTicketResponse: Decodable, Sendable { + let ticket: String + let expiresAt: String + } + + private struct ErrorBody: Decodable, Sendable { + let message: String? + let reason: String? + let traceId: String? + } + + private let transport: any HTTPTransport + private let signer: T3ConnectDPoPSigner + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.transport = transport + self.signer = signer + } + + public func prepare( + _ credential: T3ConnectManagedEnvironmentCredential, + scopes: [String] = standardScopes, + clientLabel: String? = nil + ) async throws -> T3ConnectPreparedEnvironmentConnection { + let accessToken = try await exchange( + credential, + scopes: scopes, + clientLabel: clientLabel + ) + let webSocketURL = try await webSocketURL(using: accessToken) + return T3ConnectPreparedEnvironmentConnection( + authorization: accessToken, + webSocketURL: webSocketURL + ) + } + + public func exchange( + _ credential: T3ConnectManagedEnvironmentCredential, + scopes: [String] = standardScopes, + clientLabel: String? = nil + ) async throws -> T3ConnectEnvironmentAccessToken { + guard let httpBaseURL = credential.endpoint.httpBaseURL else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment HTTP URL is invalid." + ) + } + let thumbprint = try await signer.thumbprint() + guard thumbprint == credential.proofKeyThumbprint else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed credential is bound to a different device identity." + ) + } + let target = endpoint(httpBaseURL, path: ["oauth", "token"]) + let proof = try await signer.proof(method: "POST", url: target) + var fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": credential.bootstrapCredential, + "subject_token_type": "urn:t3:params:oauth:token-type:environment-bootstrap", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": scopes.joined(separator: " "), + "client_device_type": "mobile", + "client_os": ProcessInfo.processInfo.operatingSystemVersionString, + ] + if let clientLabel, !clientLabel.isEmpty { + fields["client_label"] = clientLabel + } + var request = URLRequest(url: target) + request.httpMethod = "POST" + request.httpBody = Self.formEncoded(fields) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(proof.value, forHTTPHeaderField: "DPoP") + let response = try await send(request, as: AccessTokenResponse.self) + let grantedScopes = Set(response.scope.split(separator: " ").map(String.init)) + guard response.tokenType == "DPoP", + response.issuedTokenType + == "urn:ietf:params:oauth:token-type:access_token", + response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false, + response.expiresIn.isFinite, + response.expiresIn > 0, + grantedScopes == Set(scopes) else { + if grantedScopes != Set(scopes) { + throw T3ConnectRelayError.unexpectedScope( + requested: scopes, + granted: response.scope + ) + } + throw T3ConnectRelayError.invalidResponse + } + return T3ConnectEnvironmentAccessToken( + environmentID: credential.environmentID, + label: credential.label, + endpoint: credential.endpoint, + accessToken: response.accessToken, + expiresAt: Date().addingTimeInterval(response.expiresIn), + scopes: response.scope.split(separator: " ").map(String.init), + proofKeyThumbprint: thumbprint + ) + } + + /// Adds a fresh request-bound proof. Call this immediately before sending; + /// reusing a proof defeats replay protection and is rejected by the server. + public func authorize( + _ request: URLRequest, + using authorization: T3ConnectEnvironmentAccessToken + ) async throws -> URLRequest { + guard let url = request.url else { throw T3ConnectDPoPError.invalidURL } + let proof = try await signer.proof( + method: request.httpMethod ?? "GET", + url: url, + accessToken: authorization.accessToken + ) + guard proof.thumbprint == authorization.proofKeyThumbprint else { + throw T3ConnectRelayError.invalidConfiguration( + "The environment token is bound to a different device identity." + ) + } + var authorized = request + authorized.setValue( + "DPoP \(authorization.accessToken)", + forHTTPHeaderField: "Authorization" + ) + authorized.setValue(proof.value, forHTTPHeaderField: "DPoP") + return authorized + } + + public func proofKeyThumbprint() async throws -> String { + try await signer.thumbprint() + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + try await send( + URLRequest( + url: endpoint( + httpBaseURL, + path: [".well-known", "t3", "environment"] + ) + ), + as: EnvironmentDescriptor.self + ) + } + + public func webSocketURL( + using authorization: T3ConnectEnvironmentAccessToken + ) async throws -> URL { + guard + let httpBaseURL = authorization.endpoint.httpBaseURL, + let webSocketBaseURL = authorization.endpoint.webSocketBaseURL, + httpBaseURL.scheme?.lowercased() == "https", + let httpHost = httpBaseURL.host, + webSocketBaseURL.scheme?.lowercased() == "wss", + let webSocketHost = webSocketBaseURL.host, + httpHost.caseInsensitiveCompare(webSocketHost) == .orderedSame, + (httpBaseURL.port ?? 443) == (webSocketBaseURL.port ?? 443) + else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment endpoint is invalid." + ) + } + let target = endpoint( + httpBaseURL, + path: ["api", "auth", "websocket-ticket"] + ) + var ticketRequest = URLRequest(url: target) + ticketRequest.httpMethod = "POST" + ticketRequest = try await authorize(ticketRequest, using: authorization) + let ticket = try await send(ticketRequest, as: WebSocketTicketResponse.self) + guard !ticket.ticket.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3ConnectRelayError.invalidResponse + } + + var components = URLComponents( + url: webSocketBaseURL, + resolvingAgainstBaseURL: false + ) + if components?.path.isEmpty == true || components?.path == "/" { + components?.path = "/ws" + } + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "wsTicket" } + queryItems.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + components?.queryItems = queryItems + guard let url = components?.url else { throw T3ConnectDPoPError.invalidURL } + return url + } + + private func endpoint(_ baseURL: URL, path: [String]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + components?.path = "" + components?.query = nil + components?.fragment = nil + let origin = components?.url ?? baseURL + return path.reduce(origin) { partial, component in + partial.appendingPathComponent(component) + } + } + + private func send( + _ request: URLRequest, + as type: Response.Type + ) async throws -> Response { + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(ErrorBody.self, from: data) + throw T3ConnectRelayError.response( + status: response.statusCode, + message: body?.message ?? body?.reason ?? "Environment authorization failed.", + traceID: body?.traceId + ) + } + do { + return try JSONDecoder.t3.decode(type, from: data) + } catch { + throw T3ConnectRelayError.invalidResponse + } + } + + private static func formEncoded(_ fields: [String: String]) -> Data { + var components = URLComponents() + components.queryItems = fields.keys.sorted().map { + URLQueryItem(name: $0, value: fields[$0]) + } + return Data((components.percentEncodedQuery ?? "").utf8) + } +} + +/// Adapts the T3 Connect token lifecycle to Core's environment transport. +/// Refresh work is coalesced per environment because shell, detail, and socket +/// reconnect requests can all discover expiration at the same time. +public actor T3ConnectRuntimeAuthorization: ManagedEnvironmentAuthorizing { + public typealias BootstrapProvider = @Sendable (String) async throws + -> T3ConnectManagedEnvironmentCredential + + private struct InFlightRefresh: Sendable { + let id: UUID + let task: Task + } + + private let authorizer: T3ConnectManagedEnvironmentAuthorizer + private let bootstrapProvider: BootstrapProvider + private var refreshTasks: [String: InFlightRefresh] = [:] + + @MainActor + public init(controller: T3ConnectController) { + authorizer = controller.managedAuthorizer + bootstrapProvider = { environmentID in + try await controller.credential(forEnvironmentID: environmentID) + } + } + + public init( + authorizer: T3ConnectManagedEnvironmentAuthorizer, + bootstrapProvider: @escaping BootstrapProvider + ) { + self.authorizer = authorizer + self.bootstrapProvider = bootstrapProvider + } + + public func credentialRequiresRefresh( + _ credential: EnvironmentCredential, + environment: Environment + ) async throws -> Bool { + _ = try Self.authorization(environment: environment, credential: credential) + return try await authorizer.proofKeyThumbprint() != credential.proofKeyThumbprint + } + + public func authorize( + _ request: URLRequest, + environment: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest { + let authorization = try Self.authorization( + environment: environment, + credential: credential + ) + return try await authorizer.authorize(request, using: authorization) + } + + public func refreshCredential( + for environment: Environment, + replacing credential: EnvironmentCredential + ) async throws -> EnvironmentCredential { + _ = try Self.authorization(environment: environment, credential: credential) + if let refresh = refreshTasks[environment.id] { + return try await refresh.task.value + } + + let authorizer = self.authorizer + let bootstrapProvider = self.bootstrapProvider + let task = Task { + let bootstrap = try await bootstrapProvider(environment.id) + try Self.validate( + bootstrap: bootstrap, + environment: environment + ) + guard let httpBaseURL = bootstrap.endpoint.httpBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + let descriptor = try await authorizer.descriptor(at: httpBaseURL) + guard descriptor.environmentId == environment.id else { + throw T3ConnectRelayError.environmentMismatch + } + let authorization = try await authorizer.exchange(bootstrap) + return try Self.credential( + authorization: authorization, + environment: environment + ) + } + let refreshID = UUID() + refreshTasks[environment.id] = InFlightRefresh(id: refreshID, task: task) + do { + let credential = try await task.value + finishRefresh(environmentID: environment.id, id: refreshID) + return credential + } catch { + finishRefresh(environmentID: environment.id, id: refreshID) + throw error + } + } + + private func finishRefresh(environmentID: String, id: UUID) { + guard refreshTasks[environmentID]?.id == id else { return } + refreshTasks.removeValue(forKey: environmentID) + } + + private static func authorization( + environment: Environment, + credential: EnvironmentCredential + ) throws -> T3ConnectEnvironmentAccessToken { + guard environment.kind == .managedDPoP, + credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id, + let expiresAt = credential.expiresAt, + let proofKeyThumbprint = credential.proofKeyThumbprint, + let endpoint = managedEndpoint(for: environment) else { + throw HTTPError.incompatibleCredential + } + return T3ConnectEnvironmentAccessToken( + environmentID: environment.id, + label: environment.label, + endpoint: endpoint, + accessToken: credential.accessToken, + expiresAt: expiresAt, + scopes: credential.scopes, + proofKeyThumbprint: proofKeyThumbprint + ) + } + + private static func credential( + authorization: T3ConnectEnvironmentAccessToken, + environment: Environment + ) throws -> EnvironmentCredential { + guard authorization.environmentID == environment.id, + authorization.proofKeyThumbprint.isEmpty == false, + authorization.endpoint.httpBaseURL == environment.httpBaseURL, + authorization.endpoint.webSocketBaseURL == environment.webSocketBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + return .managedDPoP( + accessToken: authorization.accessToken, + expiresAt: authorization.expiresAt, + scopes: authorization.scopes, + environmentID: authorization.environmentID, + proofKeyThumbprint: authorization.proofKeyThumbprint + ) + } + + private static func validate( + bootstrap: T3ConnectManagedEnvironmentCredential, + environment: Environment + ) throws { + guard bootstrap.environmentID == environment.id, + bootstrap.proofKeyThumbprint.isEmpty == false, + bootstrap.endpoint.httpBaseURL == environment.httpBaseURL, + bootstrap.endpoint.webSocketBaseURL == environment.webSocketBaseURL else { + throw T3ConnectRelayError.environmentMismatch + } + } + + private static func managedEndpoint( + for environment: Environment + ) -> T3ConnectManagedEndpoint? { + guard environment.httpBaseURL.scheme?.lowercased() == "https", + environment.webSocketBaseURL.scheme?.lowercased() == "wss", + environment.httpBaseURL.host != nil, + environment.webSocketBaseURL.host != nil else { return nil } + return T3ConnectManagedEndpoint( + httpBaseUrl: environment.httpBaseURL.absoluteString, + wsBaseUrl: environment.webSocketBaseURL.absoluteString, + providerKind: .t3Relay + ) + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift b/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift new file mode 100644 index 000000000000..e193b8e86dad --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectRelayClient.swift @@ -0,0 +1,527 @@ +import Foundation + +public enum T3ConnectRelayError: LocalizedError, Sendable { + case invalidConfiguration(String) + case invalidResponse + case response(status: Int, message: String, traceID: String?) + case unexpectedScope(requested: [String], granted: String) + case environmentMismatch + + public var errorDescription: String? { + switch self { + case let .invalidConfiguration(message): + message + case .invalidResponse: + "T3 Connect returned an invalid response." + case let .response(status, message, traceID): + traceID.map { "\(message) (trace \($0))" } ?? "\(message) (HTTP \(status))" + case let .unexpectedScope(requested, granted): + "T3 Connect granted \(granted) instead of \(requested.joined(separator: " "))." + case .environmentMismatch: + "T3 Connect returned credentials for a different environment." + } + } +} + +struct T3ConnectRelayErrorBody: Decodable, Sendable { + let message: String? + let reason: String? + let code: String? + let dpopFailureReason: DPoPFailureReason? + let maxTunnels: Int? + let traceId: String? +} + +enum T3ConnectRelayErrorPresentation { + static func message( + for error: T3ConnectRelayErrorBody, + requestUsesDPoP: Bool + ) -> String { + switch error.code { + case "auth_invalid": + switch error.reason { + case "missing_bearer", "invalid_bearer": + return "Relay rejected the cloud session token." + case "invalid_dpop" where requestUsesDPoP: + return DPoPFailurePresentation.message( + "Relay rejected the DPoP proof.", + reason: error.dpopFailureReason + ) + case "not_authorized": + return "Relay rejected the authenticated request." + default: + break + } + case "environment_link_proof_expired": + return "Relay rejected an expired environment link proof." + case "environment_link_proof_invalid": + if let reason = error.reason { + return "Relay rejected the environment link proof (\(reason))." + } + case "environment_connect_not_authorized": + if error.reason == "environment_link_not_found" { + return "Relay has no active link for this environment. The environment server may not have re-established its link yet." + } + if let reason = error.reason { + return "Relay rejected the environment connection request (\(reason))." + } + return "Relay rejected the environment connection request." + case "environment_endpoint_unavailable": + if let reason = error.reason { + return "Relay could not reach the environment endpoint (\(reason))." + } + case "environment_endpoint_timed_out": + return "Relay timed out while contacting the environment endpoint." + case "environment_link_failed": + if let reason = error.reason { + return "Relay could not link the environment (\(reason))." + } + case "environment_link_unavailable": + if let reason = error.reason { + return "Relay cannot provision the managed endpoint (\(reason))." + } + case "environment_link_limit_exceeded": + if let maxTunnels = error.maxTunnels { + return "Relay refused the link: this account already has its maximum of \(maxTunnels) managed tunnels. Unlink an environment to free one up." + } + return "Relay refused the link because this account has reached its managed tunnel limit. Unlink an environment to free one up." + case "agent_activity_publish_proof_expired": + return "Relay rejected an expired agent activity publish proof." + case "agent_activity_publish_proof_invalid": + if let reason = error.reason { + return "Relay rejected the agent activity publish proof (\(reason))." + } + case "internal_error": + if let reason = error.reason { + return "Relay encountered an internal error (\(reason))." + } + default: + break + } + return error.message ?? error.reason ?? error.code ?? "T3 Connect request failed." + } +} + +public actor T3ConnectRelayClient { + private struct CachedToken: Sendable { + let accessToken: String + let expiresAt: Date + let scopes: [T3ConnectRelayScope] + let thumbprint: String + } + + private struct EnvironmentList: Decodable, Sendable { + let environments: [T3ConnectRelayEnvironment] + } + + private struct DeviceList: Decodable, Sendable { + let devices: [T3ConnectRelayDevice] + } + + private struct ConnectResponse: Decodable, Sendable { + let environmentId: String + let endpoint: T3ConnectManagedEndpoint + let credential: String + let expiresAt: String + } + + private struct OKResponse: Decodable, Sendable { + let ok: Bool + } + + private let configuration: T3ConnectConfiguration + private let transport: any HTTPTransport + private let signer: T3ConnectDPoPSigner + private var cachedTokens: [String: CachedToken] = [:] + + public init( + configuration: T3ConnectConfiguration, + transport: any HTTPTransport = URLSessionHTTPTransport(), + signer: T3ConnectDPoPSigner = T3ConnectDPoPSigner() + ) { + self.configuration = configuration + self.transport = transport + self.signer = signer + } + + public func listEnvironments(clerkToken: String) async throws + -> [T3ConnectRelayEnvironment] + { + var request = request(path: ["v1", "environments"], method: "GET") + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: EnvironmentList.self).environments + } + + public func listDevices(clerkToken: String) async throws -> [T3ConnectRelayDevice] { + var request = request(path: ["v1", "client", "devices"], method: "GET") + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: DeviceList.self).devices + } + + public func createEnvironmentLinkChallenge( + clerkToken: String, + request payload: T3ConnectEnvironmentLinkChallengeRequest + ) async throws -> T3ConnectEnvironmentLinkChallenge { + var request = try jsonRequest( + path: ["v1", "client", "environment-link-challenges"], + method: "POST", + payload: payload + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: T3ConnectEnvironmentLinkChallenge.self) + } + + public func linkEnvironment( + clerkToken: String, + request payload: T3ConnectEnvironmentLinkRequest + ) async throws -> T3ConnectEnvironmentLinkResponse { + var request = try jsonRequest( + path: ["v1", "client", "environment-links"], + method: "POST", + payload: payload + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + return try await send(request, as: T3ConnectEnvironmentLinkResponse.self) + } + + public func unlinkEnvironment(environmentID: String, clerkToken: String) async throws { + var request = request( + path: ["v1", "client", "environment-links", environmentID], + method: "DELETE" + ) + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + try requireOK(try await send(request, as: OKResponse.self)) + } + + public func status( + for environment: T3ConnectRelayEnvironment, + clerkToken: String + ) async throws -> T3ConnectRelayEnvironmentStatus { + let path = ["v1", "environments", environment.environmentId, "status"] + let result: T3ConnectRelayEnvironmentStatus = try await sendDPoP( + path: path, + method: "POST", + body: nil, + clerkToken: clerkToken, + scopes: [.environmentStatus] + ) + guard + result.environmentId == environment.environmentId, + result.endpoint == environment.endpoint, + result.descriptor?.environmentId == nil + || result.descriptor?.environmentId == environment.environmentId + else { throw T3ConnectRelayError.environmentMismatch } + return result + } + + public func connect( + to environment: T3ConnectRelayEnvironment, + clerkToken: String, + deviceID: String? = nil + ) async throws -> T3ConnectManagedEnvironmentCredential { + let thumbprint = try await signer.thumbprint() + let payload = ConnectRequest( + deviceId: deviceID, + clientProofKeyThumbprint: thumbprint + ) + let body = try JSONEncoder.t3.encode(payload) + let response: ConnectResponse = try await sendDPoP( + path: ["v1", "environments", environment.environmentId, "connect"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.environmentConnect] + ) + guard + response.environmentId == environment.environmentId, + response.endpoint == environment.endpoint + else { throw T3ConnectRelayError.environmentMismatch } + guard !response.credential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !response.expiresAt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3ConnectRelayError.invalidResponse + } + return T3ConnectManagedEnvironmentCredential( + environmentID: response.environmentId, + label: environment.label, + endpoint: response.endpoint, + bootstrapCredential: response.credential, + bootstrapExpiresAt: response.expiresAt, + proofKeyThumbprint: thumbprint + ) + } + + public func registerDevice( + _ registration: T3ConnectDeviceRegistration, + clerkToken: String + ) async throws { + guard registration.iosMajorVersion >= 18 else { + throw T3ConnectRelayError.invalidConfiguration( + "T3 Connect device registration requires iOS 18 or newer." + ) + } + let body = try JSONEncoder.t3.encode(registration) + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "devices"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func registerLiveActivity( + _ registration: T3ConnectLiveActivityRegistration, + clerkToken: String + ) async throws { + let body = try JSONEncoder.t3.encode(registration) + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "live-activities"], + method: "POST", + body: body, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func unregisterDevice(deviceID: String, clerkToken: String) async throws { + let response: OKResponse = try await sendDPoP( + path: ["v1", "mobile", "devices", deviceID], + method: "DELETE", + body: nil, + clerkToken: clerkToken, + scopes: [.mobileRegistration] + ) + try requireOK(response) + } + + public func clearTokenCache() { + cachedTokens.removeAll() + } + + private func sendDPoP( + path: [String], + method: String, + body: Data?, + clerkToken: String, + scopes: [T3ConnectRelayScope] + ) async throws -> Response { + let target = endpoint(path) + let authorization = try await authorize( + clerkToken: clerkToken, + scopes: scopes, + method: method, + url: target + ) + var request = URLRequest(url: target) + request.httpMethod = method + request.httpBody = body + request.setValue( + "DPoP \(authorization.accessToken)", + forHTTPHeaderField: "Authorization" + ) + request.setValue(authorization.proof, forHTTPHeaderField: "DPoP") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + do { + return try await send(request, as: Response.self) + } catch let error as T3ConnectRelayError where error.isRejectedAuthorization { + cachedTokens.removeValue(forKey: tokenCacheKey(scopes, clerkToken: clerkToken)) + let refreshed = try await authorize( + clerkToken: clerkToken, + scopes: scopes, + method: method, + url: target + ) + request.setValue("DPoP \(refreshed.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(refreshed.proof, forHTTPHeaderField: "DPoP") + return try await send(request, as: Response.self) + } + } + + private func authorize( + clerkToken: String, + scopes: [T3ConnectRelayScope], + method: String, + url: URL + ) async throws -> (accessToken: String, proof: String) { + let thumbprint = try await signer.thumbprint() + let cacheKey = tokenCacheKey(scopes, clerkToken: clerkToken) + let token: CachedToken + if let cached = cachedTokens[cacheKey], + cached.thumbprint == thumbprint, + cached.expiresAt.timeIntervalSinceNow > 5 + { + token = cached + } else { + token = try await exchangeRelayAccessToken( + clerkToken: clerkToken, + scopes: scopes, + thumbprint: thumbprint + ) + cachedTokens[cacheKey] = token + } + let proof = try await signer.proof( + method: method, + url: url, + accessToken: token.accessToken + ) + return (token.accessToken, proof.value) + } + + private func exchangeRelayAccessToken( + clerkToken: String, + scopes: [T3ConnectRelayScope], + thumbprint: String + ) async throws -> CachedToken { + let target = endpoint(["v1", "client", "dpop-token"]) + let proof = try await signer.proof(method: "POST", url: target) + let requestedScopes = scopes.map(\.rawValue).sorted() + let fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": clerkToken, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "resource": configuration.relayHTTPURL.absoluteString, + "scope": requestedScopes.joined(separator: " "), + "client_id": "t3-mobile", + ] + var request = URLRequest(url: target) + request.httpMethod = "POST" + request.httpBody = Self.formEncoded(fields) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue(proof.value, forHTTPHeaderField: "DPoP") + let response = try await send(request, as: T3ConnectRelayAccessToken.self) + let granted = Set(response.scope.split(separator: " ").map(String.init)) + guard granted == Set(requestedScopes) else { + throw T3ConnectRelayError.unexpectedScope( + requested: requestedScopes, + granted: response.scope + ) + } + guard !response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + response.issuedTokenType + == "urn:ietf:params:oauth:token-type:access_token", + response.tokenType == "DPoP", + response.expiresIn > 0 else { + throw T3ConnectRelayError.invalidResponse + } + return CachedToken( + accessToken: response.accessToken, + expiresAt: Date().addingTimeInterval(TimeInterval(response.expiresIn)), + scopes: scopes, + thumbprint: thumbprint + ) + } + + private func request(path: [String], method: String) -> URLRequest { + var request = URLRequest(url: endpoint(path)) + request.httpMethod = method + return request + } + + private func jsonRequest( + path: [String], + method: String, + payload: Payload + ) throws -> URLRequest { + var request = request(path: path, method: method) + request.httpBody = try JSONEncoder.t3.encode(payload) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } + + private func endpoint(_ path: [String]) -> URL { + var components = URLComponents( + url: configuration.relayHTTPURL, + resolvingAgainstBaseURL: false + ) + components?.path = "" + components?.query = nil + components?.fragment = nil + let origin = components?.url ?? configuration.relayHTTPURL + return path.reduce(origin) { partial, component in + partial.appendingPathComponent(component) + } + } + + private func requireOK(_ response: OKResponse) throws { + guard response.ok else { throw T3ConnectRelayError.invalidResponse } + } + + private func send( + _ request: URLRequest, + as type: Response.Type + ) async throws -> Response { + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(T3ConnectRelayErrorBody.self, from: data) + throw T3ConnectRelayError.response( + status: response.statusCode, + message: body.map { + T3ConnectRelayErrorPresentation.message( + for: $0, + requestUsesDPoP: request.value(forHTTPHeaderField: "DPoP") != nil + ) + } + ?? "T3 Connect request failed.", + traceID: body?.traceId + ) + } + do { + return try JSONDecoder.t3.decode(type, from: data) + } catch { + throw T3ConnectRelayError.invalidResponse + } + } + + private func tokenCacheKey( + _ scopes: [T3ConnectRelayScope], + clerkToken: String + ) -> String { + let account = Self.clerkSubject(clerkToken) + ?? T3ConnectDPoPSigner.accessTokenHash(clerkToken) + return "\(account)|\(scopes.map(\.rawValue).sorted().joined(separator: " "))" + } + + /// JWT claims are used only as a cache partition, never as authentication. + /// If Clerk changes token shape, the opaque token hash remains safe. + private static func clerkSubject(_ token: String) -> String? { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + var base64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + guard let data = Data(base64Encoded: base64), + let claims = try? JSONDecoder().decode(ClerkCacheClaims.self, from: data), + claims.sub.isEmpty == false else { return nil } + return claims.sub + } + + private static func formEncoded(_ fields: [String: String]) -> Data { + var components = URLComponents() + components.queryItems = fields.keys.sorted().map { + URLQueryItem(name: $0, value: fields[$0]) + } + return Data((components.percentEncodedQuery ?? "").utf8) + } + + private struct ConnectRequest: Encodable { + let deviceId: String? + let clientProofKeyThumbprint: String + } + + private struct ClerkCacheClaims: Decodable { + let sub: String + } +} + +private extension T3ConnectRelayError { + var isRejectedAuthorization: Bool { + guard case let .response(status, _, _) = self else { return false } + return status == 401 + } +} diff --git a/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift b/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift new file mode 100644 index 000000000000..dcb59d862ad0 --- /dev/null +++ b/apps/swift-ios/App/Cloud/T3ConnectRelayModels.swift @@ -0,0 +1,261 @@ +import Foundation + +public enum T3ConnectRelayScope: String, Codable, CaseIterable, Sendable { + case environmentConnect = "environment:connect" + case environmentStatus = "environment:status" + case mobileRegistration = "mobile:registration" +} + +public enum T3ConnectManagedEndpointProvider: String, Codable, Sendable { + case manual + case cloudflareTunnel = "cloudflare_tunnel" + case t3Relay = "t3_relay" +} + +public struct T3ConnectManagedEndpoint: Codable, Equatable, Sendable { + public let httpBaseUrl: String + public let wsBaseUrl: String + public let providerKind: T3ConnectManagedEndpointProvider + + public var httpBaseURL: URL? { URL(string: httpBaseUrl) } + public var webSocketBaseURL: URL? { URL(string: wsBaseUrl) } +} + +public struct T3ConnectRelayEnvironment: Codable, Identifiable, Equatable, Sendable { + public var id: String { environmentId } + + public let environmentId: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let linkedAt: String +} + +public struct T3ConnectRelayEnvironmentStatus: Codable, Equatable, Sendable { + public enum Value: String, Codable, Sendable { + case online + case offline + } + + public let environmentId: String + public let endpoint: T3ConnectManagedEndpoint + public let status: Value + public let checkedAt: String + public let descriptor: EnvironmentDescriptor? + public let error: String? + public let traceId: String? +} + +public struct T3ConnectManagedEnvironmentCredential: Codable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let bootstrapCredential: String + public let bootstrapExpiresAt: String + public let proofKeyThumbprint: String +} + +public struct T3ConnectEnvironmentLinkChallengeRequest: Codable, Equatable, Sendable { + public let notificationsEnabled: Bool + public let liveActivitiesEnabled: Bool + public let managedTunnelsEnabled: Bool + + public init( + notificationsEnabled: Bool, + liveActivitiesEnabled: Bool, + managedTunnelsEnabled: Bool = true + ) { + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.managedTunnelsEnabled = managedTunnelsEnabled + } +} + +public struct T3ConnectEnvironmentLinkChallenge: Codable, Equatable, Sendable { + public let challenge: String + public let expiresAt: String +} + +public struct T3ConnectEnvironmentLinkRequest: Codable, Equatable, Sendable { + public let deviceId: String? + public let proof: String + public let notificationsEnabled: Bool + public let liveActivitiesEnabled: Bool + public let managedTunnelsEnabled: Bool + + public init( + deviceID: String? = nil, + proof: String, + notificationsEnabled: Bool, + liveActivitiesEnabled: Bool, + managedTunnelsEnabled: Bool = true + ) { + deviceId = deviceID + self.proof = proof + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.managedTunnelsEnabled = managedTunnelsEnabled + } +} + +public struct T3ConnectManagedEndpointRuntime: Codable, Equatable, Sendable { + public let providerKind: T3ConnectManagedEndpointProvider + public let connectorToken: String + public let tunnelId: String? + public let tunnelName: String? +} + +public struct T3ConnectEnvironmentLinkResponse: Codable, Equatable, Sendable { + public let ok: Bool + public let cloudUserId: String + public let environmentId: String + public let endpoint: T3ConnectManagedEndpoint + public let endpointRuntime: T3ConnectManagedEndpointRuntime? + public let relayIssuer: String + public let environmentCredential: String + public let cloudMintPublicKey: String +} + +public struct T3ConnectDevicePreferences: Codable, Equatable, Sendable { + public let liveActivitiesEnabled: Bool + public let notificationsEnabled: Bool + public let notifyOnApproval: Bool + public let notifyOnInput: Bool + public let notifyOnCompletion: Bool + public let notifyOnFailure: Bool + + public init( + liveActivitiesEnabled: Bool = true, + notificationsEnabled: Bool = true, + notifyOnApproval: Bool = true, + notifyOnInput: Bool = true, + notifyOnCompletion: Bool = true, + notifyOnFailure: Bool = true + ) { + self.liveActivitiesEnabled = liveActivitiesEnabled + self.notificationsEnabled = notificationsEnabled + self.notifyOnApproval = notifyOnApproval + self.notifyOnInput = notifyOnInput + self.notifyOnCompletion = notifyOnCompletion + self.notifyOnFailure = notifyOnFailure + } +} + +public struct T3ConnectDeviceRegistration: Codable, Equatable, Sendable { + public enum APNSEnvironment: String, Codable, Sendable { + case sandbox + case production + } + + public let deviceId: String + public let label: String + public let platform: String + public let iosMajorVersion: Int + public let appVersion: String? + public let bundleId: String? + public let apsEnvironment: APNSEnvironment? + public let pushToken: String? + public let pushToStartToken: String? + public let preferences: T3ConnectDevicePreferences + + public init( + deviceID: String, + label: String, + iosMajorVersion: Int, + appVersion: String? = nil, + bundleID: String? = nil, + apsEnvironment: APNSEnvironment? = nil, + pushToken: String? = nil, + pushToStartToken: String? = nil, + preferences: T3ConnectDevicePreferences = .init() + ) { + deviceId = deviceID + self.label = label + platform = "ios" + self.iosMajorVersion = iosMajorVersion + self.appVersion = appVersion + bundleId = bundleID + self.apsEnvironment = apsEnvironment + self.pushToken = pushToken + self.pushToStartToken = pushToStartToken + self.preferences = preferences + } +} + +public struct T3ConnectLiveActivityRegistration: Codable, Equatable, Sendable { + public let deviceId: String + public let activityPushToken: String + + public init(deviceID: String, activityPushToken: String) { + deviceId = deviceID + self.activityPushToken = activityPushToken + } +} + +public struct T3ConnectRelayDevice: Codable, Identifiable, Equatable, Sendable { + public struct Notifications: Codable, Equatable, Sendable { + public let enabled: Bool + public let notifyOnApproval: Bool + public let notifyOnInput: Bool + public let notifyOnCompletion: Bool + public let notifyOnFailure: Bool + } + + public struct LiveActivities: Codable, Equatable, Sendable { + public let enabled: Bool + } + + public var id: String { deviceId } + + public let deviceId: String + public let label: String + public let platform: String + public let iosMajorVersion: Int + public let appVersion: String? + public let notifications: Notifications + public let liveActivities: LiveActivities + public let updatedAt: String +} + +public struct T3ConnectRelayAccessToken: Codable, Equatable, Sendable { + public let accessToken: String + public let issuedTokenType: String + public let tokenType: String + public let expiresIn: Int + public let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} + +public struct T3ConnectEnvironmentAccessToken: Codable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let endpoint: T3ConnectManagedEndpoint + public let accessToken: String + public let expiresAt: Date + public let scopes: [String] + public let proofKeyThumbprint: String + + public init( + environmentID: String, + label: String, + endpoint: T3ConnectManagedEndpoint, + accessToken: String, + expiresAt: Date, + scopes: [String], + proofKeyThumbprint: String + ) { + self.environmentID = environmentID + self.label = label + self.endpoint = endpoint + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + self.proofKeyThumbprint = proofKeyThumbprint + } +} diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift new file mode 100644 index 000000000000..5ee47a5d1068 --- /dev/null +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -0,0 +1,6788 @@ +import Foundation +import OSLog + +extension FeatureInputAnswer { + var jsonValue: JSONValue { + switch self { + case let .text(value): + .string(value) + case let .selections(values): + .array(values.map(JSONValue.string)) + } + } +} + +private struct T3ConnectManagedCleanupError: LocalizedError { + let failureCount: Int + + var errorDescription: String? { + "Couldn’t remove \(failureCount) managed T3 Connect " + + (failureCount == 1 ? "environment." : "environments.") + } +} + +/// Composes the transport-focused Core layer with the UI-focused Features layer. +@MainActor +final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, + FeatureProjectCreationClient, FeatureWorkspaceAssetResolving, + FeatureFeedbackSubmitting, T3ConnectCapable +{ + private static let maximumRetainedThreadDetails = 6 + private static let t3ConnectLogger = Logger( + subsystem: "codes.t3.swift-ios", + category: "T3Connect" + ) + private static let initialThreadUserTurnLimit = 10 + private static let olderThreadPageUserTurnLimit = 20 + private static let projectFaviconRefreshInterval: TimeInterval = 15 * 60 + private static let projectFaviconFallbackMarker = "project-favicon-missing" + + private let runtime: EnvironmentRuntime + let t3ConnectController: T3ConnectController + private let t3ConnectDeviceManager: any T3ConnectDeviceManaging + private let hasMatchingT3ConnectController: Bool + private let settingsStore: UserDefaults + private let projectFaviconStore: FeatureProjectFaviconStore + private let fallbackPollingInitialDelay: Duration + private let fallbackPollingInterval: Duration + private let aggregateRefreshInterval: Duration + private let environmentShellTimeoutInterval: TimeInterval + private let aggregateEnvironmentLoader: @Sendable (EnvironmentRuntime) async throws -> [Environment] + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + private var activeEnvironment: Environment? + private var client: T3Client? + private var latestShell: OrchestrationShellSnapshot? + private var environmentClients: [String: T3Client] = [:] + private var shellsByEnvironmentID: [String: OrchestrationShellSnapshot] = [:] + private var archivedThreadsByEnvironmentID: [String: [FeatureThread]] = [:] + private var archivedShellThreadsByEnvironmentID: [ + String: [String: OrchestrationThreadShell] + ] = [:] + private var projectEnvironmentIDs: [String: String] = [:] + private var projectWireIDs: [String: String] = [:] + private var threadEnvironmentIDs: [String: String] = [:] + private var threadWireIDs: [String: String] = [:] + private var provisionalThreadRoutes: [String: ProvisionalThreadRoute] = [:] + private var pendingThreadCreations: [PendingThreadCreation] = [] + private var environmentConnectionStates: [String: FeatureConnection.State] = [:] + private var environmentConnectionDetails: [String: String] = [:] + private var latestServerConfig: ServerConfigSnapshot? + private var serverConfigsByEnvironmentID: [String: ServerConfigSnapshot] = [:] + private var latestSnapshot: FeatureSnapshot? + private var activeThreadID: String? + private var activeThreadEnvironmentID: String? + private var latestDetails: [String: FeatureThreadDetail] = [:] + private var detailRenderCaches: [String: NativeDetailRenderCache] = [:] + private var detailCacheRecency: [String] = [] + private var attachmentURLs: [AttachmentCacheKey: CachedAttachmentURL] = [:] + private var projectFaviconRefreshTasks: [ + FeatureProjectFaviconCacheKey: Task + ] = [:] + private var sourceControlMonitors: [ + NativeSourceControlMonitorKey: NativeSourceControlMonitor + ] = [:] + private var pendingBootstrapSubmissions: [PendingBootstrapSubmission] = [] + private var pendingTurnSubmissions: [String: PendingTurnSubmission] = [:] + private var attachmentHydrationTasks: [ + String: (id: UUID, task: Task) + ] = [:] + private var approvalRoutes: [String: PendingRequestRoute] = [:] + private var inputRoutes: [String: PendingRequestRoute] = [:] + private var relayDeviceSessionIDs: Set = [] + private struct TerminalKey: Hashable { + let threadID: String + let terminalID: String + } + + private var terminalSnapshots: [TerminalKey: FeatureTerminalSnapshot] = [:] + private var pollingTask: Task? + private var fallbackPollingTask: Task? + private var configurationTask: Task? + private var aggregateRefreshTask: Task? + private var aggregateRefreshID: UUID? + private var shellPublishTask: Task? + private var archivedRefreshTask: Task? + private var detailRefreshTask: Task? + private var detailStreamTask: Task? + private var detailPublishTask: Task? + private var passiveDetailPollingTask: Task? + private var detailRefreshPending = false + private var detailRefreshGeneration = 0 + private var detailStreamGeneration = 0 + private var pendingDetailRenderMutations = NativeDetailRenderMutations() + private var environmentGeneration = 0 + private var lastShellEventAt: Date? + private var activeRawThread: OrchestrationThread? + private var activeThreadSequence: Int? + private var activeThreadPage: FeatureThreadPage? + private var threadHistoryEpoch = 0 + private var pendingOlderThreadPage: PendingOlderThreadPage? + + init( + runtime: EnvironmentRuntime? = nil, + t3ConnectController: T3ConnectController? = nil, + t3ConnectDeviceManager: (any T3ConnectDeviceManaging)? = nil, + settingsStore: UserDefaults = .standard, + projectFaviconStore: FeatureProjectFaviconStore = FeatureProjectFaviconStore(), + fallbackPollingInitialDelay: Duration = .seconds(3), + fallbackPollingInterval: Duration = .seconds(2), + aggregateRefreshInterval: Duration = .seconds(20), + environmentShellTimeoutInterval: TimeInterval = 6, + aggregateEnvironmentLoader: @escaping @Sendable (EnvironmentRuntime) async throws -> [Environment] = { + try await $0.environments() + } + ) { + let controller: T3ConnectController + if let t3ConnectController { + controller = t3ConnectController + } else if let runtime { + controller = T3ConnectController( + resolution: .unavailable( + reason: runtime.supportsManagedAuthorization + ? "This client runtime requires its matching T3 Connect controller." + : "This client runtime was created without T3 Connect authorization." + ) + ) + } else { + controller = T3ConnectController() + } + self.t3ConnectController = controller + self.t3ConnectDeviceManager = t3ConnectDeviceManager ?? controller + hasMatchingT3ConnectController = t3ConnectController != nil || runtime == nil + self.runtime = runtime ?? EnvironmentRuntime( + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + self.settingsStore = settingsStore + self.projectFaviconStore = projectFaviconStore + self.fallbackPollingInitialDelay = fallbackPollingInitialDelay + self.fallbackPollingInterval = fallbackPollingInterval + self.aggregateRefreshInterval = aggregateRefreshInterval + self.environmentShellTimeoutInterval = environmentShellTimeoutInterval + self.aggregateEnvironmentLoader = aggregateEnvironmentLoader + let pair = AsyncStream.makeStream() + stream = pair.stream + continuation = pair.continuation + } + + deinit { + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + shellPublishTask?.cancel() + archivedRefreshTask?.cancel() + detailRefreshTask?.cancel() + detailStreamTask?.cancel() + detailPublishTask?.cancel() + passiveDetailPollingTask?.cancel() + attachmentHydrationTasks.values.forEach { $0.task.cancel() } + projectFaviconRefreshTasks.values.forEach { $0.cancel() } + continuation.finish() + } + + func initialSnapshot() async throws -> FeatureSnapshot { + let environments = try await runtime.environments() + guard let activeClient = try await runtime.activeClient() else { + await clearActiveEnvironment() + let snapshot = disconnectedSnapshot(environments: environments) + latestSnapshot = snapshot + return snapshot + } + // The runtime actor can change its active selection at any suspension + // point. Derive both values from one client so the snapshot cannot pair + // one environment with another environment's connection. + let environment = activeClient.environment + + await adoptEnvironment(environment, client: activeClient) + let generation = environmentGeneration + let loads = await loadEnvironmentShells(environments.filter(\.isEnabled)) + guard isCurrentSession(client: activeClient, generation: generation) else { + throw CancellationError() + } + reconcileEnvironmentLoads(loads, savedEnvironments: environments) + latestShell = shellsByEnvironmentID[environment.id] + startPolling(activeClient) + let activeIsReachable = loads.contains { + $0.environment.id == environment.id && $0.shell != nil + } + if activeIsReachable { + scheduleArchivedRefresh(client: activeClient, environment: environment) + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: activeIsReachable ? .connected : .disconnected, + connectionDetail: activeIsReachable ? nil : "That server is currently unreachable." + ) + latestSnapshot = snapshot + return snapshot + } + + func backgroundSnapshot() async throws -> FeatureSnapshot { + let environments = try await runtime.environments() + guard let activeClient = try await runtime.activeClient() else { + return disconnectedSnapshot(environments: environments) + } + let environment = activeClient.environment + let loads = await loadEnvironmentShells(environments.filter(\.isEnabled)) + guard let currentClient = try await runtime.activeClient(), + currentClient.environment.id == environment.id else { + throw CancellationError() + } + + reconcileEnvironmentLoads(loads, savedEnvironments: environments) + let activeIsReachable = loads.contains { + $0.environment.id == environment.id && $0.shell != nil + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: activeIsReachable ? .connected : .disconnected, + connectionDetail: activeIsReachable ? nil : "That server is currently unreachable." + ) + latestSnapshot = snapshot + return snapshot + } + + func events() -> AsyncStream { + stream + } + + func pair(endpoint: String, token: String?) async throws { + let pairedClient: T3Client + if let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + pairedClient = try await runtime.pair( + host: endpoint, + code: token, + clientLabel: "T3 Code Swift" + ) + } else { + pairedClient = try await runtime.pair(url: endpoint, clientLabel: "T3 Code Swift") + } + await adoptEnvironment(pairedClient.environment, client: pairedClient) + startPolling(pairedClient) + } + + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws { + guard hasMatchingT3ConnectController else { + throw T3ConnectRelayError.invalidConfiguration( + "This client runtime requires its matching T3 Connect controller." + ) + } + guard runtime.supportsManagedAuthorization else { + throw T3ConnectRelayError.invalidConfiguration( + "This client runtime was created without T3 Connect authorization." + ) + } + guard credential.environmentID.isEmpty == false, + let httpBaseURL = credential.endpoint.httpBaseURL, + let webSocketBaseURL = credential.endpoint.webSocketBaseURL, + httpBaseURL.scheme?.lowercased() == "https", + webSocketBaseURL.scheme?.lowercased() == "wss", + let httpHost = httpBaseURL.host, + let webSocketHost = webSocketBaseURL.host, + httpHost.caseInsensitiveCompare(webSocketHost) == .orderedSame, + (httpBaseURL.port ?? 443) == (webSocketBaseURL.port ?? 443) else { + throw T3ConnectRelayError.invalidConfiguration( + "The managed environment endpoint is invalid." + ) + } + + let descriptor = try await runtime.descriptor(at: httpBaseURL) + guard descriptor.environmentId == credential.environmentID else { + throw T3ConnectRelayError.environmentMismatch + } + let authorization = try await t3ConnectController.managedAuthorizer.exchange( + credential, + clientLabel: "T3 Code SwiftUI" + ) + guard authorization.environmentID == descriptor.environmentId, + authorization.endpoint == credential.endpoint, + authorization.proofKeyThumbprint == credential.proofKeyThumbprint else { + throw T3ConnectRelayError.environmentMismatch + } + + let environment = Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: httpBaseURL, + webSocketBaseURL: webSocketBaseURL, + kind: .managedDPoP, + descriptor: descriptor + ) + let savedCredential = EnvironmentCredential.managedDPoP( + accessToken: authorization.accessToken, + expiresAt: authorization.expiresAt, + scopes: authorization.scopes, + environmentID: authorization.environmentID, + proofKeyThumbprint: authorization.proofKeyThumbprint + ) + let managedClient = try await runtime.saveManagedEnvironment( + environment, + credential: savedCredential + ) + await adoptEnvironment(environment, client: managedClient) + do { + try await refresh(client: managedClient) + } catch { + let environments = (try? await runtime.environments()) ?? [environment] + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: .connecting, + connectionDetail: "Connected securely. Loading this environment." + ) + publish(snapshot) + } + startPolling(managedClient) + } + + func signOutT3Connect() async { + // Clear the account and relay-token cache even when Clerk's remote + // sign-out fails, then revoke every locally minted managed credential. + // Manual pairings are device-owned and deliberately survive sign-out. + await t3ConnectController.signOut() + do { + let managedIDs = try await runtime.environments() + .filter { $0.kind == .managedDPoP } + .map(\.id) + var failureCount = 0 + for id in managedIDs { + var cleanupFailed = false + do { + try await runtime.revokeCredential(id: id) + } catch { + cleanupFailed = true + Self.t3ConnectLogger.error( + "Managed credential revocation failed: \(error.localizedDescription, privacy: .private)" + ) + } + do { + try await removeEnvironment(id: id) + // `remove` retries credential deletion, so its success + // supersedes an earlier revocation error. + cleanupFailed = false + } catch { + cleanupFailed = true + Self.t3ConnectLogger.error( + "Managed environment removal failed: \(error.localizedDescription, privacy: .private)" + ) + } + if cleanupFailed { + failureCount += 1 + } + } + guard failureCount == 0 else { + throw T3ConnectManagedCleanupError(failureCount: failureCount) + } + Self.t3ConnectLogger.info("Cleared managed T3 Connect runtime state") + } catch { + Self.t3ConnectLogger.error( + "Managed T3 Connect cleanup failed: \(error.localizedDescription, privacy: .private)" + ) + t3ConnectController.errorMessage = error.localizedDescription + } + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws { + try await runtime.setEnabled(id: id, enabled: enabled) + if !enabled { + environmentConnectionStates[id] = .disconnected + environmentConnectionDetails[id] = nil + environmentClients[id] = nil + shellsByEnvironmentID[id] = nil + serverConfigsByEnvironmentID[id] = nil + providerCatalogCache[id] = nil + archivedThreadsByEnvironmentID[id] = nil + archivedShellThreadsByEnvironmentID[id] = nil + } + } + + func removeEnvironment(id: String) async throws { + let removesActiveEnvironment = activeEnvironment?.id == id + let environment = try await runtime.environments().first { $0.id == id } + if environment?.kind == .managedDPoP { + try await runtime.revokeCredential(id: id) + } + try await runtime.remove(id: id) + if removesActiveEnvironment { + await clearActiveEnvironment(disconnectClient: false) + } + } + + func disconnect() async { + await clearActiveEnvironment() + } + + func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.prism(input) + } + + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { + let environments = try await runtime.environments().filter(\.isEnabled) + let runtime = runtime + let order = Dictionary(uniqueKeysWithValues: environments.enumerated().map { + ($0.element.id, $0.offset) + }) + + let results = try await withThrowingTaskGroup( + of: FeatureEnvironmentUsage.self, + returning: [FeatureEnvironmentUsage].self + ) { group in + for environment in environments { + group.addTask { + let probe = await runtime.ephemeralClient(for: environment) + do { + let summary = try await probe.usageSummary(input) + await probe.disconnect() + return FeatureEnvironmentUsage( + environmentID: environment.id, + label: environment.label, + summary: summary, + errorMessage: nil + ) + } catch is CancellationError { + await probe.disconnect() + throw CancellationError() + } catch { + await probe.disconnect() + return FeatureEnvironmentUsage( + environmentID: environment.id, + label: environment.label, + summary: nil, + errorMessage: "This environment could not report usage." + ) + } + } + } + + var results: [FeatureEnvironmentUsage] = [] + results.reserveCapacity(environments.count) + for try await result in group { + results.append(result) + } + return results + } + return results.sorted { + order[$0.environmentID, default: .max] < order[$1.environmentID, default: .max] + } + } + + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + try await pullRequestLists(input, inEnvironment: nil) + } + + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + try await pullRequestLists(input, inEnvironment: environmentID) + } + + private func pullRequestLists( + _ input: PullRequestListInput, + inEnvironment environmentID: String? + ) async throws -> [FeaturePullRequestEnvironmentList] { + let environments = try await runtime.environments().filter { + $0.isEnabled + && $0.descriptor?.capabilities.pullRequests == true + && (environmentID == nil || $0.id == environmentID) + } + let runtime = runtime + return await withTaskGroup(of: FeaturePullRequestEnvironmentList.self) { group in + for environment in environments { + group.addTask { + let probe = await runtime.ephemeralClient(for: environment) + do { + let result = try await probe.pullRequests(input) + await probe.disconnect() + return FeaturePullRequestEnvironmentList( + environmentID: environment.id, + environmentName: environment.label, + result: result, + errorMessage: nil + ) + } catch { + await probe.disconnect() + return FeaturePullRequestEnvironmentList( + environmentID: environment.id, + environmentName: environment.label, + result: nil, + errorMessage: error.localizedDescription + ) + } + } + } + var results: [FeaturePullRequestEnvironmentList] = [] + for await result in group { results.append(result) } + return results.sorted { $0.environmentName < $1.environmentName } + } + } + + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestDetail(target.reference) + } + + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestActivity(target.reference) + } + + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + { + try await projectCreationClient(environmentID: target.environmentID).pullRequestDiff( + PullRequestDiffInput( + projectId: target.reference.projectId, + repository: target.reference.repository, + number: target.reference.number, + cursor: cursor, + commit: nil + ) + ) + } + + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws { + try await projectCreationClient(environmentID: target.environmentID).runPullRequestAction( + target.reference, + action: action, + mergeMethod: mergeMethod, + updateMethod: updateMethod + ) + } + + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws { + try await projectCreationClient(environmentID: target.environmentID).updatePullRequest( + target.reference, + title: title, + body: body + ) + } + + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .commentOnPullRequest(target.reference, body: body) + } + + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .submitPullRequestReview( + target.reference, + verdict: verdict, + body: body, + comments: comments + ) + } + + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .replyToPullRequestThread(target.reference, threadID: threadID, body: body) + } + + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .setPullRequestThreadResolved( + target.reference, + threadID: threadID, + resolved: resolved + ) + } + + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .setPullRequestReaction( + target.reference, + subjectID: subjectID, + content: content, + reacted: reacted + ) + } + + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + { + try await projectCreationClient(environmentID: target.environmentID) + .pullRequestReviewerCandidates(target.reference) + } + + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { + try await projectCreationClient(environmentID: target.environmentID) + .requestPullRequestReviewers( + target.reference, + reviewers: reviewers, + requested: requested + ) + } + + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws { + if let target { + try await projectCreationClient(environmentID: target.environmentID) + .invalidatePullRequests(target.reference) + return + } + let environments = try await runtime.environments().filter(\.isEnabled) + for environment in environments { + try? await projectCreationClient(environmentID: environment.id).invalidatePullRequests() + } + } + + private func adoptEnvironment( + _ environment: Environment, + client newClient: T3Client + ) async { + if activeEnvironment?.id == environment.id, client === newClient { + activeEnvironment = environment + environmentClients[environment.id] = newClient + latestShell = shellsByEnvironmentID[environment.id] + startAggregateRefresh(newClient) + return + } + let previousClient = client + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + archivedRefreshTask?.cancel() + passiveDetailPollingTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + aggregateRefreshTask = nil + aggregateRefreshID = nil + archivedRefreshTask = nil + passiveDetailPollingTask = nil + clearEnvironmentState(preserveEnvironmentSnapshots: true) + activeEnvironment = environment + client = newClient + environmentClients[environment.id] = newClient + latestShell = shellsByEnvironmentID[environment.id] + if let previousClient, previousClient !== newClient { + await previousClient.disconnect() + } + startAggregateRefresh(newClient) + } + + private func clearActiveEnvironment(disconnectClient: Bool = true) async { + let previousClient = client + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + aggregateRefreshTask?.cancel() + archivedRefreshTask?.cancel() + passiveDetailPollingTask?.cancel() + pollingTask = nil + fallbackPollingTask = nil + configurationTask = nil + aggregateRefreshTask = nil + aggregateRefreshID = nil + archivedRefreshTask = nil + passiveDetailPollingTask = nil + clearEnvironmentState() + client = nil + activeEnvironment = nil + if disconnectClient, let previousClient { + await previousClient.disconnect() + } + } + + private func clearEnvironmentState(preserveEnvironmentSnapshots: Bool = false) { + environmentGeneration &+= 1 + resetDetailRefresh() + resetDetailStream() + attachmentHydrationTasks.values.forEach { $0.task.cancel() } + attachmentHydrationTasks.removeAll() + archivedRefreshTask?.cancel() + archivedRefreshTask = nil + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = nil + lastShellEventAt = nil + latestServerConfig = nil + if !preserveEnvironmentSnapshots { + environmentClients.removeAll() + shellsByEnvironmentID.removeAll() + serverConfigsByEnvironmentID.removeAll() + providerCatalogCache.removeAll() + archivedThreadsByEnvironmentID.removeAll() + archivedShellThreadsByEnvironmentID.removeAll() + projectEnvironmentIDs.removeAll() + projectWireIDs.removeAll() + threadEnvironmentIDs.removeAll() + threadWireIDs.removeAll() + provisionalThreadRoutes.removeAll() + environmentConnectionStates.removeAll() + environmentConnectionDetails.removeAll() + } + latestSnapshot = nil + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + latestDetails.removeAll() + detailRenderCaches.removeAll() + detailCacheRecency.removeAll() + attachmentURLs.removeAll() + pendingBootstrapSubmissions.removeAll() + pendingTurnSubmissions.removeAll() + approvalRoutes.removeAll() + inputRoutes.removeAll() + terminalSnapshots.removeAll() + } + + private func isCurrentSession(client: T3Client, generation: Int) -> Bool { + guard generation == environmentGeneration, let currentClient = self.client else { + return false + } + return currentClient === client + } + + private func isKnownClient( + _ client: T3Client, + environmentID: String, + generation: Int + ) -> Bool { + generation == environmentGeneration + && environmentClients[environmentID] === client + } + + func addProject(path: String) async throws { + guard let environmentID = activeEnvironment?.id else { + throw NativeFeatureClientError.notConnected + } + try await addProject(environmentID: environmentID, path: path) + } + + func addProject(environmentID: String, path: String) async throws { + let client = try await projectCreationClient(environmentID: environmentID) + try await createProject(client: client, path: path) + } + + func browseProjectFolders( + environmentID: String, + partialPath: String + ) async throws -> FilesystemBrowseResult { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.browseFilesystem(partialPath: partialPath) + } + + func workspaceAssetURL(threadID: String, path: String) async throws -> URL { + let route = try threadRoute(for: threadID) + return try await route.client.resolvedAssetURL( + resource: .workspaceFile(threadID: route.wireID, path: path) + ) + } + + func mediaAssetURL(threadID: String, path: String) async throws -> URL { + let route = try threadRoute(for: threadID) + do { + return try await route.client.resolvedAssetURL( + resource: .mediaFile(threadID: route.wireID, path: path) + ) + } catch let RPCError.remote(message) + where message.localizedCaseInsensitiveContains("media-file") + && (message.localizedCaseInsensitiveContains("schema") + || message.localizedCaseInsensitiveContains("unsupported") + || message.localizedCaseInsensitiveContains("unknown tag") + || message.localizedCaseInsensitiveContains("unknown discriminator")) { + return try await route.client.resolvedAssetURL( + resource: .workspaceFile(threadID: route.wireID, path: path) + ) + } + } + + func submitCodexFeedback(threadID: String, reason: String?) async throws -> String { + let route = try threadRoute(for: threadID) + return try await route.client.uploadFeedback( + threadID: route.wireID, + reason: reason + ).feedbackId + } + + func cachedProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? { + let key = FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) + return try? await projectFaviconStore.value(for: key)?.data + } + + func refreshProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? { + let key = FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) + let cached = try? await projectFaviconStore.value(for: key) + if let cached, + Date.now.timeIntervalSince(cached.lastCheckedAt) + < Self.projectFaviconRefreshInterval { + return cached.data + } + if let task = projectFaviconRefreshTasks[key] { + return await task.value + } + + guard let client = environmentClients[environmentID] else { + try? await projectFaviconStore.record( + data: nil, + revision: nil, + for: key + ) + return cached?.data + } + + let store = projectFaviconStore + let task = Task { + do { + let resolved = try await client.resolvedAsset( + resource: .projectFavicon(cwd: workspaceRoot) + ) + let revision = resolved.url.lastPathComponent.removingPercentEncoding + ?? resolved.url.lastPathComponent + if revision == Self.projectFaviconFallbackMarker { + try await store.record(data: nil, revision: nil, for: key) + return cached?.data + } + if cached?.revision == revision, cached?.data != nil { + try await store.record(data: nil, revision: revision, for: key) + return cached?.data + } + + let (data, response) = try await URLSession.shared.data(from: resolved.url) + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + !data.isEmpty, + data.count <= FeatureProjectFaviconStore.maximumDataSize else { + throw CocoaError(.fileReadCorruptFile) + } + guard let renderable = await FeatureProjectFaviconImageDecoder.renderableData( + from: data + ) else { + throw CocoaError(.fileReadCorruptFile) + } + try await store.record(data: renderable, revision: revision, for: key) + return renderable + } catch { + try? await store.record(data: nil, revision: nil, for: key) + return cached?.data + } + } + projectFaviconRefreshTasks[key] = task + let value = await task.value + projectFaviconRefreshTasks[key] = nil + return value + } + + func discoverProjectSources( + environmentID: String + ) async throws -> SourceControlDiscoveryResult { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.discoverSourceControl() + } + + func lookupProjectRepository( + environmentID: String, + provider: SourceControlProviderKind, + repository: String + ) async throws -> SourceControlRepositoryInfo { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.lookupRepository( + provider: provider, + repository: repository + ) + } + + func cloneProjectRepository( + environmentID: String, + remoteURL: String, + destinationPath: String + ) async throws -> SourceControlCloneResult { + let client = try await projectCreationClient(environmentID: environmentID) + do { + return try await client.cloneRepository( + remoteURL: remoteURL, + destinationPath: destinationPath + ) + } catch let error as RPCError { + switch error { + case .connectionUnavailable, .disconnected, .responseTimedOut: + // The clone RPC is not receipt-bearing, so a lost reply is + // ambiguous. Confirm the requested destination became a Git + // repository with a primary remote before moving on to the + // independently retryable project-registration step. + if let refs = try? await client.listVCSRefs( + cwd: destinationPath, + refresh: true, + limit: 1 + ), refs.isRepo, refs.hasPrimaryRemote { + return SourceControlCloneResult( + cwd: destinationPath, + remoteUrl: remoteURL, + repository: nil + ) + } + throw error + case .remote, .protocolViolation: + throw error + } + } + } + + private func createProject(client: T3Client, path: String) async throws { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw NativeFeatureClientError.invalidProjectPath + } + let title = ProjectCreationPath.lastPathComponent(trimmed) + let projectID = UUID().uuidString + do { + _ = try await client.createProject( + projectID: projectID, + title: title.isEmpty ? "Project" : title, + workspaceRoot: trimmed, + defaultModel: client.environment.id == activeEnvironment?.id + ? fallbackModelSelection( + environmentID: client.environment.id, + projectID: nil, + shell: shellsByEnvironmentID[client.environment.id] + ) + : nil + ) + } catch { + // The dispatch reply may be lost after the server persisted the + // project. A fresh shell turns that ambiguous failure into success + // and also makes retrying clone registration idempotent. + guard await recoverCreatedProject( + client: client, + projectID: projectID, + path: trimmed + ) else { + throw error + } + return + } + + do { + try await refresh(client: client) + } catch { + guard await recoverCreatedProject( + client: client, + projectID: projectID, + path: trimmed + ) else { + throw error + } + } + } + + private func recoverCreatedProject( + client: T3Client, + projectID: String, + path: String + ) async -> Bool { + let environment = client.environment + let generation = environmentGeneration + guard let fetchedShell = try? await client.shellSnapshot(), + isKnownClient(client, environmentID: environment.id, generation: generation) else { + return false + } + let shell = newestShell(fetchedShell, for: environment) + guard shell.projects.contains(where: { + $0.id == projectID + || ProjectCreationPath.normalizedForComparison($0.workspaceRoot) + == ProjectCreationPath.normalizedForComparison(path) + }) else { + return false + } + rebuildEntityIndexes((try? await runtime.environments()) ?? [environment]) + await emitSnapshot(shell, environment: environment) + return true + } + + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] { + let route = try projectRoute(for: projectID) + let project = try project(for: route) + var refs: [VCSRef] = [] + var cursor: Int? + var seenCursors = Set() + repeat { + let result = try await route.client.listVCSRefs( + cwd: project.workspaceRoot, + cursor: cursor, + refresh: refresh && cursor == nil, + limit: 100 + ) + guard result.isRepo else { return [] } + refs.append(contentsOf: result.refs) + guard let nextCursor = result.nextCursor, + seenCursors.insert(nextCursor).inserted else { + break + } + cursor = nextCursor + } while true + + return refs.map { ref in + FeatureWorkspaceBranch( + name: ref.name, + isRemote: ref.isRemote ?? false, + isCurrent: ref.current, + isDefault: ref.isDefault, + worktreePath: ref.worktreePath + ) + } + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + let route = try projectRoute(for: projectID) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + let model = modelSelection( + selection, + projectID: route.wireID, + environmentID: environment.id, + shell: shellsByEnvironmentID[environment.id] + ) + let resolvedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let threadTitle = resolvedTitle?.isEmpty == false ? resolvedTitle! : "New thread" + let signature = ThreadCreationSignature( + projectID: projectID, + title: threadTitle, + model: model + ) + let pending: PendingThreadCreation + if let existing = pendingThreadCreations.first(where: { $0.signature == signature }) { + pending = existing + } else { + pending = PendingThreadCreation(signature: signature, threadID: UUID().uuidString) + pendingThreadCreations.append(pending) + } + var recoveredShell: OrchestrationShellSnapshot? + do { + _ = try await client.createThread( + threadID: pending.threadID, + projectID: route.wireID, + title: threadTitle, + model: model, + runtimeMode: .fullAccess + ) + } catch { + guard Self.isAmbiguousDispatchFailure(error) else { + removePendingThreadCreation(threadID: pending.threadID) + throw error + } + if let shell = try? await client.shellSnapshot(), + shell.threads.contains(where: { $0.id == pending.threadID }) { + recoveredShell = shell + } else { + // Keep this ID while the outcome is ambiguous. A retry of the + // same creation attempt must not make another thread. + throw error + } + } + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + removePendingThreadCreation(threadID: pending.threadID) + registerProvisionalThread(wireID: pending.threadID, environmentID: environment.id) + let refreshedShell: OrchestrationShellSnapshot? + if let recoveredShell { + refreshedShell = recoveredShell + } else { + refreshedShell = try? await client.shellSnapshot() + } + if let refreshedShell { + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + let shell = newestShell(refreshedShell, for: environment) + await emitSnapshot(shell, environment: environment) + if let created = shell.threads.first(where: { $0.id == pending.threadID }) { + provisionalThreadRoutes[FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + )] = nil + return mapThread(created, environment: environment) + } + } + return FeatureThread( + id: FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + ), + wireID: pending.threadID, + projectID: route.uiID, + environmentID: environment.id, + environmentName: environment.label, + title: threadTitle, + providerID: model.instanceId, + providerName: providerDisplayName(model.instanceId), + modelID: model.model + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false, + attachments: attachments + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSendResolved( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments, + submissionIdentity: nil + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread { + try await createThreadAndSendResolved( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments, + submissionIdentity: identity + ) + } + + private func createThreadAndSendResolved( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + submissionIdentity: FeatureSubmissionIdentity? + ) async throws -> FeatureThread { + let route = try projectRoute(for: projectID) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + let routedProject = try project(for: route) + let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + guard workspaceMode != .worktree || branch?.isEmpty == false else { + throw NativeFeatureClientError.branchRequired + } + let worktreePath = workspaceMode == .local ? worktreePath : nil + let model = modelSelection( + selection, + projectID: route.wireID, + environmentID: environment.id, + shell: shellsByEnvironmentID[environment.id] + ) + let title = Self.title(from: prompt, hasAttachments: !attachments.isEmpty) + let uploads = try makeUploadAttachments(attachments) + if !uploads.isEmpty { _ = try await client.serverConfig() } + let runtime = coreRuntimeMode(runtimeMode) + let interaction = coreInteractionMode(interactionMode) + let signature = BootstrapSubmissionSignature( + projectID: projectID, + prompt: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments + ) + let pending: PendingBootstrapSubmission + let explicitIdentity = submissionIdentity.map { commandIdentity($0) } + if let explicitIdentity, + let existing = pendingBootstrapSubmissions.first(where: { + $0.identity == explicitIdentity + }) { + pending = existing + } else if explicitIdentity == nil, + let existing = pendingBootstrapSubmissions.first(where: { + $0.signature == signature + }) { + pending = existing + } else { + pending = PendingBootstrapSubmission( + signature: signature, + threadID: submissionIdentity?.threadID ?? UUID().uuidString, + identity: explicitIdentity ?? CommandIdentity(), + worktreeBranchName: workspaceMode == .worktree + ? Self.temporaryWorktreeBranchName( + seed: submissionIdentity?.threadID + ) + : nil + ) + pendingBootstrapSubmissions.append(pending) + } + + do { + _ = try await client.createThreadAndSend( + threadID: pending.threadID, + projectID: route.wireID, + title: title, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + branch: branch, + worktreePath: worktreePath, + worktreePreparation: pending.worktreeBranchName.flatMap { worktreeBranch in + branch.map { + ThreadWorktreePreparation( + projectCwd: routedProject.workspaceRoot, + baseBranch: $0, + branch: worktreeBranch, + startFromOrigin: startFromOrigin + ) + } + }, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + // A connection can disappear after the server accepted the command + // but before its reply reaches us. Bootstrap expansion creates the + // thread before dispatching the stable final turn, so recover an + // interrupted empty thread by sending only that original turn. + let recovered = try await recoverBootstrap( + client: client, + pending: pending, + projectID: route.wireID, + text: prompt, + model: model, + runtimeMode: runtime, + interactionMode: interaction, + attachments: uploads + ) + guard recovered else { + await resetFailedBootstrapIfConfirmed( + client: client, + pending: pending, + projectCwd: routedProject.workspaceRoot + ) + throw error + } + } + + registerProvisionalThread(wireID: pending.threadID, environmentID: environment.id) + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + removePendingBootstrap(identity: pending.identity) + // Dispatch acceptance is the commit point. A dropped refresh must not + // turn a successful first turn into a retry that creates a duplicate. + if let refreshedShell = try? await client.shellSnapshot() { + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + let shell = newestShell(refreshedShell, for: environment) + await emitSnapshot(shell, environment: environment) + if let created = shell.threads.first(where: { $0.id == pending.threadID }) { + provisionalThreadRoutes[FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + )] = nil + return mapThread(created, environment: environment) + } + } + return FeatureThread( + id: FeatureScopedID.thread( + environmentID: environment.id, + wireID: pending.threadID + ), + wireID: pending.threadID, + projectID: route.uiID, + environmentID: environment.id, + environmentName: environment.label, + title: title, + branch: workspaceMode == .worktree ? pending.worktreeBranchName : branch, + worktreePath: worktreePath, + providerID: model.instanceId, + providerName: providerDisplayName(model.instanceId), + modelID: model.model, + modelOptions: mapOptionSelections(model.options), + runtimeMode: runtimeMode, + interactionMode: interactionMode.mobileNormalized + ) + } + + private func recoverBootstrap( + client: T3Client, + pending: PendingBootstrapSubmission, + projectID: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode, + attachments: [UploadChatImageAttachment] + ) async throws -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: pending.threadID) else { + return false + } + if snapshot.thread.messages.contains(where: { + $0.id == pending.identity.messageID + }) { + return true + } + guard snapshot.thread.projectId == projectID, + snapshot.thread.deletedAt == nil, + snapshot.thread.messages.isEmpty else { + return false + } + + do { + _ = try await client.sendTurn( + threadID: pending.threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard await messageWasCommitted( + client: client, + threadID: pending.threadID, + messageID: pending.identity.messageID + ) else { + throw error + } + } + return true + } + + /// A failed bootstrap can leave its generated worktree behind after the + /// server rolls back the thread. Only reset the retry identity after a + /// fresh shell confirms the thread is absent; ambiguous network failures + /// keep the stable IDs so the normal recovery path remains idempotent. + private func resetFailedBootstrapIfConfirmed( + client: T3Client, + pending: PendingBootstrapSubmission, + projectCwd: String + ) async { + guard let shell = try? await client.shellSnapshot(), + !shell.threads.contains(where: { $0.id == pending.threadID }) else { + return + } + + if let branch = pending.worktreeBranchName, + let refs = try? await client.listVCSRefs( + cwd: projectCwd, + query: branch, + refresh: true, + limit: 100 + ), + let path = refs.refs.first(where: { + $0.name == branch && $0.isRemote != true + })?.worktreePath { + // Never force-remove: setup scripts may have left useful changes. + // A clean orphan is safe to reclaim; a dirty one remains visible + // through normal worktree management. + try? await client.removeWorktree(cwd: projectCwd, path: path) + } + + removePendingBootstrap(identity: pending.identity) + } + + private func removePendingBootstrap(identity: CommandIdentity) { + pendingBootstrapSubmissions.removeAll { $0.identity == identity } + } + + private func removePendingThreadCreation(threadID: String) { + pendingThreadCreations.removeAll { $0.threadID == threadID } + } + + private static func isAmbiguousDispatchFailure(_ error: any Error) -> Bool { + if let error = error as? RPCError { + switch error { + case .connectionUnavailable, .disconnected, .responseTimedOut: + return true + case .remote, .protocolViolation: + return false + } + } + if let error = error as? HTTPError { + switch error { + case .invalidResponse: + return true + case .status, .missingCredential, .incompatibleCredential, + .managedAuthorizationUnavailable: + return false + } + } + // URL loading errors and cancellation can happen after the request + // body crossed the network. Reusing the ID is safe in either case. + return true + } + + func renameThread(id: String, title: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.rename(threadID: route.wireID, title: title) + updateCachedArchivedThread(id: route.uiID) { $0.title = title } + try? await refresh(client: route.client) + } + + func regenerateThreadTitle(id: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.regenerateTitle(threadID: route.wireID) + try? await refresh(client: route.client) + } + + func setThreadArchived(id: String, archived: Bool) async throws { + let route = try threadRoute(for: id) + let cached = cachedThread(id: route.uiID) + _ = try await route.client.archive(threadID: route.wireID, archived: archived) + reconcileArchivedCache(thread: cached, route: route, archived: archived) + await emitCachedSnapshot(for: route.environmentID) + try? await refresh(client: route.client, includeArchived: true) + } + + func setThreadSettled(id: String, settled: Bool) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.settle(threadID: route.wireID, settled: settled) + try? await refresh(client: route.client) + } + + func setThreadSnoozed(id: String, until: Date?) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.snooze(threadID: route.wireID, until: until) + try? await refresh(client: route.client) + } + + func setThreadPinned(id: String, pinned: Bool) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.pin(threadID: route.wireID, pinned: pinned) + try? await refresh(client: route.client) + } + + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.setRuntimeMode( + threadID: route.wireID, + mode: coreRuntimeMode(mode) + ) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { + try? await refreshThread(id: route.uiID, client: route.client) + } + } + + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.setInteractionMode( + threadID: route.wireID, + mode: coreInteractionMode(mode) + ) + try? await refresh(client: route.client) + if activeThreadID == route.uiID { + try? await refreshThread(id: route.uiID, client: route.client) + } + } + + func deleteThread(id: String) async throws { + let route = try threadRoute(for: id) + _ = try await route.client.delete(threadID: route.wireID) + archivedThreadsByEnvironmentID[route.environmentID]?.removeAll { + $0.id == route.uiID + } + if let shell = shellsByEnvironmentID[route.environmentID] { + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: shell.threads.filter { $0.id != route.wireID }, + updatedAt: shell.updatedAt + ) + } + provisionalThreadRoutes[route.uiID] = nil + if activeThreadID == route.uiID { + resetDetailRefresh() + resetDetailStream() + passiveDetailPollingTask?.cancel() + passiveDetailPollingTask = nil + activeThreadID = nil + activeThreadEnvironmentID = nil + } + latestDetails[route.uiID] = nil + detailRenderCaches[route.uiID] = nil + detailCacheRecency.removeAll { $0 == route.uiID } + await emitCachedSnapshot(for: route.environmentID) + try? await refresh(client: route.client, includeArchived: true) + } + + func loadThread(id: String) async throws -> FeatureThreadDetail { + let route = try threadRoute(for: id) + let client = route.client + let environment = client.environment + let generation = environmentGeneration + resetDetailRefresh() + resetDetailStream() + passiveDetailPollingTask?.cancel() + passiveDetailPollingTask = nil + activeThreadID = route.uiID + activeThreadEnvironmentID = environment.id + threadHistoryEpoch &+= 1 + let historyEpoch = threadHistoryEpoch + pendingOlderThreadPage = nil + activeThreadPage = nil + let supportsPagination = serverConfigsByEnvironmentID[ + environment.id + ]?.threadSnapshotPagination == true + let snapshot = try await client.threadSnapshot( + id: route.wireID, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil + ) + guard isKnownClient(client, environmentID: environment.id, generation: generation), + threadHistoryEpoch == historyEpoch, + activeThreadID == route.uiID, + activeThreadEnvironmentID == environment.id else { + throw CancellationError() + } + activeThreadPage = featurePage(snapshot.page) + let detail = mapDetail( + snapshot.thread, + environment: environment, + sourceSequence: snapshot.snapshotSequence, + page: activeThreadPage + ) + activeRawThread = snapshot.thread + activeThreadSequence = snapshot.snapshotSequence + latestDetails[route.uiID] = detail + scheduleAttachmentHydration( + in: detail, + threadID: route.uiID, + client: client, + environmentID: environment.id + ) + startDetailStream( + route, + after: snapshot.snapshotSequence, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil + ) + return detail + } + + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? { + let route = try threadRoute(for: id) + guard activeThreadID == route.uiID, + activeThreadEnvironmentID == route.environmentID, + serverConfigsByEnvironmentID[ + route.environmentID + ]?.threadSnapshotPagination == true, + var page = activeThreadPage, + page.hasMore, + !page.isLoading, + let beforeCursor = page.beforeCursor else { + return latestDetails[id] + } + + let generation = environmentGeneration + let epoch = threadHistoryEpoch + let loadedSequence = activeThreadSequence ?? 0 + page.isLoading = true + activeThreadPage = page + publishActivePageState(threadID: route.uiID) + + do { + let snapshot = try await route.client.threadSnapshot( + id: route.wireID, + turnLimit: Self.olderThreadPageUserTurnLimit, + beforeCursor: beforeCursor + ) + guard isKnownClient( + route.client, + environmentID: route.environmentID, + generation: generation + ), activeThreadID == route.uiID else { + throw CancellationError() + } + guard threadHistoryEpoch == epoch, + snapshot.snapshotSequence >= loadedSequence else { + clearOlderThreadLoading(threadID: route.uiID) + return latestDetails[route.uiID] + } + + if let watermark = snapshot.page?.threadSequence, + watermark > (activeThreadSequence ?? 0) { + pendingOlderThreadPage = PendingOlderThreadPage( + snapshot: snapshot, + epoch: epoch, + threadID: route.uiID, + environmentID: route.environmentID + ) + return latestDetails[route.uiID] + } + return mergeOlderThreadPage(snapshot, route: route) + } catch { + if activeThreadID == route.uiID, threadHistoryEpoch == epoch { + clearOlderThreadLoading(threadID: route.uiID) + } + throw error + } + } + + func releaseThread(id: String) { + guard activeThreadID == id else { return } + resetDetailRefresh() + resetDetailStream() + passiveDetailPollingTask?.cancel() + passiveDetailPollingTask = nil + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + markThreadCacheRecentlyUsed(id) + evictOldThreadCachesIfNeeded() + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection? + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: [] + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: nil, + attachments: attachments, + submissionIdentity: nil + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: nil, + attachments: attachments, + submissionIdentity: identity + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessageResolved( + threadID: threadID, + text: text, + selection: selection, + runtimeMode: runtimeMode, + attachments: attachments, + submissionIdentity: identity + ) + } + + private func sendMessageResolved( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode requestedRuntimeMode: FeatureRuntimeMode?, + attachments: [FeatureUploadAttachment], + submissionIdentity: FeatureSubmissionIdentity? + ) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + guard let shellThread = shellsByEnvironmentID[environmentID]?.threads + .first(where: { $0.id == route.wireID }) else { + throw NativeFeatureClientError.threadNotFound + } + let model = selection.map(coreModelSelection) + let uploads = try makeUploadAttachments(attachments) + if !uploads.isEmpty { _ = try await client.serverConfig() } + let runtimeMode = coreRuntimeMode( + requestedRuntimeMode ?? mapRuntimeMode(shellThread.runtimeMode) + ) + let interactionMode = InteractionMode.default + let signature = TurnSubmissionSignature( + text: text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + attachments: attachments + ) + let pending: PendingTurnSubmission + let explicitIdentity = submissionIdentity.map { commandIdentity($0) } + if let explicitIdentity, + let existing = pendingTurnSubmissions[route.uiID], + existing.identity == explicitIdentity { + pending = existing + } else if explicitIdentity == nil, + let existing = pendingTurnSubmissions[route.uiID], + existing.signature == signature { + pending = existing + } else { + pending = PendingTurnSubmission( + signature: signature, + identity: explicitIdentity ?? CommandIdentity() + ) + pendingTurnSubmissions[route.uiID] = pending + } + + do { + _ = try await client.sendTurn( + threadID: submissionIdentity?.threadID ?? route.wireID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: uploads, + commandID: pending.identity.commandID, + messageID: pending.identity.messageID, + createdAt: pending.identity.createdAt + ) + } catch { + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + guard await messageWasCommitted( + client: client, + threadID: submissionIdentity?.threadID ?? route.wireID, + messageID: pending.identity.messageID + ) else { + // Keep the stable identity. Retrying the same restored draft + // cannot enqueue a duplicate turn after an ambiguous failure. + throw error + } + } + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + if pendingTurnSubmissions[route.uiID]?.identity == pending.identity { + pendingTurnSubmissions[route.uiID] = nil + } + // Live sync reconciles these snapshots. Refreshes are opportunistic + // after the accepted command so transient reads cannot invite a + // duplicate user turn. + try? await refreshThread(id: route.uiID, client: client) + try? await refresh(client: client) + } + + private func messageWasCommitted( + client: T3Client, + threadID: String, + messageID: String + ) async -> Bool { + guard let snapshot = try? await client.threadSnapshot(id: threadID) else { + return false + } + return snapshot.thread.messages.contains { $0.id == messageID } + } + + func cancelTurn(threadID: String) async throws { + let route = try threadRoute(for: threadID) + let turnID = shellsByEnvironmentID[route.environmentID]?.threads + .first(where: { $0.id == route.wireID })? + .latestTurn? + .turnId + _ = try await route.client.interrupt(threadID: route.wireID, turnID: turnID) + try? await refresh(client: route.client) + } + + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws { + guard let request = approvalRoutes[id] else { + throw NativeFeatureClientError.approvalNotFound + } + let route = try threadRoute(for: request.threadID) + _ = try await route.client.respondToApproval( + threadID: route.wireID, + requestID: request.wireID, + decision: decision.wireValue + ) + approvalRoutes[id] = nil + removeCachedApproval(id: id, threadID: route.uiID) + try? await refreshThread(id: route.uiID, client: route.client) + } + + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws { + guard let request = inputRoutes[id] else { + throw NativeFeatureClientError.inputRequestNotFound + } + let route = try threadRoute(for: request.threadID) + _ = try await route.client.respondToUserInput( + threadID: route.wireID, + requestID: request.wireID, + answers: answers.mapValues(\.jsonValue) + ) + inputRoutes[id] = nil + removeCachedInput(id: id, threadID: route.uiID) + try? await refreshThread(id: route.uiID, client: route.client) + } + + func saveSettings(_ settings: FeatureSettings) async throws { + let data = try JSONEncoder().encode(settings) + settingsStore.set(data, forKey: Self.settingsKey) + } + + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] { + let client = try await projectCreationClient(environmentID: environmentID) + let config = try await client.refreshProviders() + setServerConfig(config, environmentID: environmentID) + if environmentID == activeEnvironment?.id { latestServerConfig = config } + let providers = mapConfigProviders(config.providers) + providerCatalogCache[environmentID] = providers + if let shell = shellsByEnvironmentID[environmentID] { + await emitSnapshot(shell) + } + return providers + } + + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + if case let .afterDays(days) = change, + let days, + !(1...90).contains(days) { + throw NativeFeatureClientError.invalidAutomaticSettlementDays + } + + let client = try await projectCreationClient(environmentID: environmentID) + let previous = serverConfigsByEnvironmentID[environmentID] + let capabilities = previous?.environment?.capabilities + ?? client.environment.descriptor?.capabilities + guard capabilities?.threadAutoSettlement == true else { + throw FeatureCapabilityUnavailable("Automatic settlement settings") + } + + let serverChange: ServerSettingsChange = switch change { + case let .onMerge(value): .sidebarAutoSettleOnMerge(value) + case let .afterDays(value): .sidebarAutoSettleAfterDays(value) + } + let settings = try await client.updateSettings(serverChange) + let config = ServerConfigSnapshot( + providers: previous?.providers ?? [], + settings: settings, + threadSnapshotPagination: previous?.threadSnapshotPagination, + environment: previous?.environment + ) + setServerConfig(config, environmentID: environmentID) + if environmentID == activeEnvironment?.id { + latestServerConfig = config + } + return FeatureAutomaticSettlementSettings( + onMerge: settings.sidebarAutoSettleOnMerge, + afterDays: settings.sidebarAutoSettleAfterDays + ) + } + + var managesServerSessions: Bool { + !t3ConnectDeviceManager.hasActiveAccount + } + + func loadDeviceSessions() async throws -> [FeatureDeviceSession] { + if t3ConnectDeviceManager.hasActiveAccount { + let devices = try await t3ConnectDeviceManager.registeredDevices() + relayDeviceSessionIDs = Set(devices.map(\.deviceId)) + return devices.map { + FeatureDeviceSession( + relayDevice: $0, + currentDeviceID: t3ConnectDeviceManager.currentRegisteredDeviceID + ) + } + } + + relayDeviceSessionIDs.removeAll() + let client = try requireClient() + try await requireScope("access:read", client: client) + return try await client.clientSessions().map { session in + FeatureDeviceSession( + sessionID: session.sessionId, + label: session.client.label, + deviceType: FeatureDeviceType(rawValue: session.client.deviceType) ?? .unknown, + operatingSystem: session.client.os, + browser: session.client.browser, + ipAddress: session.client.ipAddress, + issuedAt: parseDate(session.issuedAt), + expiresAt: parseDate(session.expiresAt), + lastConnectedAt: session.lastConnectedAt.map(parseDate), + isConnected: session.connected, + isCurrent: session.current + ) + } + } + + func revokeDeviceSession(id: String) async throws { + if relayDeviceSessionIDs.contains(id) { + try await t3ConnectDeviceManager.unregisterDevice(id: id) + relayDeviceSessionIDs.remove(id) + return + } + + let client = try requireClient() + try await requireScope("access:write", client: client) + guard try await client.revokeClientSession(id: id) else { + throw NativeFeatureClientError.deviceSessionNotFound + } + } + + func revokeOtherDeviceSessions() async throws { + if !relayDeviceSessionIDs.isEmpty { + guard let currentID = t3ConnectDeviceManager.currentRegisteredDeviceID else { + throw NativeFeatureClientError.currentDeviceUnknown + } + let otherIDs = relayDeviceSessionIDs.filter { $0 != currentID } + for id in otherIDs { + try await t3ConnectDeviceManager.unregisterDevice(id: id) + } + relayDeviceSessionIDs.subtract(otherIDs) + return + } + + let client = try requireClient() + try await requireScope("access:write", client: client) + _ = try await client.revokeOtherClientSessions() + } + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.listProjectEntries(cwd: context.cwd) + return NativeWorkspaceMapper.files(result.entries, directory: path) + } + + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + let route = try projectRoute(for: projectID) + let project = try project(for: route) + let result = try await route.client.searchProjectEntries( + cwd: project.workspaceRoot, + query: query, + limit: limit + ) + return result.entries.map(Self.mapSearchEntry) + } + + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.searchProjectEntries( + cwd: context.cwd, + query: query, + limit: limit + ) + return result.entries.map(Self.mapSearchEntry) + } + + private static func mapSearchEntry(_ entry: ProjectEntry) -> FeatureFileEntry { + let name = URL(fileURLWithPath: entry.path).lastPathComponent + return FeatureFileEntry( + path: entry.path, + name: name, + kind: entry.kind == .directory ? .directory : .file, + isHidden: name.hasPrefix(".") + ) + } + + func readFile(threadID: String, path: String) async throws -> FeatureFileContent { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let result = try await route.client.readProjectFile( + cwd: context.cwd, + relativePath: path + ) + return FeatureFileContent( + path: result.relativePath, + text: result.contents, + language: NativeWorkspaceMapper.language(for: result.relativePath), + isTruncated: result.truncated, + totalBytes: result.byteLength + ) + } + + func loadReview(threadID: String) async throws -> FeatureReview { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let preview = try await route.client.reviewDiffPreview(cwd: context.cwd) + return NativeWorkspaceMapper.review(preview) + } + + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? { + guard file.change != .binary, let sourceKind = file.sourceKind else { return nil } + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let changeType: String = switch file.change { + case .added: "new" + case .deleted: "deleted" + case .renamed: file.additions == 0 && file.deletions == 0 + ? "rename-pure" + : "rename-changed" + case .modified, .binary: "change" + } + let contents = try await route.client.reviewDiffFileContents( + cwd: context.cwd, + sourceKind: sourceKind, + changeType: changeType, + baseRef: file.sourceBaseReference, + headRef: file.sourceHeadReference, + oldPath: file.previousPath ?? file.path, + newPath: file.path + ) + return FeatureReviewFileContents( + oldContents: contents.oldContents, + newContents: contents.newContents + ) + } + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + return NativeWorkspaceMapper.sourceControl( + try await route.client.refreshVCSStatus(cwd: context.cwd) + ) + } + + func sourceControlStatusEvents(threadID: String) -> AsyncStream { + let stream = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + + guard let route = try? threadRoute(for: threadID), + let context = try? workspaceContext(route: route) else { + stream.continuation.finish() + return stream.stream + } + + let key = NativeSourceControlMonitorKey( + environmentID: route.environmentID, + workingDirectory: URL(fileURLWithPath: context.cwd).standardizedFileURL.path + ) + let subscriberID = UUID() + let monitor: NativeSourceControlMonitor + + if let existing = sourceControlMonitors[key] { + monitor = existing + } else { + monitor = NativeSourceControlMonitor() + sourceControlMonitors[key] = monitor + } + + monitor.continuations[subscriberID] = stream.continuation + if let latest = monitor.latestStatus { + stream.continuation.yield(latest) + } + stream.continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeSourceControlSubscriber(subscriberID, for: key) + } + } + + if monitor.task == nil { + let monitorID = monitor.id + monitor.task = Task { [weak self] in + await self?.observeSourceControlStatus( + client: route.client, + key: key, + monitorID: monitorID + ) + } + } + + return stream.stream + } + + private func observeSourceControlStatus( + client: T3Client, + key: NativeSourceControlMonitorKey, + monitorID: UUID + ) async { + let events = await client.vcsStatusEvents(cwd: key.workingDirectory) + var local: VCSLocalStatus? + var remote: VCSRemoteStatus? + + do { + for try await event in events { + guard !Task.isCancelled, + sourceControlMonitors[key]?.id == monitorID else { + break + } + + switch event { + case let .snapshot(nextLocal, nextRemote): + local = nextLocal + remote = nextRemote + case let .localUpdated(nextLocal): + if local?.refName != nextLocal.refName { + remote = nil + } + local = nextLocal + case let .remoteUpdated(nextRemote): + remote = nextRemote + } + + guard let local else { continue } + let status = NativeWorkspaceMapper.sourceControl( + local: local, + remote: remote + ) + guard let monitor = sourceControlMonitors[key], + monitor.id == monitorID, + monitor.latestStatus != status else { + continue + } + monitor.latestStatus = status + monitor.continuations.values.forEach { $0.yield(status) } + } + } catch { + // Existing rows keep their last known PR until the next subscription. + } + + guard sourceControlMonitors[key]?.id == monitorID else { return } + let monitor = sourceControlMonitors.removeValue(forKey: key) + monitor?.continuations.values.forEach { $0.finish() } + } + + private func removeSourceControlSubscriber( + _ subscriberID: UUID, + for key: NativeSourceControlMonitorKey + ) { + guard let monitor = sourceControlMonitors[key] else { return } + monitor.continuations.removeValue(forKey: subscriberID) + guard monitor.continuations.isEmpty else { return } + monitor.task?.cancel() + sourceControlMonitors.removeValue(forKey: key) + } + + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus { + let route = try threadRoute(for: threadID) + let client = route.client + let context = try workspaceContext(route: route) + + if action == .pull { + _ = try await client.pull(cwd: context.cwd) + } else { + let progress = try await client.runGitAction( + cwd: context.cwd, + action: NativeWorkspaceMapper.gitAction(action), + commitMessage: message + ) + for try await event in progress { + if event.kind == "action_failed" { + throw RPCError.remote(event.message ?? "The source-control action failed.") + } + } + } + + return NativeWorkspaceMapper.sourceControl( + try await client.refreshVCSStatus(cwd: context.cwd) + ) + } + + func terminalSnapshot( + threadID: String, + terminalID: String + ) async throws -> FeatureTerminalSnapshot { + let route = try threadRoute(for: threadID) + let key = TerminalKey(threadID: route.uiID, terminalID: terminalID) + if let snapshot = terminalSnapshots[key] { + return snapshot + } + let context = try workspaceContext(route: route) + return FeatureTerminalSnapshot( + threadID: route.uiID, + terminalID: terminalID, + workingDirectory: context.cwd + ) + } + + func terminalEvents( + threadID: String, + terminalID: String + ) -> AsyncStream { + guard let route = try? threadRoute(for: threadID), + let context = try? workspaceContext(route: route) else { + return AsyncStream { continuation in continuation.finish() } + } + let environmentID = route.environmentID + let client = route.client + let uiThreadID = route.uiID + let wireThreadID = route.wireID + let key = TerminalKey(threadID: uiThreadID, terminalID: terminalID) + let generation = environmentGeneration + return AsyncStream { continuation in + if let snapshot = terminalSnapshots[key] { + continuation.yield(snapshot) + } + let task = Task { [weak self] in + do { + let events = try await client.attachTerminal( + threadID: wireThreadID, + terminalID: terminalID, + cwd: context.cwd, + worktreePath: context.worktreePath, + columns: 80, + rows: 24 + ) + for try await event in events { + guard !Task.isCancelled else { break } + guard let self else { break } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + break + } + let snapshot = self.consumeTerminalEvent( + event, + threadID: uiThreadID, + terminalID: terminalID + ) + continuation.yield(snapshot) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + guard let self else { + continuation.finish() + return + } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + continuation.finish() + return + } + var snapshot = self.terminalSnapshots[key] + ?? FeatureTerminalSnapshot( + threadID: uiThreadID, + terminalID: terminalID, + workingDirectory: context.cwd + ) + snapshot.state = .failed + snapshot.error = error.localizedDescription + self.terminalSnapshots[key] = snapshot + continuation.yield(snapshot) + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + func terminalSessions(threadID: String) -> AsyncStream<[FeatureTerminalSnapshot]> { + guard let route = try? threadRoute(for: threadID) else { + return AsyncStream { continuation in continuation.finish() } + } + let environmentID = route.environmentID + let client = route.client + let uiThreadID = route.uiID + let wireThreadID = route.wireID + let generation = environmentGeneration + return AsyncStream { continuation in + let task = Task { [weak self] in + var summaries = [TerminalSummary]() + do { + for try await event in await client.terminalMetadataEvents() { + guard !Task.isCancelled else { break } + guard let self else { break } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + break + } + + switch event.type { + case "snapshot": + summaries = (event.terminals ?? []).filter { + $0.threadId == wireThreadID + } + case "upsert": + if let summary = event.terminal, + summary.threadId == wireThreadID { + summaries.removeAll { $0.terminalId == summary.terminalId } + summaries.append(summary) + } + case "remove": + if event.threadId == wireThreadID, + let terminalID = event.terminalId { + summaries.removeAll { $0.terminalId == terminalID } + } + default: + break + } + + let sessions = summaries + .sorted { + $0.terminalId.localizedStandardCompare($1.terminalId) + == .orderedAscending + } + .map { self.mergeTerminalSummary($0, threadID: uiThreadID) } + continuation.yield(sessions) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish() + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + func openTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + let context = try workspaceContext(route: route) + let snapshot = try await client.openTerminal( + threadID: route.wireID, + terminalID: terminalID, + cwd: context.cwd, + worktreePath: context.worktreePath, + columns: columns, + rows: rows + ) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let mapped = NativeWorkspaceMapper.terminal(snapshot) + var scoped = mapped + scoped.threadID = route.uiID + scoped.buffer = Self.cappedTerminalBuffer(scoped.buffer) + terminalSnapshots[TerminalKey(threadID: route.uiID, terminalID: terminalID)] = scoped + } + + func writeTerminal(threadID: String, terminalID: String, data: String) async throws { + let route = try threadRoute(for: threadID) + try await route.client.writeTerminal( + threadID: route.wireID, + terminalID: terminalID, + data: data + ) + } + + func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + let route = try threadRoute(for: threadID) + try await route.client.resizeTerminal( + threadID: route.wireID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + } + + func clearTerminal(threadID: String, terminalID: String) async throws { + let route = try threadRoute(for: threadID) + try await route.client.clearTerminal( + threadID: route.wireID, + terminalID: terminalID + ) + } + + func closeTerminal(threadID: String, terminalID: String) async throws { + let route = try threadRoute(for: threadID) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + try await client.closeTerminal(threadID: route.wireID, terminalID: terminalID) + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + throw CancellationError() + } + let context = try workspaceContext(route: route) + terminalSnapshots[TerminalKey(threadID: route.uiID, terminalID: terminalID)] = + FeatureTerminalSnapshot( + threadID: route.uiID, + terminalID: terminalID, + workingDirectory: context.cwd + ) + } + + private func requireClient() throws -> T3Client { + guard let client else { throw NativeFeatureClientError.notConnected } + return client + } + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? { + let client = try await projectCreationClient(environmentID: environmentID) + _ = try await client.serverConfig() + let prepared = try await client.prepareAttachment( + makeUploadAttachments([attachment])[0] + ) + return prepared.map { + FeatureUploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + } + + private func projectCreationClient(environmentID: String) async throws -> T3Client { + if let client = environmentClients[environmentID] { + return client + } + guard let environment = try await runtime.environments().first(where: { + $0.id == environmentID + }) else { + throw NativeFeatureClientError.environmentNotFound + } + let client = await runtime.client(for: environment) + environmentClients[environmentID] = client + return client + } + + private func projectRoute(for projectID: String) throws -> NativeProjectRoute { + guard let environmentID = projectEnvironmentIDs[projectID], + let wireID = projectWireIDs[projectID], + let client = environmentClients[environmentID] else { + throw NativeFeatureClientError.projectNotFound + } + return NativeProjectRoute( + uiID: FeatureScopedID.project(environmentID: environmentID, wireID: wireID), + wireID: wireID, + environmentID: environmentID, + client: client + ) + } + + private func project(for route: NativeProjectRoute) throws -> OrchestrationProject { + guard let project = shellsByEnvironmentID[route.environmentID]?.projects.first(where: { + $0.id == route.wireID + }) else { + throw NativeFeatureClientError.projectNotFound + } + return project + } + + private func threadRoute(for threadID: String) throws -> NativeThreadRoute { + guard let environmentID = threadEnvironmentIDs[threadID], + let wireID = threadWireIDs[threadID], + let client = environmentClients[environmentID] else { + throw NativeFeatureClientError.threadNotFound + } + return NativeThreadRoute( + uiID: FeatureScopedID.thread(environmentID: environmentID, wireID: wireID), + wireID: wireID, + environmentID: environmentID, + client: client + ) + } + + private func registerProvisionalThread(wireID: String, environmentID: String) { + let uiID = FeatureScopedID.thread(environmentID: environmentID, wireID: wireID) + provisionalThreadRoutes[uiID] = ProvisionalThreadRoute( + environmentID: environmentID, + wireID: wireID + ) + threadEnvironmentIDs[uiID] = environmentID + threadWireIDs[uiID] = wireID + } + + private func cachedThread(id: String) -> FeatureThread? { + latestSnapshot?.threads.first(where: { $0.id == id }) + ?? archivedThreadsByEnvironmentID.values.lazy + .flatMap { $0 } + .first(where: { $0.id == id }) + } + + private func updateCachedArchivedThread( + id: String, + update: (inout FeatureThread) -> Void + ) { + for environmentID in Array(archivedThreadsByEnvironmentID.keys) { + guard var threads = archivedThreadsByEnvironmentID[environmentID], + let index = threads.firstIndex(where: { $0.id == id }) else { + continue + } + update(&threads[index]) + archivedThreadsByEnvironmentID[environmentID] = threads + return + } + } + + private func reconcileArchivedCache( + thread: FeatureThread?, + route: NativeThreadRoute, + archived: Bool + ) { + archivedThreadsByEnvironmentID[route.environmentID, default: []] + .removeAll { $0.id == route.uiID } + var archivedShellThreads = archivedShellThreadsByEnvironmentID[ + route.environmentID, + default: [:] + ] + let previouslyArchivedShell = archivedShellThreads.removeValue( + forKey: route.wireID + ) + + if archived, var thread { + // Keep the accepted lifecycle transition visible until both live + // and archived follow-up reads converge, including when the + // owning passive device drops immediately after the command. + thread.isArchived = true + archivedThreadsByEnvironmentID[route.environmentID, default: []].append(thread) + } + + if let shell = shellsByEnvironmentID[route.environmentID] { + if archived { + if let liveThread = shell.threads.first(where: { $0.id == route.wireID }) { + archivedShellThreads[route.wireID] = liveThread + } + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: shell.threads.filter { $0.id != route.wireID }, + updatedAt: shell.updatedAt + ) + } else if let previouslyArchivedShell { + var threads = shell.threads.filter { $0.id != route.wireID } + threads.append(Self.unarchived(previouslyArchivedShell)) + shellsByEnvironmentID[route.environmentID] = OrchestrationShellSnapshot( + snapshotSequence: shell.snapshotSequence, + projects: shell.projects, + threads: threads, + updatedAt: shell.updatedAt + ) + } + } + archivedShellThreadsByEnvironmentID[route.environmentID] = archivedShellThreads + } + + private static func unarchived( + _ thread: OrchestrationThreadShell + ) -> OrchestrationThreadShell { + OrchestrationThreadShell( + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + latestTurn: thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + archivedAt: nil, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + snoozedUntil: thread.snoozedUntil, + snoozedAt: thread.snoozedAt, + pinnedAt: thread.pinnedAt, + session: thread.session, + latestUserMessageAt: thread.latestUserMessageAt, + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasActionableProposedPlan: thread.hasActionableProposedPlan, + backgroundLiveness: thread.backgroundLiveness + ) + } + + private func emitCachedSnapshot(for environmentID: String) async { + guard let environment = environmentClients[environmentID]?.environment, + let shell = shellsByEnvironmentID[environmentID] else { + return + } + await emitSnapshot(shell, environment: environment) + } + + private func removeCachedApproval(id: String, threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.approvals.removeAll { $0.id == id } + if detail.approvals.isEmpty, detail.thread.state == .waitingForApproval { + detail.thread.state = detail.userInputs.isEmpty ? .idle : .waitingForInput + } + publish(detail, threadID: threadID) + } + + private func removeCachedInput(id: String, threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.userInputs.removeAll { $0.id == id } + if detail.userInputs.isEmpty, detail.thread.state == .waitingForInput { + detail.thread.state = detail.approvals.isEmpty ? .idle : .waitingForApproval + } + publish(detail, threadID: threadID) + } + + private func workspaceContext(route: NativeThreadRoute) throws -> ( + cwd: String, + worktreePath: String? + ) { + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }) else { + throw NativeFeatureClientError.workspaceNotFound + } + return ( + cwd: thread.worktreePath ?? project.workspaceRoot, + worktreePath: thread.worktreePath + ) + } + + private func consumeTerminalEvent( + _ event: TerminalEvent, + threadID: String, + terminalID: String + ) -> FeatureTerminalSnapshot { + let key = TerminalKey(threadID: threadID, terminalID: terminalID) + if let coreSnapshot = event.snapshot { + var snapshot = NativeWorkspaceMapper.terminal(coreSnapshot) + snapshot.threadID = threadID + snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) + terminalSnapshots[key] = snapshot + return snapshot + } + + var snapshot = terminalSnapshots[key] + ?? FeatureTerminalSnapshot(threadID: threadID, terminalID: terminalID) + switch event.type { + case "output": + snapshot.buffer.append(event.data ?? "") + snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) + case "exited": + snapshot.state = .exited + snapshot.exitCode = event.exitCode + case "closed": + snapshot.state = .stopped + case "error": + snapshot.state = .failed + snapshot.error = event.message + case "cleared": + snapshot.buffer = "" + case "activity": + snapshot.title = event.label ?? snapshot.title + snapshot.hasRunningSubprocess = event.hasRunningSubprocess + ?? snapshot.hasRunningSubprocess + default: + break + } + terminalSnapshots[key] = snapshot + return snapshot + } + + private func mergeTerminalSummary( + _ summary: TerminalSummary, + threadID: String + ) -> FeatureTerminalSnapshot { + let key = TerminalKey(threadID: threadID, terminalID: summary.terminalId) + var snapshot = NativeWorkspaceMapper.terminal(summary) + snapshot.threadID = threadID + if let cached = terminalSnapshots[key] { + snapshot.buffer = cached.buffer + snapshot.error = cached.error + } + terminalSnapshots[key] = snapshot + return snapshot + } + + /// A verbose command can stream megabytes; the viewer only ever shows the + /// tail, so cap retained history to keep layout and memory bounded. + private static let terminalBufferLimit = 512 * 1024 + + private static func cappedTerminalBuffer(_ buffer: String) -> String { + let utf8 = buffer.utf8 + guard utf8.count > terminalBufferLimit else { return buffer } + // Slice in UTF-8 bytes (the unit the limit is defined in), then snap + // forward to a character boundary so multibyte output cannot blow + // past the cap or tear a scalar. + let byteStart = utf8.index(utf8.endIndex, offsetBy: -terminalBufferLimit) + var start = byteStart.samePosition(in: buffer) + if start == nil { + var probe = byteStart + while probe < utf8.endIndex, start == nil { + probe = utf8.index(after: probe) + start = probe.samePosition(in: buffer) + } + } + guard let start else { return buffer } + let tail = buffer[start...] + // Trim to the next line boundary so the top of the view isn't a torn line. + if let newline = tail.firstIndex(of: "\n") { + return String(tail[tail.index(after: newline)...]) + } + return String(tail) + } + + private func startPolling(_ activeClient: T3Client) { + pollingTask?.cancel() + fallbackPollingTask?.cancel() + configurationTask?.cancel() + let generation = environmentGeneration + pollingTask = Task { [weak self] in + do { + await activeClient.connect() + guard self?.isCurrentSession( + client: activeClient, + generation: generation + ) == true else { + return + } + let sequence = self?.latestShell?.snapshotSequence + let events = await activeClient.shellEvents(after: sequence) + // Re-bind self per event instead of holding it strongly across + // the indefinite stream, so the client can deinit mid-stream. + for try await item in events { + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + break + } + self.lastShellEventAt = .now + self.emitConnection(.connected) + switch item { + case let .snapshot(shell): + await self.consume( + shell: shell, + client: activeClient, + refreshActiveThread: true + ) + case .projectUpserted, .projectRemoved, .threadUpserted, .threadRemoved: + await self.consume(delta: item, client: activeClient) + case .refreshRequired: + if let shell = try? await activeClient.shellSnapshot() { + await self.consume( + shell: shell, + client: activeClient, + refreshActiveThread: true + ) + } + case .synchronized: + break + } + } + } catch is CancellationError { + return + } catch { + // The independent HTTP fallback below keeps the workspace + // fresh while the socket reconnects. + } + + guard !Task.isCancelled, + let self, + self.isCurrentSession(client: activeClient, generation: generation) else { + return + } + self.lastShellEventAt = nil + self.emitConnection( + .reconnecting, + detail: "Live updates paused. Refreshing over HTTP." + ) + } + let fallbackPollingInitialDelay = fallbackPollingInitialDelay + let fallbackPollingInterval = fallbackPollingInterval + fallbackPollingTask = Task { [weak self] in + do { + try await Task.sleep(for: fallbackPollingInitialDelay) + } catch { + return + } + while !Task.isCancelled { + guard let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + let socketIsSynchronized = + await activeClient.liveConnectionActive() + && self.lastShellEventAt != nil + if !socketIsSynchronized { + self.emitConnection( + .reconnecting, + detail: "Live updates reconnecting. Refreshing over HTTP." + ) + do { + let shell = try await activeClient.shellSnapshot() + guard !Task.isCancelled, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + await self.consumeFallbackShell( + shell: shell, + client: activeClient, + generation: generation + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + self.emitConnection( + .reconnecting, + detail: "Server unreachable. Retrying automatically." + ) + } + } + do { + try await Task.sleep(for: fallbackPollingInterval) + } catch { + return + } + } + } + configurationTask = Task { [weak self] in + do { + for try await event in await activeClient.serverConfigEvents() { + guard !Task.isCancelled, + let self, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + break + } + switch event { + case let .snapshot(config): + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case let .providerStatuses(providers): + let previous = self.serverConfigsByEnvironmentID[ + activeClient.environment.id + ] + let config = ServerConfigSnapshot( + providers: providers, + settings: previous?.settings, + threadSnapshotPagination: previous?.threadSnapshotPagination, + environment: previous?.environment + ?? self.latestServerConfig?.environment + ) + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case let .settingsUpdated(settings): + let previous = self.serverConfigsByEnvironmentID[ + activeClient.environment.id + ] + let providers = previous?.providers + ?? self.latestServerConfig?.providers ?? [] + let config = ServerConfigSnapshot( + providers: providers, + settings: settings, + threadSnapshotPagination: previous?.threadSnapshotPagination + ?? self.latestServerConfig?.threadSnapshotPagination, + environment: previous?.environment + ?? self.latestServerConfig?.environment + ) + self.latestServerConfig = config + self.setServerConfig(config, environmentID: activeClient.environment.id) + case .unrelated: + continue + } + if let shell = self.latestShell { + await self.emitSnapshot(shell) + } + } + } catch is CancellationError { + return + } catch { + // The shell and thread streams remain useful on older servers + // that do not expose the provider catalogue subscription. + } + } + } + + /// Non-active environments do not hold WebSocket subscriptions. A quiet + /// HTTP refresh keeps their home rows and reachability useful without + /// multiplying live streams or creating a high-frequency battery cost. + private func startAggregateRefresh(_ activeClient: T3Client) { + aggregateRefreshTask?.cancel() + let generation = environmentGeneration + let refreshID = UUID() + let interval = aggregateRefreshInterval + let loadEnvironments = aggregateEnvironmentLoader + aggregateRefreshID = refreshID + aggregateRefreshTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: interval) + } catch { + return + } + guard let self, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ), + let activeEnvironment = self.activeEnvironment else { + return + } + let environments: [Environment] + do { + environments = try await loadEnvironments(self.runtime) + } catch is CancellationError where Task.isCancelled { + return + } catch { + // Persistence can be briefly unavailable while another + // actor atomically replaces the environment document. + // Keep the low-frequency loop alive for the next cadence. + continue + } + guard !Task.isCancelled, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + let passiveEnvironments = environments.filter { + $0.isEnabled && $0.id != activeEnvironment.id + } + guard !passiveEnvironments.isEmpty else { continue } + let loads = await self.loadEnvironmentShells(passiveEnvironments) + guard !Task.isCancelled, + self.aggregateRefreshID == refreshID, + self.isCurrentSession( + client: activeClient, + generation: generation + ) else { + return + } + self.reconcileEnvironmentLoads(loads, savedEnvironments: environments) + let currentConnection = self.latestSnapshot?.connection + ?? FeatureConnection( + state: .disconnected, + environmentName: activeEnvironment.label, + endpoint: activeEnvironment.httpBaseURL.absoluteString + ) + let snapshot = self.makeSnapshot( + environments: environments, + activeEnvironment: activeEnvironment, + connectionState: currentConnection.state, + connectionDetail: currentConnection.detail + ) + self.publish(snapshot) + } + } + } + + private func consume( + shell: OrchestrationShellSnapshot, + client: T3Client, + refreshActiveThread: Bool + ) async { + guard let currentClient = self.client, + currentClient === client, + shell.snapshotSequence >= (latestShell?.snapshotSequence ?? .min) else { + return + } + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = shell + await emitSnapshot(shell) + if refreshActiveThread, let threadID = activeThreadID { + scheduleDetailRefresh(threadID: threadID, client: client) + } + } + + /// HTTP fallback refreshes data while preserving the socket's reconnecting + /// state. The generation travels through the awaited snapshot publish so a + /// task from a previous environment session cannot publish late results. + private func consumeFallbackShell( + shell: OrchestrationShellSnapshot, + client: T3Client, + generation: Int + ) async { + guard isCurrentSession(client: client, generation: generation), + shell.snapshotSequence >= (latestShell?.snapshotSequence ?? .min) else { + return + } + shellPublishTask?.cancel() + shellPublishTask = nil + latestShell = shell + await emitSnapshot( + shell, + markSourceConnected: false, + expectedGeneration: generation + ) + guard isCurrentSession(client: client, generation: generation), + let threadID = activeThreadID else { + return + } + scheduleDetailRefresh(threadID: threadID, client: client) + } + + private func consume(delta: ShellStreamItem, client: T3Client) async { + guard let currentClient = self.client, currentClient === client else { return } + guard let current = latestShell else { + if let shell = try? await client.shellSnapshot() { + await consume(shell: shell, client: client, refreshActiveThread: true) + } + return + } + + let sequence: Int + + switch delta { + case let .projectUpserted(nextSequence, _): + sequence = nextSequence + case let .projectRemoved(nextSequence, _): + sequence = nextSequence + case let .threadUpserted(nextSequence, _): + sequence = nextSequence + case let .threadRemoved(nextSequence, _): + sequence = nextSequence + case .snapshot, .synchronized, .refreshRequired: + return + } + + // Replayed deltas are expected after reconnect. They must be entirely + // side-effect free, including for cached detail and selection state. + guard sequence > current.snapshotSequence else { return } + + var projects = current.projects + var threads = current.threads + var changedThreadID: String? + var shouldRefreshArchived = false + + switch delta { + case let .projectUpserted(_, project): + if let index = projects.firstIndex(where: { $0.id == project.id }) { + projects[index] = project + } else { + projects.append(project) + } + case let .projectRemoved(_, projectID): + projects.removeAll { $0.id == projectID } + case let .threadUpserted(_, thread): + changedThreadID = activeEnvironment.map { + FeatureScopedID.thread(environmentID: $0.id, wireID: thread.id) + } + if let environmentID = activeEnvironment?.id { + archivedThreadsByEnvironmentID[environmentID]?.removeAll { + ($0.wireID ?? $0.id) == thread.id + } + } + if let index = threads.firstIndex(where: { $0.id == thread.id }) { + threads[index] = thread + } else { + threads.append(thread) + } + case let .threadRemoved(_, threadID): + let uiThreadID = activeEnvironment.map { + FeatureScopedID.thread(environmentID: $0.id, wireID: threadID) + } + changedThreadID = uiThreadID + shouldRefreshArchived = true + threads.removeAll { $0.id == threadID } + if let uiThreadID { + latestDetails[uiThreadID] = nil + detailRenderCaches[uiThreadID] = nil + detailCacheRecency.removeAll { $0 == uiThreadID } + } + if activeThreadID == uiThreadID { + resetDetailRefresh() + resetDetailStream() + activeThreadID = nil + activeThreadEnvironmentID = nil + activeRawThread = nil + activeThreadSequence = nil + activeThreadPage = nil + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + } + case .snapshot, .synchronized, .refreshRequired: + return + } + + let shell = OrchestrationShellSnapshot( + snapshotSequence: sequence, + projects: projects, + threads: threads, + updatedAt: current.updatedAt + ) + latestShell = shell + scheduleShellPublish(client) + if shouldRefreshArchived, let environment = activeEnvironment { + scheduleArchivedRefresh(client: client, environment: environment) + } + if let changedThreadID, activeThreadID == changedThreadID { + scheduleDetailRefresh(threadID: changedThreadID, client: client) + } + } + + /// Shell streams can emit many metadata updates during one provider turn. + /// Home only needs the newest row state, so publish at most four times per + /// second while the selected transcript continues on its dedicated stream. + private func scheduleShellPublish(_ client: T3Client) { + guard shellPublishTask == nil else { return } + let generation = environmentGeneration + shellPublishTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard let self else { return } + self.shellPublishTask = nil + guard !Task.isCancelled, + self.isCurrentSession(client: client, generation: generation), + let shell = self.latestShell else { + return + } + await self.emitSnapshot(shell) + } + } + + private func scheduleDetailRefresh( + threadID: String, + client: T3Client, + force: Bool = false + ) { + guard activeThreadID == threadID, + activeThreadEnvironmentID == client.environment.id else { return } + guard force || detailStreamTask == nil else { return } + guard detailRefreshTask == nil else { + detailRefreshPending = true + return + } + detailRefreshPending = false + detailRefreshGeneration &+= 1 + let generation = detailRefreshGeneration + let sessionGeneration = environmentGeneration + detailRefreshTask = Task { [weak self] in + do { + // Four updates per second keeps streaming text responsive while + // coalescing bursty shell events into one detail snapshot. + try await Task.sleep(for: .milliseconds(250)) + } catch { + self?.finishDetailRefresh(generation: generation, client: client) + return + } + guard let self else { return } + if !Task.isCancelled, + self.activeThreadID == threadID, + self.isKnownClient( + client, + environmentID: client.environment.id, + generation: sessionGeneration + ) { + try? await self.refreshThread(id: threadID, client: client) + } + self.finishDetailRefresh(generation: generation, client: client) + } + } + + private func startDetailStream( + _ route: NativeThreadRoute, + after sequence: Int, + turnLimit: Int? + ) { + detailStreamGeneration &+= 1 + let streamGeneration = detailStreamGeneration + let sessionGeneration = environmentGeneration + detailStreamTask = Task { [weak self] in + do { + for try await item in await route.client.threadEvents( + threadID: route.wireID, + after: sequence, + turnLimit: turnLimit + ) { + guard !Task.isCancelled, + let self, + self.detailStreamGeneration == streamGeneration, + self.activeThreadID == route.uiID, + self.isKnownClient( + route.client, + environmentID: route.environmentID, + generation: sessionGeneration + ) else { + break + } + self.consumeDetailStreamItem(item, route: route) + } + } catch is CancellationError { + return + } catch { + // Shell-driven HTTP refresh remains the compatibility fallback. + } + guard let self else { return } + self.finishDetailStream( + generation: streamGeneration, + route: route, + sessionGeneration: sessionGeneration + ) + } + } + + private func consumeDetailStreamItem( + _ item: ThreadStreamItem, + route: NativeThreadRoute + ) { + switch item { + case .synchronized: + return + case let .snapshot(snapshot): + guard snapshot.snapshotSequence > (activeThreadSequence ?? 0) else { return } + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeThreadSequence = snapshot.snapshotSequence + activeRawThread = snapshot.thread + activeThreadPage = featurePage(snapshot.page) + scheduleRawDetailPublish(route: route, mutation: .full) + case let .event(event): + guard let current = activeRawThread else { + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + return + } + let reduction = NativeThreadDetailReducer.apply(event, to: current) + if reduction.sequence < 0 { + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeRawThread = nil + discardPendingDetailPublish() + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + return + } + guard reduction.sequence > (activeThreadSequence ?? 0) else { return } + switch reduction.result { + case let .updated(thread): + activeThreadSequence = reduction.sequence + activeRawThread = thread + scheduleRawDetailPublish(route: route, mutation: reduction.renderMutation) + tryMergePendingOlderThreadPage(route: route) + case .unchanged: + activeThreadSequence = reduction.sequence + tryMergePendingOlderThreadPage(route: route) + case .refresh: + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeThreadSequence = reduction.sequence + activeRawThread = nil + discardPendingDetailPublish() + scheduleDetailRefresh(threadID: route.uiID, client: route.client, force: true) + } + } + } + + private func scheduleRawDetailPublish( + route: NativeThreadRoute, + mutation: NativeDetailRenderMutation + ) { + pendingDetailRenderMutations.formUnion(mutation) + guard detailPublishTask == nil else { return } + let streamGeneration = detailStreamGeneration + detailPublishTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(80)) + guard let self else { return } + self.detailPublishTask = nil + guard !Task.isCancelled, + self.detailStreamGeneration == streamGeneration, + self.activeThreadID == route.uiID, + let rawThread = self.activeRawThread else { + return + } + let mutations = self.pendingDetailRenderMutations + self.pendingDetailRenderMutations = NativeDetailRenderMutations() + let previousDetail = self.latestDetails[route.uiID] + let detail = self.mapDetail( + rawThread, + environment: route.client.environment, + sourceSequence: self.activeThreadSequence ?? 0, + mutations: mutations, + page: self.activeThreadPage + ) + let delta = self.makeDetailDelta( + previous: previousDetail, + next: detail, + mutations: mutations + ) + self.publish( + detail, + threadID: route.uiID, + renderCacheIsSource: true, + delta: delta + ) + self.scheduleAttachmentHydration( + in: detail, + threadID: route.uiID, + client: route.client, + environmentID: route.environmentID + ) + } + } + + private func finishDetailStream( + generation: Int, + route: NativeThreadRoute, + sessionGeneration: Int + ) { + guard detailStreamGeneration == generation, + activeThreadID == route.uiID, + isKnownClient( + route.client, + environmentID: route.environmentID, + generation: sessionGeneration + ) else { + return + } + detailStreamTask = nil + scheduleDetailRefresh(threadID: route.uiID, client: route.client) + startPassiveDetailPolling(route) + } + + /// Passive environments intentionally avoid full shell WebSocket streams. + /// A selected passive thread is still live enough to drive remotely. + private func startPassiveDetailPolling(_ route: NativeThreadRoute) { + passiveDetailPollingTask?.cancel() + passiveDetailPollingTask = nil + guard route.environmentID != activeEnvironment?.id else { return } + let generation = environmentGeneration + passiveDetailPollingTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + guard let self, + self.activeThreadID == route.uiID, + self.isKnownClient( + route.client, + environmentID: route.environmentID, + generation: generation + ) else { + return + } + try? await self.refreshThread(id: route.uiID, client: route.client) + } + } + } + + private func finishDetailRefresh(generation: Int, client: T3Client) { + guard detailRefreshGeneration == generation else { return } + detailRefreshTask = nil + let needsTrailingRefresh = detailRefreshPending + detailRefreshPending = false + if needsTrailingRefresh, let threadID = activeThreadID { + scheduleDetailRefresh(threadID: threadID, client: client) + } + } + + private func resetDetailRefresh() { + detailRefreshGeneration &+= 1 + detailRefreshTask?.cancel() + detailRefreshTask = nil + detailRefreshPending = false + } + + private func resetDetailStream() { + detailStreamGeneration &+= 1 + detailStreamTask?.cancel() + detailStreamTask = nil + discardPendingDetailPublish() + } + + private func discardPendingDetailPublish() { + detailPublishTask?.cancel() + detailPublishTask = nil + pendingDetailRenderMutations = NativeDetailRenderMutations() + } + + private func loadEnvironmentShells( + _ environments: [Environment] + ) async -> [EnvironmentShellLoad] { + let activeEnvironmentID = activeEnvironment?.id + let environmentsWithCachedConfig = Set(serverConfigsByEnvironmentID.keys) + let shellTimeoutInterval = environmentShellTimeoutInterval + let runtime = runtime + var clients: [(environment: Environment, client: T3Client)] = [] + clients.reserveCapacity(environments.count) + for environment in environments { + clients.append( + (environment, await runtime.client(for: environment)) + ) + } + + return await withTaskGroup(of: EnvironmentShellLoad.self) { group in + for pair in clients { + group.addTask { + let shell = try? await pair.client.shellSnapshot( + timeoutInterval: shellTimeoutInterval + ) + guard shell != nil else { + return EnvironmentShellLoad( + environment: pair.environment, + client: pair.client, + shell: nil, + config: nil + ) + } + + let isActive = pair.environment.id == activeEnvironmentID + let shouldFetchConfig = isActive + || !environmentsWithCachedConfig.contains(pair.environment.id) + var config: ServerConfigSnapshot? + if shouldFetchConfig { + if isActive { + config = try? await pair.client.serverConfig() + } else { + // A passive catalogue is a bounded one-shot RPC on + // an uncached client. Never disconnect the shared + // client because the environment may become active + // while this aggregate load is in flight. + let probe = await runtime.ephemeralClient( + for: pair.environment + ) + config = try? await probe.serverConfig() + await probe.disconnect() + } + } + return EnvironmentShellLoad( + environment: pair.environment, + client: pair.client, + shell: shell, + config: config + ) + } + } + var loads: [EnvironmentShellLoad] = [] + loads.reserveCapacity(environments.count) + for await load in group { + loads.append(load) + } + return loads + } + } + + /// Successful reads replace that environment's cache. Failed reads leave + /// its last-known rows intact, so one offline machine cannot empty home. + private func reconcileEnvironmentLoads( + _ loads: [EnvironmentShellLoad], + savedEnvironments: [Environment] + ) { + let savedIDs = Set(savedEnvironments.map(\.id)) + environmentClients = environmentClients.filter { savedIDs.contains($0.key) } + shellsByEnvironmentID = shellsByEnvironmentID.filter { savedIDs.contains($0.key) } + serverConfigsByEnvironmentID = serverConfigsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + providerCatalogCache = providerCatalogCache.filter { + savedIDs.contains($0.key) + } + archivedThreadsByEnvironmentID = archivedThreadsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + archivedShellThreadsByEnvironmentID = archivedShellThreadsByEnvironmentID.filter { + savedIDs.contains($0.key) + } + environmentConnectionStates = environmentConnectionStates.filter { + savedIDs.contains($0.key) + } + environmentConnectionDetails = environmentConnectionDetails.filter { + savedIDs.contains($0.key) + } + + for load in loads { + environmentClients[load.environment.id] = load.client + if let config = load.config { + setServerConfig(config, environmentID: load.environment.id) + if load.environment.id == activeEnvironment?.id { + latestServerConfig = config + } + } + if let shell = load.shell { + if shell.snapshotSequence + >= (shellsByEnvironmentID[load.environment.id]?.snapshotSequence ?? .min) { + shellsByEnvironmentID[load.environment.id] = shell + } + environmentConnectionStates[load.environment.id] = .connected + environmentConnectionDetails[load.environment.id] = nil + } else { + environmentConnectionStates[load.environment.id] = .disconnected + environmentConnectionDetails[load.environment.id] = + "That server is currently unreachable." + } + } + rebuildEntityIndexes(savedEnvironments) + } + + private func newestShell( + _ candidate: OrchestrationShellSnapshot, + for environment: Environment + ) -> OrchestrationShellSnapshot { + let latest: OrchestrationShellSnapshot + if let cached = shellsByEnvironmentID[environment.id], + cached.snapshotSequence > candidate.snapshotSequence { + latest = cached + } else { + latest = candidate + shellsByEnvironmentID[environment.id] = candidate + } + if activeEnvironment?.id == environment.id { + latestShell = latest + } + return latest + } + + private func rebuildEntityIndexes(_ environments: [Environment]) { + let savedIDs = Set(environments.map(\.id)) + provisionalThreadRoutes = provisionalThreadRoutes.filter { + savedIDs.contains($0.value.environmentID) + } + + var nextProjectEnvironments: [String: String] = [:] + var nextProjectWireIDs: [String: String] = [:] + var nextThreadEnvironments: [String: String] = [:] + var nextThreadWireIDs: [String: String] = [:] + var projectCandidates: [String: Set] = [:] + var threadCandidates: [String: Set] = [:] + var materializedThreadIDs: Set = [] + + for environment in environments { + let environmentID = environment.id + for project in shellsByEnvironmentID[environmentID]?.projects ?? [] { + let uiID = FeatureScopedID.project( + environmentID: environmentID, + wireID: project.id + ) + nextProjectEnvironments[uiID] = environmentID + nextProjectWireIDs[uiID] = project.id + projectCandidates[project.id, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: project.id) + ) + } + for thread in shellsByEnvironmentID[environmentID]?.threads ?? [] { + let uiID = FeatureScopedID.thread( + environmentID: environmentID, + wireID: thread.id + ) + nextThreadEnvironments[uiID] = environmentID + nextThreadWireIDs[uiID] = thread.id + materializedThreadIDs.insert(uiID) + threadCandidates[thread.id, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: thread.id) + ) + } + for thread in archivedThreadsByEnvironmentID[environmentID] ?? [] { + let wireID = thread.wireID ?? thread.id + let uiID = FeatureScopedID.thread( + environmentID: environmentID, + wireID: wireID + ) + nextThreadEnvironments[uiID] = environmentID + nextThreadWireIDs[uiID] = wireID + materializedThreadIDs.insert(uiID) + threadCandidates[wireID, default: []].insert( + EntityWireOwner(environmentID: environmentID, wireID: wireID) + ) + } + } + + provisionalThreadRoutes = provisionalThreadRoutes.filter { + !materializedThreadIDs.contains($0.key) + } + for (uiID, provisional) in provisionalThreadRoutes { + nextThreadEnvironments[uiID] = provisional.environmentID + nextThreadWireIDs[uiID] = provisional.wireID + threadCandidates[provisional.wireID, default: []].insert( + EntityWireOwner( + environmentID: provisional.environmentID, + wireID: provisional.wireID + ) + ) + } + + // Raw IDs remain accepted for source-compatible fixtures only when + // their owner is unambiguous. Native snapshots always use scoped IDs. + for (rawID, candidates) in projectCandidates where candidates.count == 1 { + guard let owner = candidates.first else { continue } + nextProjectEnvironments[rawID] = owner.environmentID + nextProjectWireIDs[rawID] = owner.wireID + } + for (rawID, candidates) in threadCandidates where candidates.count == 1 { + guard let owner = candidates.first else { continue } + nextThreadEnvironments[rawID] = owner.environmentID + nextThreadWireIDs[rawID] = owner.wireID + } + + projectEnvironmentIDs = nextProjectEnvironments + projectWireIDs = nextProjectWireIDs + threadEnvironmentIDs = nextThreadEnvironments + threadWireIDs = nextThreadWireIDs + } + + private func refresh(client: T3Client, includeArchived: Bool = false) async throws { + let environment = client.environment + let generation = environmentGeneration + let shell = try await client.shellSnapshot() + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + guard shell.snapshotSequence + >= (shellsByEnvironmentID[environment.id]?.snapshotSequence ?? .min) else { + return + } + shellsByEnvironmentID[environment.id] = shell + if activeEnvironment?.id == environment.id { + latestShell = shell + } + rebuildEntityIndexes( + (try? await runtime.environments()) ?? [environment] + ) + if includeArchived, + let archivedShell = try? await client.archivedShellSnapshot(), + isKnownClient(client, environmentID: environment.id, generation: generation) { + archivedThreadsByEnvironmentID[environment.id] = archivedShell.threads.map { + mapThread($0, environment: environment) + } + archivedShellThreadsByEnvironmentID[environment.id] = Dictionary( + uniqueKeysWithValues: archivedShell.threads.map { ($0.id, $0) } + ) + rebuildEntityIndexes((try? await runtime.environments()) ?? [environment]) + } + await emitSnapshot(shell, environment: environment) + } + + private func scheduleArchivedRefresh(client: T3Client, environment: Environment) { + archivedRefreshTask?.cancel() + let generation = environmentGeneration + archivedRefreshTask = Task { [weak self] in + guard let self, + let archivedShell = try? await client.archivedShellSnapshot(), + !Task.isCancelled, + self.isCurrentSession(client: client, generation: generation) else { + return + } + self.archivedThreadsByEnvironmentID[environment.id] = archivedShell.threads.map { + self.mapThread($0, environment: environment) + } + self.archivedShellThreadsByEnvironmentID[environment.id] = Dictionary( + uniqueKeysWithValues: archivedShell.threads.map { ($0.id, $0) } + ) + self.rebuildEntityIndexes( + (try? await self.runtime.environments()) ?? [environment] + ) + if let shell = self.latestShell { + await self.emitSnapshot(shell) + } + } + } + + private func refreshThread(id: String, client: T3Client) async throws { + let route = try threadRoute(for: id) + guard route.client === client else { + throw NativeFeatureClientError.threadNotFound + } + let environment = route.client.environment + let generation = environmentGeneration + let supportsPagination = serverConfigsByEnvironmentID[ + environment.id + ]?.threadSnapshotPagination == true + let snapshot = try await client.threadSnapshot( + id: route.wireID, + turnLimit: supportsPagination ? Self.initialThreadUserTurnLimit : nil + ) + guard isKnownClient(client, environmentID: environment.id, generation: generation) else { + throw CancellationError() + } + if activeThreadID == route.uiID { + guard snapshot.snapshotSequence >= (activeThreadSequence ?? 0) else { return } + threadHistoryEpoch &+= 1 + pendingOlderThreadPage = nil + activeRawThread = snapshot.thread + activeThreadSequence = snapshot.snapshotSequence + activeThreadPage = featurePage(snapshot.page) + } + let detail = mapDetail( + snapshot.thread, + environment: environment, + sourceSequence: snapshot.snapshotSequence, + page: activeThreadID == route.uiID ? activeThreadPage : featurePage(snapshot.page) + ) + publish(detail, threadID: route.uiID) + let hydrationBase = latestDetails[route.uiID] ?? detail + let hydrated = await hydratedAttachmentURLs( + in: hydrationBase, + client: client, + environmentID: environment.id, + generation: generation + ) + guard isKnownClient(client, environmentID: environment.id, generation: generation), + latestDetails[route.uiID] == hydrationBase, + hydrated != hydrationBase else { + return + } + publish(hydrated, threadID: route.uiID, synchronizeRenderedMessages: true) + } + + private func emitSnapshot( + _ shell: OrchestrationShellSnapshot, + environment sourceEnvironment: Environment? = nil, + markSourceConnected: Bool = true, + expectedGeneration: Int? = nil + ) async { + guard let environment = activeEnvironment else { return } + let sourceEnvironment = sourceEnvironment ?? environment + let generation = environmentGeneration + guard expectedGeneration == nil || expectedGeneration == generation else { return } + let environments = (try? await runtime.environments()) ?? [environment] + guard generation == environmentGeneration, + expectedGeneration == nil || expectedGeneration == environmentGeneration, + activeEnvironment?.id == environment.id, + shell.snapshotSequence + >= (shellsByEnvironmentID[sourceEnvironment.id]?.snapshotSequence ?? .min) else { + return + } + shellsByEnvironmentID[sourceEnvironment.id] = shell + if markSourceConnected { + environmentConnectionStates[sourceEnvironment.id] = .connected + environmentConnectionDetails[sourceEnvironment.id] = nil + } + if sourceEnvironment.id == environment.id { + latestShell = shell + } + rebuildEntityIndexes(environments) + synchronizeActiveDetail( + with: shell, + environment: sourceEnvironment + ) + let connectionState: FeatureConnection.State + let connectionDetail: String? + if sourceEnvironment.id == environment.id, markSourceConnected { + connectionState = .connected + connectionDetail = nil + } else { + connectionState = latestSnapshot?.connection.state + ?? environmentConnectionStates[environment.id] + ?? .disconnected + connectionDetail = latestSnapshot?.connection.detail + } + let snapshot = makeSnapshot( + environments: environments, + activeEnvironment: environment, + connectionState: connectionState, + connectionDetail: connectionDetail + ) + publish(snapshot) + } + + /// The detail stream does not carry shell-only background liveness. Merge + /// that small state directly so a settled parent turn still reads as live. + private func synchronizeActiveDetail( + with shell: OrchestrationShellSnapshot, + environment: Environment + ) { + guard activeThreadEnvironmentID == environment.id, + let threadID = activeThreadID, + let wireID = threadWireIDs[threadID], + let shellThread = shell.threads.first(where: { $0.id == wireID }), + var detail = latestDetails[threadID] else { + return + } + + let backgroundLiveness = shellThread.backgroundLiveness + let backgroundWorkIsActive = backgroundLiveness == .working + let sessionIsLive = shellThread.session?.status == "starting" + || shellThread.session?.status == "running" + detail.thread.state = Self.resolveThreadState( + latestTurn: shellThread.latestTurn, + session: shellThread.session, + hasApprovals: !detail.approvals.isEmpty, + hasUserInput: !detail.userInputs.isEmpty, + backgroundLiveness: backgroundLiveness + ) + detail.thread.workingStartedAt = workingStartedAt( + latestTurn: shellThread.latestTurn, + session: shellThread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: shellThread.updatedAt + ) + if shell.snapshotSequence >= (activeThreadSequence ?? .min) { + applySettlementAuthority(from: shellThread, to: &detail.thread) + } + detail.backgroundWorkIsActive = backgroundWorkIsActive + detail.activeSubagentCount = backgroundWorkIsActive || sessionIsLive + ? detailRenderCaches[threadID]?.subagents.activeCount ?? 0 + : 0 + guard latestDetails[threadID] != detail else { return } + publish(detail, threadID: threadID, renderCacheIsSource: true) + } + + /// Thread-only shell changes stay granular so Home does not replace and + /// diff the aggregate snapshot for every active turn update. Structural + /// changes retain the canonical snapshot event as a safe fallback. + private func publish(_ snapshot: FeatureSnapshot) { + guard let previous = latestSnapshot else { + latestSnapshot = snapshot + continuation.yield(.snapshot(snapshot)) + return + } + guard previous != snapshot else { return } + latestSnapshot = snapshot + + guard canPublishThreadDelta(from: previous, to: snapshot) else { + continuation.yield(.snapshot(snapshot)) + return + } + + let previousByID = previous.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let nextByID = snapshot.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let removedIDs = previous.threads.compactMap { thread in + nextByID[thread.id] == nil ? thread.id : nil + } + let changedThreads = snapshot.threads.filter { previousByID[$0.id] != $0 } + + guard !removedIDs.isEmpty || !changedThreads.isEmpty else { + // A count-only project correction has no corresponding thread + // event that could reproduce it in the feature model. + continuation.yield(.snapshot(snapshot)) + return + } + for id in removedIDs { + continuation.yield(.threadRemoved(id: id)) + } + for thread in changedThreads { + continuation.yield(.thread(thread)) + } + } + + private func canPublishThreadDelta( + from previous: FeatureSnapshot, + to next: FeatureSnapshot + ) -> Bool { + previous.connection == next.connection + && previous.environments == next.environments + && previous.providers == next.providers + && previous.providersByEnvironment == next.providersByEnvironment + && previous.preferencesByEnvironment == next.preferencesByEnvironment + && previous.settings == next.settings + && projectsMatchIgnoringThreadCounts(previous.projects, next.projects) + } + + private func projectsMatchIgnoringThreadCounts( + _ lhs: [FeatureProject], + _ rhs: [FeatureProject] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { left, right in + left.id == right.id + && left.wireID == right.wireID + && left.environmentID == right.environmentID + && left.name == right.name + && left.path == right.path + && left.defaultSelection == right.defaultSelection + && left.repositoryIdentity == right.repositoryIdentity + && left.createdAt == right.createdAt + && left.updatedAt == right.updatedAt + } + } + + /// Preserve the unchanged transcript prefix when a streaming update only + /// replaces the tail message. The public event remains authoritative and + /// backwards compatible for non-native FeatureClient implementations. + private func publish( + _ detail: FeatureThreadDetail, + threadID: String, + renderCacheIsSource: Bool = false, + synchronizeRenderedMessages: Bool = false, + delta: FeatureDetailDelta? = nil + ) { + if renderCacheIsSource { + // Reducer-provided mutations already updated the authoritative + // cache. Avoid a prefix comparison across the entire transcript. + latestDetails[threadID] = detail + if let delta { + continuation.yield(.detailDelta(detail, delta)) + } else { + continuation.yield(.detail(detail)) + } + return + } + let next = latestDetails[threadID].map { current in + mergedDetail(current: current, incoming: detail) + } ?? detail + guard latestDetails[threadID] != next else { return } + latestDetails[threadID] = next + if let cache = detailRenderCaches[threadID] { + cache.approvals = next.approvals + cache.userInputs = next.userInputs + if synchronizeRenderedMessages { + for message in next.messages { + if let index = cache.mergedIndexByID[message.id] { + cache.mergedMessages[index] = message + } + if cache.messagesByID[message.id] != nil { + cache.messagesByID[message.id] = message + } + } + } + } + continuation.yield(.detail(next)) + } + + private func makeDetailDelta( + previous: FeatureThreadDetail?, + next: FeatureThreadDetail, + mutations: NativeDetailRenderMutations + ) -> FeatureDetailDelta? { + guard !mutations.requiresFullRebuild, + let previous, + next.messages.count >= previous.messages.count else { + return nil + } + + var changedIDs = Set(mutations.messages.map(\.id)) + for activity in mutations.activities { + if activity.tone == "error" { + changedIDs.insert("activity-\(activity.id)") + } else if NativeWorkLogAccumulator.accepts(activity) { + changedIDs.insert("work-log-\(activity.turnId ?? "unscoped")") + } + } + + guard let cache = detailRenderCaches[next.thread.id] else { return nil } + let changedMessages = changedIDs.compactMap { id in + cache.mergedIndexByID[id].map { cache.mergedMessages[$0] } + } + let appendedCount = next.messages.count - previous.messages.count + let appendedMessageIDs = appendedCount == 0 + ? [] + : next.messages.suffix(appendedCount).map(\.id) + + // A newly rendered entity with an older timestamp can be inserted into + // history. That rare path takes one authoritative diff instead of + // applying an invalid append-only delta. + guard appendedMessageIDs.allSatisfy(changedIDs.contains) else { return nil } + return FeatureDetailDelta( + changedMessages: changedMessages, + appendedMessageIDs: appendedMessageIDs + ) + } + + private func mergedDetail( + current: FeatureThreadDetail, + incoming: FeatureThreadDetail + ) -> FeatureThreadDetail { + FeatureThreadDetail( + thread: incoming.thread, + messages: replacingChangedSuffix(current.messages, with: incoming.messages), + approvals: replacingChangedSuffix(current.approvals, with: incoming.approvals), + userInputs: replacingChangedSuffix(current.userInputs, with: incoming.userInputs), + page: incoming.page, + activeSubagentCount: incoming.activeSubagentCount, + backgroundWorkIsActive: incoming.backgroundWorkIsActive + ) + } + + private func replacingChangedSuffix( + _ current: [Element], + with incoming: [Element] + ) -> [Element] { + guard current != incoming else { return current } + let prefixCount = zip(current, incoming).prefix { pair in + pair.0 == pair.1 + }.count + var result = current + result.replaceSubrange(prefixCount..., with: incoming.dropFirst(prefixCount)) + return result + } + + private func disconnectedSnapshot( + environments: [Environment], + detail: String? = nil + ) -> FeatureSnapshot { + FeatureSnapshot( + connection: .init(state: .disconnected, detail: detail), + environments: environments.map { mapEnvironment($0, activeID: nil) }, + settings: loadSettings() + ) + } + + private func emitConnection( + _ state: FeatureConnection.State, + detail: String? = nil + ) { + guard let environment = activeEnvironment else { return } + // Shell event loops call this per event; only publish real transitions. + guard environmentConnectionStates[environment.id] != state + || environmentConnectionDetails[environment.id] != detail else { return } + environmentConnectionStates[environment.id] = state + environmentConnectionDetails[environment.id] = detail + let connection = FeatureConnection( + state: state, + environmentName: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + detail: detail + ) + if var snapshot = latestSnapshot { + snapshot.connection = connection + if let index = snapshot.environments.firstIndex(where: { $0.id == environment.id }) { + snapshot.environments[index].connectionState = state + snapshot.environments[index].connectionDetail = detail + } + latestSnapshot = snapshot + continuation.yield(.snapshot(snapshot)) + return + } + continuation.yield(.connection(connection)) + } + + private func makeSnapshot( + environments: [Environment], + activeEnvironment: Environment, + connectionState: FeatureConnection.State, + connectionDetail: String? = nil + ) -> FeatureSnapshot { + let enabledEnvironments = environments.filter(\.isEnabled) + let threads = enabledEnvironments.flatMap { environment in + let live = shellsByEnvironmentID[environment.id]?.threads.map { + mapThread($0, environment: environment) + } ?? [] + let liveIDs = Set(live.map(\.id)) + let cached = (archivedThreadsByEnvironmentID[environment.id] ?? []).filter { + !liveIDs.contains($0.id) + } + return live + cached + } + let threadCountByProjectID = threads.reduce(into: [String: Int]()) { + $0[$1.projectID, default: 0] += 1 + } + let projects = enabledEnvironments.flatMap { environment in + (shellsByEnvironmentID[environment.id]?.projects ?? []).map { project in + let uiID = FeatureScopedID.project( + environmentID: environment.id, + wireID: project.id + ) + return FeatureProject( + id: uiID, + wireID: project.id, + environmentID: environment.id, + name: project.title, + path: project.workspaceRoot, + threadCount: threadCountByProjectID[uiID, default: 0], + defaultSelection: project.defaultModelSelection.map(mapSelection), + repositoryIdentity: project.repositoryIdentity.map { + FeatureRepositoryIdentity( + canonicalKey: $0.canonicalKey, + rootPath: $0.rootPath, + displayName: $0.displayName, + name: $0.name + ) + }, + createdAt: project.createdAt, + updatedAt: project.updatedAt + ) + } + } + let providersByEnvironment = enabledEnvironments.reduce( + into: [String: [FeatureProvider]]() + ) { catalogues, environment in + guard let shell = shellsByEnvironmentID[environment.id] else { return } + catalogues[environment.id] = mapProviders( + environmentID: environment.id, + shell: shell, + config: serverConfigsByEnvironmentID[environment.id] + ) + } + let preferencesByEnvironment = enabledEnvironments.reduce( + into: [String: FeatureEnvironmentPreferences]() + ) { preferences, environment in + guard let config = serverConfigsByEnvironmentID[environment.id], + let serverSettings = config.settings else { + return + } + let defaultWorkspaceMode: FeatureWorkspaceMode = + switch serverSettings.defaultThreadEnvMode { + case .local: .local + case .worktree: .worktree + } + let groupingMode: FeatureEnvironmentPreferences.ProjectGroupingMode = + switch serverSettings.sidebarProjectGroupingMode { + case .repositoryPath: .repositoryPath + case .separate: .separate + case .repository, nil: .repository + } + let groupingOverrides = serverSettings.sidebarProjectGroupingOverrides? + .mapValues { mode -> FeatureEnvironmentPreferences.ProjectGroupingMode in + switch mode { + case .repository: return .repository + case .repositoryPath: return .repositoryPath + case .separate: return .separate + } + } ?? [:] + let capabilities = config.environment?.capabilities + ?? environment.descriptor?.capabilities + let supportsAutomaticSettlement = capabilities?.threadAutoSettlement == true + let supportsImageUploads = capabilities?.attachmentUploads == true + let maxFileAttachmentBytes = supportsImageUploads + ? capabilities?.fileAttachments.map { + min(ManagedAttachmentFileStore.maximumBytes, max(0, $0.maxUploadBytes)) + } + : nil + preferences[environment.id] = FeatureEnvironmentPreferences( + defaultWorkspaceMode: defaultWorkspaceMode, + newWorktreesStartFromOrigin: serverSettings.newWorktreesStartFromOrigin, + projectGroupingMode: groupingMode, + projectGroupingOverrides: groupingOverrides, + automaticSettlement: supportsAutomaticSettlement + ? FeatureAutomaticSettlementSettings( + onMerge: serverSettings.sidebarAutoSettleOnMerge, + afterDays: serverSettings.sidebarAutoSettleAfterDays + ) + : nil, + supportsImageUploads: supportsImageUploads, + maxFileAttachmentBytes: maxFileAttachmentBytes + ) + } + return FeatureSnapshot( + connection: FeatureConnection( + state: connectionState, + environmentName: activeEnvironment.label, + endpoint: activeEnvironment.httpBaseURL.absoluteString, + detail: connectionDetail + ), + environments: environments.map { + mapEnvironment($0, activeID: activeEnvironment.id) + }, + projects: projects, + threads: threads, + providers: providersByEnvironment[activeEnvironment.id] ?? [], + providersByEnvironment: providersByEnvironment, + preferencesByEnvironment: preferencesByEnvironment, + settings: loadSettings() + ) + } + + private func mapEnvironment(_ environment: Environment, activeID: String?) -> FeatureEnvironment { + FeatureEnvironment( + id: environment.id, + name: environment.label, + endpoint: environment.httpBaseURL.absoluteString, + isActive: environment.id == activeID, + isEnabled: environment.isEnabled, + source: environment.kind == .managedDPoP ? .t3Connect : .direct, + connectionState: environment.isEnabled + ? environmentConnectionStates[environment.id] + : .disconnected, + connectionDetail: environment.isEnabled + ? environmentConnectionDetails[environment.id] + : nil, + prismEnabled: serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities.forkFlags?["prism"] + ) + } + + private func mapThread( + _ thread: OrchestrationThreadShell, + environment: Environment + ) -> FeatureThread { + let backgroundLiveness = thread.backgroundLiveness + let backgroundWorkIsActive = backgroundLiveness == .working + return FeatureThread( + id: FeatureScopedID.thread(environmentID: environment.id, wireID: thread.id), + wireID: thread.id, + projectID: FeatureScopedID.project( + environmentID: environment.id, + wireID: thread.projectId + ), + environmentID: environment.id, + environmentName: environment.label, + title: thread.title, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + createdAt: parseDate(thread.createdAt), + updatedAt: parseDate(thread.updatedAt), + state: Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: thread.hasPendingApprovals, + hasUserInput: thread.hasPendingUserInput, + backgroundLiveness: backgroundLiveness + ), + providerID: thread.modelSelection.instanceId, + providerName: threadProviderName( + session: thread.session, + modelSelection: thread.modelSelection, + environmentID: environment.id + ), + modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + unsettledAt: thread.unsettledAt.flatMap(parseValidDate), + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.latestUserMessageAt, + latestTurn: thread.latestTurn + ), + snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + pinnedAt: thread.pinnedAt.map(parseDate), + supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + supportsSnooze: environment.descriptor?.capabilities.threadSnooze, + supportsPinning: environment.descriptor?.capabilities.threadPinning, + supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + workingStartedAt: workingStartedAt( + latestTurn: thread.latestTurn, + session: thread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: thread.updatedAt + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), + settlementFacts: settlementFacts( + override: thread.settledOverride, + session: thread.session, + hasApprovals: thread.hasPendingApprovals, + hasUserInput: thread.hasPendingUserInput, + latestUserMessageAt: thread.latestUserMessageAt, + latestTurn: thread.latestTurn + ), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapThread( + _ thread: OrchestrationThread, + environment: Environment + ) -> FeatureThread { + let backgroundLiveness = backgroundLiveness( + threadID: thread.id, + environmentID: environment.id + ) + let backgroundWorkIsActive = backgroundLiveness == .working + return FeatureThread( + id: FeatureScopedID.thread(environmentID: environment.id, wireID: thread.id), + wireID: thread.id, + projectID: FeatureScopedID.project( + environmentID: environment.id, + wireID: thread.projectId + ), + environmentID: environment.id, + environmentName: environment.label, + title: thread.title, + preview: previewText(thread.messages.last?.text), + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + createdAt: parseDate(thread.createdAt), + updatedAt: parseDate(thread.updatedAt), + state: Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: backgroundLiveness + ), + providerID: thread.modelSelection.instanceId, + providerName: threadProviderName( + session: thread.session, + modelSelection: thread.modelSelection, + environmentID: environment.id + ), + modelID: thread.modelSelection.model, + modelOptions: mapOptionSelections(thread.modelSelection.options), + isArchived: thread.archivedAt != nil, + isSettled: isSettled(thread.settledOverride, settledAt: thread.settledAt), + keepsActive: thread.settledOverride == "active", + settledAt: thread.settledAt.map(parseDate), + unsettledAt: thread.unsettledAt.flatMap(parseValidDate), + lastActivityAt: lastActivityDate( + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), + snoozedUntil: thread.snoozedUntil.map(parseDate), + snoozedAt: thread.snoozedAt.map(parseDate), + pinnedAt: thread.pinnedAt.map(parseDate), + supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + supportsSnooze: environment.descriptor?.capabilities.threadSnooze, + supportsPinning: environment.descriptor?.capabilities.threadPinning, + supportsTitleRegeneration: environment.descriptor?.capabilities.threadTitleRegeneration, + supportsPullRequestLinking: environment.descriptor?.capabilities.threadPullRequestLinking, + attentionAt: failureDate( + latestTurn: thread.latestTurn, + session: thread.session + ), + workingStartedAt: workingStartedAt( + latestTurn: thread.latestTurn, + session: thread.session, + backgroundWorkIsActive: backgroundWorkIsActive, + fallbackUpdatedAt: thread.updatedAt + ), + latestTurnCompletedAt: thread.latestTurn?.completedAt.map(parseDate), + settlementFacts: settlementFacts( + override: thread.settledOverride, + session: thread.session, + hasApprovals: false, + hasUserInput: false, + latestUserMessageAt: thread.messages.last(where: { $0.role == "user" })?.createdAt, + latestTurn: thread.latestTurn + ), + runtimeMode: mapRuntimeMode(thread.runtimeMode), + interactionMode: mapInteractionMode(thread.interactionMode) + ) + } + + private func mapDetail( + _ thread: OrchestrationThread, + environment: Environment, + sourceSequence: Int, + mutations: NativeDetailRenderMutations? = nil, + page: FeatureThreadPage? = nil + ) -> FeatureThreadDetail { + let threadID = FeatureScopedID.thread( + environmentID: environment.id, + wireID: thread.id + ) + let cache = detailRenderCaches[threadID] ?? NativeDetailRenderCache() + detailRenderCaches[threadID] = cache + markThreadCacheRecentlyUsed(threadID) + + if !cache.isInitialized || mutations == nil || mutations?.requiresFullRebuild == true { + cache.messagesByID = thread.messages.reduce(into: [:]) { result, raw in + result[raw.id] = mapMessage(raw, environmentID: environment.id) + } + cache.approvals = pendingApprovals(thread, environment: environment) + cache.userInputs = pendingUserInputs(thread, environment: environment) + let errors = thread.activities.compactMap(mapErrorActivity) + let sessionIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + let activityMessages = (errors + collapsedWorkLogs( + thread.activities, + sessionIsLive: sessionIsLive + )) + .sorted { $0.createdAt < $1.createdAt } + seedWorkLogs(thread.activities, sessionIsLive: sessionIsLive, cache: cache) + cache.subagents.reset(with: thread.activities) + let messages = thread.messages.compactMap { cache.messagesByID[$0.id] } + cache.mergedMessages = (messages + activityMessages) + .sorted { $0.createdAt < $1.createdAt } + rebuildMergedIndexes(cache) + cache.isInitialized = true + } else if let mutations { + for message in mutations.messages { + let mapped = mapMessage(message, environmentID: environment.id) + cache.messagesByID[message.id] = mapped + upsertMergedMessage(mapped, cache: cache) + } + for activity in mutations.activities { + applyActivityMutation( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + } + } else { + assertionFailure("Initialized detail caches require an incremental mutation") + } + + var mappedThread = mapThread(thread, environment: environment) + let backgroundLiveness = backgroundLiveness( + threadID: thread.id, + environmentID: environment.id + ) + let backgroundWorkIsActive = backgroundLiveness == .working + let sessionIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + if !sessionIsLive { + for (groupID, var accumulator) in cache.workLogsByGroupID + where accumulator.hasActiveWork { + accumulator.clearActiveWork() + cache.workLogsByGroupID[groupID] = accumulator + upsertMergedMessage(accumulator.message(groupID: groupID), cache: cache) + } + } + mappedThread.state = Self.resolveThreadState( + latestTurn: thread.latestTurn, + session: thread.session, + hasApprovals: !cache.approvals.isEmpty, + hasUserInput: !cache.userInputs.isEmpty, + backgroundLiveness: backgroundLiveness + ) + mappedThread.settlementFacts?.hasPendingApprovals = !cache.approvals.isEmpty + mappedThread.settlementFacts?.hasPendingUserInput = !cache.userInputs.isEmpty + if let shell = shellsByEnvironmentID[environment.id], + let shellThread = shell.threads.first(where: { $0.id == thread.id }), + shell.snapshotSequence >= sourceSequence { + applySettlementAuthority(from: shellThread, to: &mappedThread) + } + return FeatureThreadDetail( + thread: mappedThread, + messages: cache.mergedMessages, + approvals: cache.approvals, + userInputs: cache.userInputs, + page: page, + activeSubagentCount: backgroundWorkIsActive || sessionIsLive + ? cache.subagents.activeCount + : 0, + backgroundWorkIsActive: backgroundWorkIsActive + ) + } + + private func backgroundLiveness( + threadID: String, + environmentID: String + ) -> OrchestrationBackgroundLiveness? { + if let live = shellsByEnvironmentID[environmentID]?.threads + .first(where: { $0.id == threadID })?.backgroundLiveness { + return live + } + return archivedShellThreadsByEnvironmentID[environmentID]?[threadID]? + .backgroundLiveness + } + + private func markThreadCacheRecentlyUsed(_ threadID: String) { + detailCacheRecency.removeAll { $0 == threadID } + detailCacheRecency.append(threadID) + } + + private func evictOldThreadCachesIfNeeded() { + while detailCacheRecency.count > Self.maximumRetainedThreadDetails { + let threadID = detailCacheRecency.removeFirst() + guard threadID != activeThreadID else { + detailCacheRecency.append(threadID) + break + } + latestDetails[threadID] = nil + detailRenderCaches[threadID] = nil + terminalSnapshots = terminalSnapshots.filter { $0.key.threadID != threadID } + if let hydration = attachmentHydrationTasks.removeValue(forKey: threadID) { + hydration.task.cancel() + } + } + } + + private func featurePage( + _ page: OrchestrationThreadDetailPage?, + isLoading: Bool = false + ) -> FeatureThreadPage? { + page.map { + FeatureThreadPage( + beforeCursor: $0.beforeCursor, + hasMore: $0.hasMore, + isLoading: isLoading + ) + } + } + + private func publishActivePageState(threadID: String) { + guard var detail = latestDetails[threadID] else { return } + detail.page = activeThreadPage + publish(detail, threadID: threadID, renderCacheIsSource: true) + } + + private func clearOlderThreadLoading(threadID: String) { + pendingOlderThreadPage = nil + activeThreadPage?.isLoading = false + publishActivePageState(threadID: threadID) + } + + private func tryMergePendingOlderThreadPage(route: NativeThreadRoute) { + guard let pending = pendingOlderThreadPage, + pending.threadID == route.uiID, + pending.environmentID == route.environmentID else { return } + guard pending.epoch == threadHistoryEpoch else { + clearOlderThreadLoading(threadID: route.uiID) + return + } + if let watermark = pending.snapshot.page?.threadSequence, + watermark > (activeThreadSequence ?? 0) { + return + } + pendingOlderThreadPage = nil + _ = mergeOlderThreadPage(pending.snapshot, route: route) + } + + @discardableResult + private func mergeOlderThreadPage( + _ snapshot: OrchestrationThreadDetailSnapshot, + route: NativeThreadRoute + ) -> FeatureThreadDetail? { + guard activeThreadID == route.uiID, + let loadedThread = activeRawThread, + let currentDetail = latestDetails[route.uiID] else { + clearOlderThreadLoading(threadID: route.uiID) + return latestDetails[route.uiID] + } + + let mergedThread = mergingOlderHistory(snapshot.thread, into: loadedThread) + let olderMessages = renderedHistoryMessages( + snapshot.thread, + environmentID: route.environmentID + ) + let loadedMessageIDs = Set(currentDetail.messages.map(\.id)) + let mergedMessages = ( + olderMessages.filter { !loadedMessageIDs.contains($0.id) } + + currentDetail.messages + ).sorted { $0.createdAt < $1.createdAt } + + activeRawThread = mergedThread + activeThreadPage = featurePage(snapshot.page) + + if let cache = detailRenderCaches[route.uiID] { + for rawMessage in snapshot.thread.messages where cache.messagesByID[rawMessage.id] == nil { + cache.messagesByID[rawMessage.id] = mapMessage( + rawMessage, + environmentID: route.environmentID + ) + } + cache.mergedMessages = mergedMessages + rebuildMergedIndexes(cache) + } + + let detail = FeatureThreadDetail( + thread: currentDetail.thread, + messages: mergedMessages, + approvals: currentDetail.approvals, + userInputs: currentDetail.userInputs, + page: activeThreadPage, + activeSubagentCount: currentDetail.activeSubagentCount, + backgroundWorkIsActive: currentDetail.backgroundWorkIsActive + ) + publish(detail, threadID: route.uiID, renderCacheIsSource: true) + scheduleAttachmentHydration( + in: detail, + threadID: route.uiID, + client: route.client, + environmentID: route.environmentID + ) + return detail + } + + private func renderedHistoryMessages( + _ thread: OrchestrationThread, + environmentID: String + ) -> [FeatureMessage] { + let messages = thread.messages.map { + mapMessage($0, environmentID: environmentID) + } + let workIsLive = thread.session?.status == "starting" + || thread.session?.status == "running" + || backgroundLiveness(threadID: thread.id, environmentID: environmentID) == .working + let activities = thread.activities.compactMap(mapErrorActivity) + + collapsedWorkLogs(thread.activities, sessionIsLive: workIsLive) + return (messages + activities).sorted { $0.createdAt < $1.createdAt } + } + + private func mergingOlderHistory( + _ older: OrchestrationThread, + into loaded: OrchestrationThread + ) -> OrchestrationThread { + func prependByID( + _ olderRows: [Element], + _ loadedRows: [Element] + ) -> [Element] where Element.ID: Hashable { + let loadedIDs = Set(loadedRows.map(\.id)) + return olderRows.filter { !loadedIDs.contains($0.id) } + loadedRows + } + + let loadedCheckpointTurns = Set(loaded.checkpoints.map(\.turnId)) + return OrchestrationThread( + id: loaded.id, + projectId: loaded.projectId, + title: loaded.title, + modelSelection: loaded.modelSelection, + runtimeMode: loaded.runtimeMode, + interactionMode: loaded.interactionMode, + branch: loaded.branch, + worktreePath: loaded.worktreePath, + linkedPullRequest: loaded.linkedPullRequest, + latestTurn: loaded.latestTurn, + createdAt: loaded.createdAt, + updatedAt: loaded.updatedAt, + archivedAt: loaded.archivedAt, + settledOverride: loaded.settledOverride, + settledAt: loaded.settledAt, + unsettledAt: loaded.unsettledAt, + snoozedUntil: loaded.snoozedUntil, + snoozedAt: loaded.snoozedAt, + pinnedAt: loaded.pinnedAt, + deletedAt: loaded.deletedAt, + messages: prependByID(older.messages, loaded.messages), + activities: prependByID(older.activities, loaded.activities), + checkpoints: older.checkpoints.filter { + !loadedCheckpointTurns.contains($0.turnId) + } + loaded.checkpoints, + session: loaded.session + ) + } + + private func rebuildMergedIndexes(_ cache: NativeDetailRenderCache) { + cache.mergedIndexByID = cache.mergedMessages.enumerated().reduce(into: [:]) { + $0[$1.element.id] = $1.offset + } + } + + /// Known stream events are chronological, so new render entities land at + /// the tail and existing streaming/work-log entities patch in constant time. + private func upsertMergedMessage( + _ message: FeatureMessage, + cache: NativeDetailRenderCache + ) { + if let index = cache.mergedIndexByID[message.id] { + cache.mergedMessages[index] = message + return + } + if let last = cache.mergedMessages.last, last.createdAt > message.createdAt { + // Out-of-order events are rare; preserve correctness while keeping + // the normal append path independent of transcript size. + cache.mergedMessages.append(message) + cache.mergedMessages.sort { $0.createdAt < $1.createdAt } + rebuildMergedIndexes(cache) + return + } + cache.mergedIndexByID[message.id] = cache.mergedMessages.count + cache.mergedMessages.append(message) + } + + private func applyActivityMutation( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + cache.subagents.apply(activity) + applyApprovalActivity( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + applyUserInputActivity( + activity, + threadID: threadID, + environment: environment, + cache: cache + ) + if let error = mapErrorActivity(activity) { + upsertMergedMessage(error, cache: cache) + } + guard NativeWorkLogAccumulator.accepts(activity), + cache.workLogActivityIDs.insert(activity.id).inserted else { + return + } + let groupID = activity.turnId ?? "unscoped" + var accumulator = cache.workLogsByGroupID[groupID] ?? NativeWorkLogAccumulator() + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + cache.workLogsByGroupID[groupID] = accumulator + guard accumulator.hasContent else { return } + let message = accumulator.message(groupID: groupID) + upsertMergedMessage(message, cache: cache) + } + + /// Decorate-sort so each timestamp is parsed once (via the memoized date + /// cache) instead of inside an O(n log n) comparator. Raw string order is + /// not safe here: the wire can mix fractional and non-fractional ISO8601 + /// representations, which sort lexicographically wrong. Ties keep wire + /// order so a request and its resolution never swap. + private func sortedByCreation( + _ activities: [OrchestrationActivity] + ) -> [OrchestrationActivity] { + var decorated: [(index: Int, date: Date, activity: OrchestrationActivity)] = [] + decorated.reserveCapacity(activities.count) + for (index, activity) in activities.enumerated() { + decorated.append((index, parseDate(activity.createdAt), activity)) + } + decorated.sort { lhs, rhs in + lhs.date != rhs.date ? lhs.date < rhs.date : lhs.index < rhs.index + } + return decorated.map(\.activity) + } + + private func seedWorkLogs( + _ activities: [OrchestrationActivity], + sessionIsLive: Bool, + cache: NativeDetailRenderCache + ) { + cache.workLogsByGroupID.removeAll(keepingCapacity: true) + cache.workLogActivityIDs.removeAll(keepingCapacity: true) + for activity in sortedByCreation(activities) + where NativeWorkLogAccumulator.accepts(activity) { + cache.workLogActivityIDs.insert(activity.id) + let groupID = activity.turnId ?? "unscoped" + var accumulator = cache.workLogsByGroupID[groupID] ?? NativeWorkLogAccumulator() + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + cache.workLogsByGroupID[groupID] = accumulator + } + if !sessionIsLive { + for groupID in cache.workLogsByGroupID.keys { + cache.workLogsByGroupID[groupID]?.clearActiveWork() + } + } + } + + private func applyApprovalActivity( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + guard let requestID = activity.payload["requestId"]?.stringValue else { return } + let uiRequestID = FeatureScopedID.approval( + environmentID: environment.id, + wireID: requestID + ) + switch activity.kind { + case "approval.requested": + let kind = Self.approvalKind(activity.payload) + let appName = activity.payload["appName"]?.stringValue + let approval = FeatureApproval( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + kind: kind, + title: appName ?? activity.summary, + detail: activity.payload["detail"]?.stringValue ?? activity.summary, + appName: appName, + options: Self.approvalOptions(activity.payload) + ) + cache.approvals.removeAll { $0.id == uiRequestID } + cache.approvals.append(approval) + cache.approvals.sort { $0.id < $1.id } + approvalRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + case "approval.resolved": + cache.approvals.removeAll { $0.id == uiRequestID } + approvalRoutes[uiRequestID] = nil + case "provider.approval.respond.failed": + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + guard detail.contains("stale") || detail.contains("unknown") else { return } + cache.approvals.removeAll { $0.id == uiRequestID } + approvalRoutes[uiRequestID] = nil + default: + return + } + } + + private func applyUserInputActivity( + _ activity: OrchestrationActivity, + threadID: String, + environment: Environment, + cache: NativeDetailRenderCache + ) { + guard let requestID = activity.payload["requestId"]?.stringValue else { return } + let uiRequestID = FeatureScopedID.input( + environmentID: environment.id, + wireID: requestID + ) + switch activity.kind { + case "user-input.requested": + guard let questions = parseInputQuestions(activity.payload), !questions.isEmpty else { + return + } + let request = FeatureUserInput( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + questions: questions + ) + cache.userInputs.removeAll { $0.id == uiRequestID } + cache.userInputs.append(request) + cache.userInputs.sort { $0.id < $1.id } + inputRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + case "user-input.resolved": + cache.userInputs.removeAll { $0.id == uiRequestID } + inputRoutes[uiRequestID] = nil + case "provider.user-input.respond.failed": + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + guard detail.contains("stale") || detail.contains("unknown") else { return } + cache.userInputs.removeAll { $0.id == uiRequestID } + inputRoutes[uiRequestID] = nil + default: + return + } + } + + private func mapMessage( + _ message: OrchestrationMessage, + environmentID: String + ) -> FeatureMessage { + FeatureMessage( + id: message.id, + role: mapRole(message.role), + text: message.text, + createdAt: parseDate(message.createdAt), + state: message.streaming ? .streaming : .complete, + attachments: (message.attachments ?? []).map { + FeatureMessageAttachment( + id: $0.id, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: $0.sizeBytes, + url: cachedAttachmentURL(for: $0.id, environmentID: environmentID) + ) + } + ) + } + + private func mapErrorActivity(_ activity: OrchestrationActivity) -> FeatureMessage? { + guard activity.tone == "error" else { return nil } + let detail = activity.payload["detail"]?.stringValue + let text = detail.map { "\(activity.summary)\n\($0)" } ?? activity.summary + return FeatureMessage( + id: "activity-\(activity.id)", + role: .system, + text: text, + createdAt: parseDate(activity.createdAt), + state: .complete, + toolName: activity.kind + ) + } + + /// Lifecycle updates can number in the thousands on a long turn. Keep the + /// primary transcript message-sized while preserving a bounded, expandable + /// summary for each turn. + private func collapsedWorkLogs( + _ activities: [OrchestrationActivity], + sessionIsLive: Bool + ) -> [FeatureMessage] { + let groups = Dictionary(grouping: sortedByCreation(activities).filter { + NativeWorkLogAccumulator.accepts($0) + }) { activity in + activity.turnId ?? "unscoped" + } + return groups.compactMap { groupID, group in + var accumulator = NativeWorkLogAccumulator() + for activity in group { + accumulator.append( + activity, + preview: previewText(activity.payload["detail"]?.stringValue), + createdAt: parseDate(activity.createdAt) + ) + } + if !sessionIsLive { accumulator.clearActiveWork() } + return accumulator.hasContent ? accumulator.message(groupID: groupID) : nil + } + } + + private func pendingApprovals( + _ thread: OrchestrationThread, + environment: Environment + ) -> [FeatureApproval] { + var open: [String: FeatureApproval] = [:] + let threadID = FeatureScopedID.thread( + environmentID: environment.id, + wireID: thread.id + ) + for activity in sortedByCreation(thread.activities) { + let requestID = activity.payload["requestId"]?.stringValue + let uiRequestID = requestID.map { + FeatureScopedID.approval(environmentID: environment.id, wireID: $0) + } + if activity.kind == "approval.requested", let requestID { + let kind = Self.approvalKind(activity.payload) + let detail = activity.payload["detail"]?.stringValue ?? activity.summary + let appName = activity.payload["appName"]?.stringValue + let uiRequestID = FeatureScopedID.approval( + environmentID: environment.id, + wireID: requestID + ) + open[uiRequestID] = FeatureApproval( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + kind: kind, + title: appName ?? activity.summary, + detail: detail, + appName: appName, + options: Self.approvalOptions(activity.payload) + ) + approvalRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + } else if activity.kind == "approval.resolved", let uiRequestID { + open[uiRequestID] = nil + approvalRoutes[uiRequestID] = nil + } else if activity.kind == "provider.approval.respond.failed", let uiRequestID { + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + if detail.contains("stale") || detail.contains("unknown") { + open[uiRequestID] = nil + approvalRoutes[uiRequestID] = nil + } + } + } + return open.values.sorted { $0.id < $1.id } + } + + private static func approvalKind(_ payload: JSONValue) -> FeatureApprovalKind { + switch payload["requestKind"]?.stringValue { + case "command": .command + case "file-read": .fileRead + case "file-change": .fileChange + case "mcp-elicitation": .mcpElicitation + default: .other + } + } + + private static func approvalOptions(_ payload: JSONValue) -> [FeatureApprovalOption]? { + guard case let .array(values)? = payload["options"] else { return nil } + let options = values.compactMap { value -> FeatureApprovalOption? in + guard let wireDecision = value["decision"]?.stringValue, + let decision = FeatureApprovalDecision(wireValue: wireDecision), + let label = value["label"]?.stringValue, + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return FeatureApprovalOption(decision: decision, label: label) + } + return options.isEmpty ? nil : options + } + + private func pendingUserInputs( + _ thread: OrchestrationThread, + environment: Environment + ) -> [FeatureUserInput] { + var open: [String: FeatureUserInput] = [:] + let threadID = FeatureScopedID.thread( + environmentID: environment.id, + wireID: thread.id + ) + for activity in sortedByCreation(thread.activities) { + let requestID = activity.payload["requestId"]?.stringValue + let uiRequestID = requestID.map { + FeatureScopedID.input(environmentID: environment.id, wireID: $0) + } + if activity.kind == "user-input.requested", + let requestID, + let questions = parseInputQuestions(activity.payload), + !questions.isEmpty { + let uiRequestID = FeatureScopedID.input( + environmentID: environment.id, + wireID: requestID + ) + open[uiRequestID] = FeatureUserInput( + id: uiRequestID, + wireID: requestID, + threadID: threadID, + questions: questions + ) + inputRoutes[uiRequestID] = PendingRequestRoute( + threadID: threadID, + wireID: requestID + ) + } else if activity.kind == "user-input.resolved", let uiRequestID { + open[uiRequestID] = nil + inputRoutes[uiRequestID] = nil + } else if activity.kind == "provider.user-input.respond.failed", let uiRequestID { + let detail = activity.payload["detail"]?.stringValue?.lowercased() ?? "" + if detail.contains("stale") || detail.contains("unknown") { + open[uiRequestID] = nil + inputRoutes[uiRequestID] = nil + } + } + } + return open.values.sorted { $0.id < $1.id } + } + + private func parseInputQuestions(_ payload: JSONValue) -> [FeatureInputQuestion]? { + guard case let .array(rawQuestions)? = payload["questions"] else { return nil } + return rawQuestions.compactMap { rawQuestion in + guard case let .object(question) = rawQuestion, + let id = question["id"]?.stringValue, + let header = question["header"]?.stringValue, + let text = question["question"]?.stringValue else { + return nil + } + let options: [FeatureInputOption] + if case let .array(rawOptions)? = question["options"] { + options = rawOptions.compactMap { rawOption in + guard case let .object(option) = rawOption, + let label = option["label"]?.stringValue else { + return nil + } + return FeatureInputOption( + label: label, + detail: option["description"]?.stringValue ?? "" + ) + } + } else { + options = [] + } + let allowsMultiple: Bool + if case let .bool(value)? = question["multiSelect"] { + allowsMultiple = value + } else { + allowsMultiple = false + } + return FeatureInputQuestion( + id: id, + header: header, + question: text, + options: options, + allowsMultiple: allowsMultiple + ) + } + } + + nonisolated static func resolveThreadState( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool, + backgroundLiveness: OrchestrationBackgroundLiveness? + ) -> FeatureThreadState { + if hasApprovals { return .waitingForApproval } + if hasUserInput { return .waitingForInput } + if session?.status == "starting" { return .queued } + if session?.status == "running" || latestTurn?.state == "running" { return .working } + if session?.status == "error" || latestTurn?.state == "error" { return .failed } + if backgroundLiveness == .working { return .working } + if backgroundLiveness == .monitoring { return .monitoring } + if latestTurn?.state == "completed" { return .completed } + return .idle + } + + private func mapRole(_ role: String) -> FeatureMessageRole { + switch role { + case "user": .user + case "assistant": .assistant + case "system": .system + default: .tool + } + } + + private func isSettled(_ override: String?, settledAt: String?) -> Bool { + if override == "active" { return false } + return override == "settled" || settledAt != nil + } + + private func settlementFacts( + override: String?, + session: OrchestrationSession?, + hasApprovals: Bool, + hasUserInput: Bool, + latestUserMessageAt: String?, + latestTurn: OrchestrationLatestTurn? + ) -> FeatureThreadSettlementFacts { + return FeatureThreadSettlementFacts( + settlementOverride: override.flatMap(FeatureThreadSettlementOverride.init(rawValue:)), + sessionStatus: session?.status, + hasPendingApprovals: hasApprovals, + hasPendingUserInput: hasUserInput, + latestUserMessageAt: latestUserMessageAt.flatMap(parseValidDate), + latestTurn: latestTurn.map { + FeatureThreadSettlementFacts.LatestTurn( + requestedAt: parseValidDate($0.requestedAt), + startedAt: $0.startedAt.flatMap(parseValidDate), + completedAt: $0.completedAt.flatMap(parseValidDate), + requestedAtIsInvalid: parseValidDate($0.requestedAt) == nil, + startedAtIsInvalid: $0.startedAt.map { parseValidDate($0) == nil } ?? false, + completedAtIsInvalid: $0.completedAt.map { parseValidDate($0) == nil } ?? false + ) + } + ) + } + + private func applySettlementAuthority( + from shell: OrchestrationThreadShell, + to thread: inout FeatureThread + ) { + thread.isSettled = isSettled(shell.settledOverride, settledAt: shell.settledAt) + thread.keepsActive = shell.settledOverride == "active" + thread.settledAt = shell.settledAt.flatMap(parseValidDate) + thread.unsettledAt = shell.unsettledAt.flatMap(parseValidDate) + thread.settlementFacts = settlementFacts( + override: shell.settledOverride, + session: shell.session, + hasApprovals: shell.hasPendingApprovals, + hasUserInput: shell.hasPendingUserInput, + latestUserMessageAt: shell.latestUserMessageAt, + latestTurn: shell.latestTurn + ) + } + + private func mapRuntimeMode(_ mode: RuntimeMode) -> FeatureRuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .auto: .automatic + case .fullAccess: .fullAccess + } + } + + private func coreRuntimeMode(_ mode: FeatureRuntimeMode) -> RuntimeMode { + switch mode { + case .approvalRequired: .approvalRequired + case .autoAcceptEdits: .autoAcceptEdits + case .automatic: .auto + case .fullAccess: .fullAccess + } + } + + private func mapInteractionMode(_: InteractionMode) -> FeatureInteractionMode { + .standard + } + + private func coreInteractionMode(_: FeatureInteractionMode) -> InteractionMode { + .default + } + + /// The mapped catalog for the config-driven branch of mapProviders. Every + /// publish rebuilds the snapshot, but the catalog only changes when a new + /// server config arrives, so mapping hundreds of models per publish is + /// wasted work. Entries are invalidated wherever + /// serverConfigsByEnvironmentID is written. + private var providerCatalogCache: [String: [FeatureProvider]] = [:] + + /// Single write path for server configs so the provider catalog cache can + /// never go stale against the config that feeds it. + private func setServerConfig(_ config: ServerConfigSnapshot, environmentID: String) { + serverConfigsByEnvironmentID[environmentID] = config + providerCatalogCache[environmentID] = nil + } + + private func mapProviders( + environmentID: String, + shell: OrchestrationShellSnapshot, + config: ServerConfigSnapshot? + ) -> [FeatureProvider] { + if let providers = config?.providers, !providers.isEmpty { + if let cached = providerCatalogCache[environmentID] { return cached } + let mapped = mapConfigProviders(providers) + providerCatalogCache[environmentID] = mapped + return mapped + } + return mapShellFallbackProviders(shell) + } + + private func mapConfigProviders( + _ providers: [ServerProviderSnapshot] + ) -> [FeatureProvider] { + Self.normalizedProviders(providers.map { provider in + FeatureProvider( + id: provider.instanceId, + name: provider.displayName ?? providerDisplayName(provider.driver), + isAvailable: provider.enabled + && provider.installed + && provider.status != "disabled" + && provider.status != "error" + && provider.auth.status != "unauthenticated" + && provider.availability != "unavailable", + driver: provider.driver, + requiresNewThreadForModelChange: + provider.requiresNewThreadForModelChange ?? false, + models: provider.models.map { model in + let options = (model.capabilities?.optionDescriptors ?? []) + .map(mapOptionDescriptor) + return FeatureModel( + id: model.slug, + name: model.name, + detail: model.subProvider ?? model.shortName, + supportsReasoning: options.contains { descriptor in + let searchable = "\(descriptor.id) \(descriptor.label)".lowercased() + return searchable.contains("reason") + || searchable.contains("effort") + || searchable.contains("thinking") + }, + isDefault: model.isDefault ?? false, + isLegacy: model.isLegacy, + options: options + ) + }, + slashCommands: (provider.slashCommands ?? []).map { command in + FeatureProviderSlashCommand( + name: command.name, + description: command.description, + inputHint: command.input?.hint + ) + }, + skills: (provider.skills ?? []).map { skill in + FeatureProviderSkill( + name: skill.name, + displayName: skill.displayName, + description: skill.description, + shortDescription: skill.shortDescription, + path: skill.path, + scope: skill.scope, + isEnabled: skill.enabled + ) + } + ) + }) + } + + /// Without a server config the catalog is inferred from selections in the + /// shell, which is cheap enough to rebuild per publish. + private func mapShellFallbackProviders( + _ shell: OrchestrationShellSnapshot + ) -> [FeatureProvider] { + var modelsByProvider: [String: Set] = [:] + for selection in shell.projects.compactMap(\.defaultModelSelection) + + shell.threads.map(\.modelSelection) { + modelsByProvider[selection.instanceId, default: []].insert(selection.model) + } + if modelsByProvider.isEmpty { + modelsByProvider["codex"] = ["gpt-5.6-sol"] + } + return modelsByProvider.keys.sorted().map { providerID in + FeatureProvider( + id: providerID, + name: providerDisplayName(providerID), + driver: providerID, + models: (modelsByProvider[providerID] ?? []).sorted().map { + FeatureModel(id: $0, name: $0) + } + ) + } + } + + static func normalizedProviders( + _ providers: [FeatureProvider] + ) -> [FeatureProvider] { + var normalized: [FeatureProvider] = [] + var providerIndexByID: [String: Int] = [:] + + for var provider in providers { + var seenModelIDs = Set() + provider.models = provider.models.filter { + seenModelIDs.insert($0.id).inserted + } + if let index = providerIndexByID[provider.id] { + var existing = normalized[index] + var existingModelIDs = Set(existing.models.map(\.id)) + existing.models.append(contentsOf: provider.models.filter { + existingModelIDs.insert($0.id).inserted + }) + normalized[index] = existing + } else { + providerIndexByID[provider.id] = normalized.count + normalized.append(provider) + } + } + return normalized + } + + private func modelSelection( + _ selection: FeatureSelection?, + projectID: String, + environmentID: String, + shell: OrchestrationShellSnapshot? + ) -> ModelSelection { + if let selection { + return coreModelSelection(selection) + } + if let projectDefault = shell?.projects + .first(where: { $0.id == projectID })? + .defaultModelSelection { + return projectDefault + } + return fallbackModelSelection( + environmentID: environmentID, + projectID: projectID, + shell: shell + ) + } + + /// Fallback selection is resolved against the target environment. This + /// matters when a passive machine exposes a different provider catalogue + /// than the currently active one. + private func fallbackModelSelection( + environmentID: String, + projectID: String?, + shell: OrchestrationShellSnapshot? + ) -> ModelSelection { + let config = serverConfigsByEnvironmentID[environmentID] + let appSelection = loadSettings().defaultSelection + if let selection = appSelection, let config { + if configSupports(selection, config: config) { + return coreModelSelection(selection) + } + } + if let configuredDefault = defaultModelSelection(in: config) { + return configuredDefault + } + if let projectID, + let recentProjectSelection = shell?.threads + .first(where: { $0.projectId == projectID })? + .modelSelection { + return recentProjectSelection + } + if let knownSelection = shell?.projects.compactMap(\.defaultModelSelection).first + ?? shell?.threads.first?.modelSelection { + return knownSelection + } + if let selection = appSelection { + return coreModelSelection(selection) + } + return ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + } + + private func configSupports( + _ selection: FeatureSelection, + config: ServerConfigSnapshot + ) -> Bool { + config.providers.contains { provider in + provider.instanceId == selection.providerID + && providerCanRun(provider) + && provider.models.contains { $0.slug == selection.modelID } + } + } + + private func defaultModelSelection( + in config: ServerConfigSnapshot? + ) -> ModelSelection? { + guard let providers = config?.providers else { return nil } + for provider in providers where providerCanRun(provider) { + if let model = provider.models.first(where: { $0.isDefault == true }) { + return ModelSelection(instanceId: provider.instanceId, model: model.slug) + } + } + for provider in providers where providerCanRun(provider) { + if let model = provider.models.first { + return ModelSelection(instanceId: provider.instanceId, model: model.slug) + } + } + return nil + } + + private func providerCanRun(_ provider: ServerProviderSnapshot) -> Bool { + provider.enabled + && provider.installed + && provider.status != "disabled" + && provider.status != "error" + && provider.auth.status != "unauthenticated" + && provider.availability != "unavailable" + } + + private func coreModelSelection(_ selection: FeatureSelection) -> ModelSelection { + let options = selection.options.map { option in + ModelSelection.OptionSelection( + id: option.id, + value: coreOptionValue(option.value) + ) + } + return ModelSelection( + instanceId: selection.providerID, + model: selection.modelID, + options: options.isEmpty ? nil : options + ) + } + + private func mapSelection(_ selection: ModelSelection) -> FeatureSelection { + FeatureSelection( + providerID: selection.instanceId, + modelID: selection.model, + options: mapOptionSelections(selection.options) + ) + } + + private func coreOptionValue(_ value: FeatureModelOptionValue) -> JSONValue { + switch value { + case let .string(rawValue): + return .string(rawValue) + case let .boolean(rawValue): + return .bool(rawValue) + } + } + + private func mapOptionSelections( + _ selections: [ModelSelection.OptionSelection]? + ) -> [FeatureModelOptionSelection] { + (selections ?? []).compactMap { selection in + let value: FeatureModelOptionValue + switch selection.value { + case let .string(rawValue): + value = .string(rawValue) + case let .bool(rawValue): + value = .boolean(rawValue) + default: + return nil + } + return FeatureModelOptionSelection(id: selection.id, value: value) + } + } + + private func mapOptionDescriptor( + _ descriptor: ServerProviderOptionDescriptor + ) -> FeatureModelOptionDescriptor { + switch descriptor { + case let .select(value): + let defaultValue = value.currentValue + ?? value.options.first(where: { $0.isDefault == true })?.id + return FeatureModelOptionDescriptor( + id: value.id, + label: value.label, + detail: value.description, + kind: .select, + choices: value.options.map { + FeatureModelOptionChoice( + id: $0.id, + label: $0.label, + detail: $0.description, + isDefault: $0.isDefault ?? false + ) + }, + defaultValue: defaultValue.map(FeatureModelOptionValue.string) + ) + case let .boolean(value): + return FeatureModelOptionDescriptor( + id: value.id, + label: value.label, + detail: value.description, + kind: .boolean, + defaultValue: value.currentValue.map(FeatureModelOptionValue.boolean) + ) + } + } + + private func providerDisplayName(_ id: String) -> String { + switch id { + case "codex": "Codex" + case "claudeAgent", "claude": "Claude" + case "cursor": "Cursor" + case "grok": "Grok" + case "opencode": "OpenCode" + default: id + } + } + + private func threadProviderName( + session: OrchestrationSession?, + modelSelection: ModelSelection, + environmentID: String + ) -> String { + if let name = session?.providerName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty { + return name + } + let providerID = session?.providerInstanceId ?? modelSelection.instanceId + if let provider = serverConfigsByEnvironmentID[environmentID]?.providers.first(where: { + $0.instanceId == providerID + }) { + return provider.displayName ?? providerDisplayName(provider.driver) + } + return providerDisplayName(providerID) + } + + private func cachedAttachmentURL( + for id: String, + environmentID: String? = nil + ) -> URL? { + guard let environmentID = environmentID ?? activeEnvironment?.id else { + return nil + } + let key = AttachmentCacheKey(environmentID: environmentID, attachmentID: id) + guard let cached = attachmentURLs[key] else { return nil } + guard cached.expiresAt > Date().addingTimeInterval(30) else { + attachmentURLs[key] = nil + return nil + } + return cached.url + } + + private func scheduleAttachmentHydration( + in detail: FeatureThreadDetail, + threadID: String, + client: T3Client, + environmentID: String + ) { + guard detail.messages.contains(where: { message in + message.attachments.contains { + $0.mimeType.hasPrefix("image/") && $0.url == nil + } + }) else { + return + } + // Streaming activity can publish many detail revisions per second. Let the + // current asset resolution finish instead of continuously restarting it. + guard attachmentHydrationTasks[threadID] == nil else { return } + let generation = environmentGeneration + let workID = UUID() + let task = Task { [weak self] in + guard let self else { return } + let hydrated = await self.hydratedAttachmentURLs( + in: detail, + client: client, + environmentID: environmentID, + generation: generation + ) + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ), + self.latestDetails[threadID] == detail, + hydrated != detail else { + self.finishAttachmentHydration(threadID: threadID, workID: workID) + if let latest = self.latestDetails[threadID], latest != detail { + self.scheduleAttachmentHydration( + in: latest, + threadID: threadID, + client: client, + environmentID: environmentID + ) + } + return + } + self.publish( + hydrated, + threadID: threadID, + synchronizeRenderedMessages: true + ) + self.finishAttachmentHydration(threadID: threadID, workID: workID) + } + attachmentHydrationTasks[threadID] = (workID, task) + } + + private func finishAttachmentHydration(threadID: String, workID: UUID) { + guard attachmentHydrationTasks[threadID]?.id == workID else { return } + attachmentHydrationTasks[threadID] = nil + } + + private func hydratedAttachmentURLs( + in detail: FeatureThreadDetail, + client: T3Client, + environmentID: String, + generation: Int + ) async -> FeatureThreadDetail { + guard isKnownClient(client, environmentID: environmentID, generation: generation) else { + return detail + } + let attachmentMetadata = detail.messages.flatMap(\.attachments).reduce( + into: [String: (name: String, mimeType: String)]() + ) { metadata, attachment in + metadata[attachment.id] = (attachment.name, attachment.mimeType) + } + // Threads without attachments refresh every couple of seconds. Skip + // the resolve pass and full message walk when there is nothing to hydrate. + guard !attachmentMetadata.isEmpty else { return detail } + let missingIDs = Array(attachmentMetadata.keys.filter { + cachedAttachmentURL(for: $0, environmentID: environmentID) == nil + }) + + await withTaskGroup(of: (String, ResolvedAssetURL?).self) { group in + var iterator = missingIDs.makeIterator() + for _ in 0.. Date? { + [ + latestUserMessageAt, + latestTurn?.requestedAt, + latestTurn?.startedAt, + latestTurn?.completedAt, + ] + .compactMap { $0.flatMap(parseValidDate) } + .max() + } + + private func failureDate( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession? + ) -> Date? { + guard session?.status == "error" || latestTurn?.state == "error" else { + return nil + } + return [ + session?.updatedAt, + latestTurn?.completedAt, + latestTurn?.startedAt, + latestTurn?.requestedAt, + ] + .compactMap { $0.flatMap(parseValidDate) } + .max() + } + + private func workingStartedAt( + latestTurn: OrchestrationLatestTurn?, + session: OrchestrationSession?, + backgroundWorkIsActive: Bool = false, + fallbackUpdatedAt: String? = nil + ) -> Date? { + let directSessionIsLive = session?.status == "starting" + || session?.status == "running" + || latestTurn?.state == "running" + guard directSessionIsLive || backgroundWorkIsActive else { + return nil + } + let candidates: [String?] + if directSessionIsLive, let latestTurn, latestTurn.completedAt == nil { + candidates = [ + latestTurn.startedAt, + latestTurn.requestedAt, + session?.updatedAt, + ] + } else if backgroundWorkIsActive { + candidates = [ + latestTurn?.startedAt, + latestTurn?.requestedAt, + session?.updatedAt, + fallbackUpdatedAt, + ] + } else { + candidates = [session?.updatedAt] + } + return candidates.lazy.compactMap { $0.flatMap(self.parseValidDate) }.first + } + + private func makeUploadAttachments( + _ attachments: [FeatureUploadAttachment] + ) throws -> [UploadChatAttachment] { + guard attachments.count <= 8 else { + throw NativeFeatureClientError.tooManyAttachments + } + return try attachments.map { + let reference = $0.uploadedReference.map { + UploadedAttachmentReference( + environmentID: $0.environmentID, + attachmentID: $0.attachmentID + ) + } + if let ownedFile = $0.ownedFile { + return try UploadChatAttachment( + id: $0.id, + fileURL: ownedFile.url, + name: $0.name, + mimeType: $0.mimeType, + sizeBytes: ownedFile.byteCount, + uploadedReference: reference + ) + } + return try UploadChatAttachment( + id: $0.id, + data: $0.data, + name: $0.name, + mimeType: $0.mimeType, + uploadedReference: reference + ) + } + } + + private func requireScope(_ scope: String, client: T3Client) async throws { + let session = try await client.authSession() + guard session.scopes?.contains(scope) == true else { + throw NativeFeatureClientError.missingScope(scope) + } + } + + private static func title(from prompt: String, hasAttachments: Bool) -> String { + let compact = prompt + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + guard !compact.isEmpty else { + return hasAttachments ? "Image task" : "New thread" + } + guard compact.count > 72 else { return compact } + return "\(compact.prefix(69).trimmingCharacters(in: .whitespacesAndNewlines))..." + } + + private func commandIdentity( + _ identity: FeatureSubmissionIdentity + ) -> CommandIdentity { + CommandIdentity( + commandID: identity.commandID, + messageID: identity.messageID, + createdAt: Self.fractionalDateFormatter.string(from: identity.createdAt) + ) + } + + private static func temporaryWorktreeBranchName(seed: String? = nil) -> String { + let suffix = seed ?? UUID().uuidString + return "t3code/\(suffix.prefix(8).lowercased())" + } + + private func previewText(_ text: String?) -> String? { + guard let text else { return nil } + let compact = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + guard !compact.isEmpty else { return nil } + return compact.count > 160 ? "\(compact.prefix(157))..." : compact + } + + private func loadSettings() -> FeatureSettings { + guard let data = settingsStore.data(forKey: Self.settingsKey), + let settings = try? JSONDecoder().decode(FeatureSettings.self, from: data) else { + return FeatureSettings() + } + return settings + } + + private func parseDate(_ value: String) -> Date { + parseValidDate(value) ?? .distantPast + } + + /// Every publish re-maps every thread, and most timestamps are unchanged + /// between publishes, so parsed dates are memoized. ISO8601DateFormatter + /// costs microseconds per call, which adds up to milliseconds per publish + /// across hundreds of threads during streaming. + private func parseValidDate(_ value: String) -> Date? { + if let cached = parsedDates[value] { return cached } + guard let parsed = Self.fractionalDateFormatter.date(from: value) + ?? Self.dateFormatter.date(from: value) else { return nil } + if parsedDates.count >= 4096 { parsedDates.removeAll(keepingCapacity: true) } + parsedDates[value] = parsed + return parsed + } + + private var parsedDates: [String: Date] = [:] + + private static let settingsKey = "swift-ios.feature-settings.v1" + private static let fractionalDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let dateFormatter = ISO8601DateFormatter() +} + +extension FeatureDeviceSession { + init(relayDevice: T3ConnectRelayDevice, currentDeviceID: String?) { + let updatedAt = Self.t3ConnectRelayDate(relayDevice.updatedAt) + self.init( + sessionID: relayDevice.deviceId, + label: relayDevice.label, + deviceType: relayDevice.platform.lowercased().contains("ipad") ? .tablet : .mobile, + operatingSystem: "iOS \(relayDevice.iosMajorVersion)", + browser: relayDevice.appVersion.map { "T3 Code \($0)" }, + issuedAt: updatedAt, + expiresAt: .distantFuture, + lastConnectedAt: updatedAt, + isConnected: false, + isCurrent: relayDevice.deviceId == currentDeviceID + ) + } + + private static func t3ConnectRelayDate(_ value: String) -> Date { + (try? Date(value, strategy: .iso8601)) ?? .distantPast + } +} + +enum NativeDetailRenderMutation: Equatable { + case full + case message(OrchestrationMessage) + case activity(OrchestrationActivity) + case metadata + case none +} + +struct NativeDetailRenderMutations { + private(set) var requiresFullRebuild = false + private(set) var messages: [OrchestrationMessage] = [] + private(set) var activities: [OrchestrationActivity] = [] + + mutating func formUnion(_ mutation: NativeDetailRenderMutation) { + guard !requiresFullRebuild else { return } + switch mutation { + case .full: + requiresFullRebuild = true + messages.removeAll(keepingCapacity: true) + activities.removeAll(keepingCapacity: true) + case let .message(message): + if let index = messages.firstIndex(where: { $0.id == message.id }) { + messages[index] = message + } else { + messages.append(message) + } + case let .activity(activity): + if let index = activities.firstIndex(where: { $0.id == activity.id }) { + activities[index] = activity + } else { + activities.append(activity) + } + case .metadata, .none: + break + } + } +} + +private final class NativeDetailRenderCache { + var isInitialized = false + var messagesByID: [String: FeatureMessage] = [:] + var mergedMessages: [FeatureMessage] = [] + var mergedIndexByID: [String: Int] = [:] + var workLogsByGroupID: [String: NativeWorkLogAccumulator] = [:] + var workLogActivityIDs: Set = [] + var approvals: [FeatureApproval] = [] + var userInputs: [FeatureUserInput] = [] + var subagents = FeatureActiveSubagentTracker() +} + +struct NativeWorkLogAccumulator { + private static let terminalKinds = Set([ + "tool.completed", "task.completed", "turn.plan.updated", + ]) + private static let activeKinds = Set(["tool.started", "tool.updated"]) + private static let imageExtensions = Set([ + "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "tif", "tiff", "webp", + ]) + + private(set) var count = 0 + private var visibleLines: [String] = [] + private var createdAt = Date.distantPast + private var activeEntries: [String: String] = [:] + private var activeOrder: [String] = [] + private var imagePaths: [String] = [] + + var hasActiveWork: Bool { !activeEntries.isEmpty } + var hasContent: Bool { count > 0 || hasActiveWork || !imagePaths.isEmpty } + + static func accepts(_ activity: OrchestrationActivity) -> Bool { + activeKinds.contains(activity.kind) + || (activity.tone != "error" && terminalKinds.contains(activity.kind)) + } + + mutating func append( + _ activity: OrchestrationActivity, + preview: String?, + createdAt: Date + ) { + if count == 0 && activeEntries.isEmpty { + self.createdAt = createdAt + } + let key = Self.lifecycleKey(activity) + let label = activity.payload["title"]?.stringValue ?? activity.summary + let lifecycleStatus = activity.payload["status"]?.stringValue + let isTerminalUpdate = activity.kind == "tool.updated" + && lifecycleStatus.map { $0 != "inProgress" && $0 != "in_progress" } == true + if Self.activeKinds.contains(activity.kind) && !isTerminalUpdate + && activity.tone != "error" { + activeEntries[key] = label + activeOrder.removeAll { $0 == key } + activeOrder.append(key) + } else { + activeEntries[key] = nil + activeOrder.removeAll { $0 == key } + guard activity.tone != "error" else { return } + count += 1 + visibleLines.append("• \(preview ?? activity.summary)") + if visibleLines.count > 40 { + visibleLines.removeFirst(visibleLines.count - 40) + } + } + if let path = Self.viewedImagePath(activity), !imagePaths.contains(path) { + imagePaths.append(path) + if imagePaths.count > 8 { imagePaths.removeFirst(imagePaths.count - 8) } + } + } + + mutating func clearActiveWork() { + activeEntries.removeAll(keepingCapacity: true) + activeOrder.removeAll(keepingCapacity: true) + } + + func message(groupID: String) -> FeatureMessage { + var lines: [String] = [] + if count > visibleLines.count { + lines.append("\(count - visibleLines.count) earlier updates hidden") + } + lines.append(contentsOf: visibleLines) + return FeatureMessage( + id: "work-log-\(groupID)", + role: .tool, + text: lines.joined(separator: "\n"), + createdAt: createdAt, + state: .complete, + toolName: "Work log · \(count)", + workLogImagePaths: imagePaths.isEmpty ? nil : imagePaths, + activeWorkLabel: activeOrder.last.flatMap { activeEntries[$0] } + ) + } + + private static func lifecycleKey(_ activity: OrchestrationActivity) -> String { + if let id = activity.payload["toolCallId"]?.stringValue + ?? activity.payload["data"]?["toolCallId"]?.stringValue { + return "id:\(id)" + } + let itemType = activity.payload["itemType"]?.stringValue ?? "" + let title = activity.payload["title"]?.stringValue ?? activity.summary + let detail = activity.payload["detail"]?.stringValue ?? "" + return "fallback:\([itemType, title, detail].map(normalizedLifecycleText).joined(separator: "|"))" + } + + private static func normalizedLifecycleText(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences( + of: #"\s+(complete|completed)$"#, + with: "", + options: .regularExpression + ) + } + + private static func viewedImagePath(_ activity: OrchestrationActivity) -> String? { + let itemType = normalizedLifecycleText(activity.payload["itemType"]?.stringValue ?? "") + let title = normalizedLifecycleText(activity.payload["title"]?.stringValue ?? activity.summary) + let qualifies = activity.payload["requestKind"]?.stringValue == "file-read" + || itemType == "image_view" + || (itemType == "dynamic_tool_call" && title == "read file") + guard qualifies, + let detail = activity.payload["detail"]?.stringValue, + !detail.contains("\n"), !detail.contains("\r") else { return nil } + let path = detail.trimmingCharacters(in: .whitespacesAndNewlines) + guard let ext = path.split(separator: ".").last?.lowercased(), + imageExtensions.contains(String(ext)) else { return nil } + return path + } +} + +enum NativeThreadDetailReductionResult: Equatable { + case updated(OrchestrationThread) + case unchanged + case refresh +} + +struct NativeThreadDetailReduction: Equatable { + let sequence: Int + let result: NativeThreadDetailReductionResult + let renderMutation: NativeDetailRenderMutation + + init( + sequence: Int, + result: NativeThreadDetailReductionResult, + renderMutation: NativeDetailRenderMutation = .metadata + ) { + self.sequence = sequence + self.result = result + self.renderMutation = renderMutation + } +} + +/// Swift counterpart to client-runtime's thread reducer for the detail event +/// subset sent by `subscribeThread`. Destructive and forward-unknown events +/// deliberately request an authoritative snapshot. +enum NativeThreadDetailReducer { + static func apply( + _ event: JSONValue, + to thread: OrchestrationThread + ) -> NativeThreadDetailReduction { + guard case let .object(object) = event, + let type = object["type"]?.stringValue, + let occurredAt = object["occurredAt"]?.stringValue, + let sequence = intValue(object["sequence"]), + let payload = object["payload"], + payload["threadId"]?.stringValue == thread.id else { + return NativeThreadDetailReduction( + sequence: -1, + result: .refresh, + renderMutation: .full + ) + } + + let result: NativeThreadDetailReductionResult + var renderMutation = NativeDetailRenderMutation.metadata + switch type { + case "thread.settled": + result = reduceSettled(payload: payload, thread: thread) + case "thread.unsettled": + result = reduceUnsettled(payload: payload, thread: thread) + case "thread.meta-updated": + result = reduceMetadata(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.message-sent": + result = reduceMessage( + payload: payload, + occurredAt: occurredAt, + thread: thread, + renderMutation: &renderMutation + ) + case "thread.activity-appended": + result = reduceActivity( + payload: payload, + occurredAt: occurredAt, + thread: thread, + renderMutation: &renderMutation + ) + case "thread.session-set": + result = reduceSession(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.turn-diff-completed": + result = reduceTurnDiff(payload: payload, occurredAt: occurredAt, thread: thread) + case "thread.proposed-plan-upserted": + // Proposed plans are not rendered by the native detail model yet. + result = .unchanged + renderMutation = .none + case "thread.reverted": + result = .refresh + renderMutation = .full + default: + result = .refresh + renderMutation = .full + } + return NativeThreadDetailReduction( + sequence: sequence, + result: result, + renderMutation: renderMutation + ) + } + + private static func reduceSettled( + payload: JSONValue, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let settledAt = payload["settledAt"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + return .updated( + replacing( + thread, + settlement: SettlementReplacement( + override: "settled", + settledAt: settledAt, + unsettledAt: nil + ), + updatedAt: updatedAt + ) + ) + } + + private static func reduceUnsettled( + payload: JSONValue, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let reason = payload["reason"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + return .updated( + replacing( + thread, + settlement: SettlementReplacement( + override: reason == "user" ? "active" : nil, + settledAt: nil, + unsettledAt: thread.settledOverride == "active" + ? thread.unsettledAt + : updatedAt + ), + updatedAt: updatedAt + ) + ) + } + + private static func reduceMetadata( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard case let .object(values) = payload, + let rawLink = values["linkedPullRequest"] else { + return .refresh + } + guard !["title", "modelSelection", "branch", "worktreePath"].contains(where: { + values[$0] != nil + }) else { + return .refresh + } + let linkedPullRequest: ThreadLinkedPullRequest? + if rawLink == .null { + linkedPullRequest = nil + } else { + guard let decoded = try? rawLink.decode(ThreadLinkedPullRequest.self) else { + return .refresh + } + linkedPullRequest = decoded + } + var updated = replacing( + thread, + updatedAt: payload["updatedAt"]?.stringValue ?? occurredAt + ) + updated.linkedPullRequest = linkedPullRequest + return .updated(updated) + } + + private static func reduceMessage( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread, + renderMutation: inout NativeDetailRenderMutation + ) -> NativeThreadDetailReductionResult { + guard let id = payload["messageId"]?.stringValue, + let role = payload["role"]?.stringValue, + let text = payload["text"]?.stringValue, + let streaming = boolValue(payload["streaming"]), + let createdAt = payload["createdAt"]?.stringValue, + let updatedAt = payload["updatedAt"]?.stringValue else { + return .refresh + } + let turnID = payload["turnId"]?.stringValue + let attachments: [ChatAttachment]? + if let rawAttachments = payload["attachments"], rawAttachments != .null { + guard let decoded = try? rawAttachments.decode([ChatAttachment].self) else { + return .refresh + } + attachments = decoded + } else { + attachments = nil + } + + var messages = thread.messages + let existingIndex = messages.last?.id == id + ? messages.indices.last + : messages.firstIndex(where: { $0.id == id }) + if let index = existingIndex { + let existing = messages[index] + messages[index] = OrchestrationMessage( + id: existing.id, + role: existing.role, + text: streaming ? existing.text + text : (text.isEmpty ? existing.text : text), + attachments: attachments ?? existing.attachments, + turnId: turnID, + streaming: streaming, + createdAt: existing.createdAt, + updatedAt: streaming ? existing.updatedAt : updatedAt + ) + renderMutation = .message(messages[index]) + } else { + let message = OrchestrationMessage( + id: id, + role: role, + text: text, + attachments: attachments, + turnId: turnID, + streaming: streaming, + createdAt: createdAt, + updatedAt: updatedAt + ) + messages.append(message) + renderMutation = .message(message) + } + + var latestTurn = thread.latestTurn + var checkpoints = thread.checkpoints + if role == "assistant", let turnID, + latestTurn == nil || latestTurn?.turnId == turnID { + let turnStillRunning = thread.session?.status == "running" + && thread.session?.activeTurnId == turnID + let settlesTurn = !streaming && !turnStillRunning + let previous = latestTurn?.turnId == turnID ? latestTurn : nil + let state = settlesTurn + ? (previous?.state == "interrupted" || previous?.state == "error" + ? previous!.state + : "completed") + : "running" + latestTurn = OrchestrationLatestTurn( + turnId: turnID, + state: state, + requestedAt: previous?.requestedAt ?? createdAt, + startedAt: previous?.startedAt ?? createdAt, + completedAt: settlesTurn ? updatedAt : previous?.completedAt, + assistantMessageId: id + ) + checkpoints = checkpoints.map { checkpoint in + guard checkpoint.turnId == turnID, + checkpoint.assistantMessageId == nil else { return checkpoint } + return CheckpointSummary( + turnId: checkpoint.turnId, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + status: checkpoint.status, + files: checkpoint.files, + assistantMessageId: id, + completedAt: checkpoint.completedAt + ) + } + } + return .updated( + replacing( + thread, + messages: messages, + checkpoints: checkpoints, + latestTurn: latestTurn, + updatedAt: occurredAt + ) + ) + } + + private static func reduceActivity( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread, + renderMutation: inout NativeDetailRenderMutation + ) -> NativeThreadDetailReductionResult { + guard let raw = payload["activity"], + let activity = try? raw.decode(OrchestrationActivity.self) else { + return .refresh + } + renderMutation = .activity(activity) + return .updated( + // The render cache owns the event tail. Keeping the authoritative + // snapshot array shared avoids copying tens of thousands of old + // activities for each append; a resnapshot rebuilds after recovery. + replacing(thread, updatedAt: occurredAt) + ) + } + + private static func reduceSession( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let raw = payload["session"], + let session = try? raw.decode(OrchestrationSession.self) else { + return .refresh + } + var latestTurn = thread.latestTurn + if session.status == "running", let activeTurnID = session.activeTurnId { + let previous = latestTurn?.turnId == activeTurnID ? latestTurn : nil + latestTurn = OrchestrationLatestTurn( + turnId: activeTurnID, + state: "running", + requestedAt: previous?.requestedAt ?? session.updatedAt, + startedAt: previous?.startedAt ?? session.updatedAt, + completedAt: nil, + assistantMessageId: previous?.assistantMessageId + ) + } else if latestTurn?.state == "running", + let settledState = settledTurnState(session.status), + let current = latestTurn { + latestTurn = OrchestrationLatestTurn( + turnId: current.turnId, + state: settledState, + requestedAt: current.requestedAt, + startedAt: current.startedAt, + completedAt: session.updatedAt, + assistantMessageId: current.assistantMessageId + ) + } + return .updated( + replacing( + thread, + latestTurn: latestTurn, + session: session, + updatedAt: occurredAt + ) + ) + } + + private static func reduceTurnDiff( + payload: JSONValue, + occurredAt: String, + thread: OrchestrationThread + ) -> NativeThreadDetailReductionResult { + guard let turnID = payload["turnId"]?.stringValue, + let turnCount = intValue(payload["checkpointTurnCount"]), + let checkpointRef = payload["checkpointRef"]?.stringValue, + let status = payload["status"]?.stringValue, + let completedAt = payload["completedAt"]?.stringValue, + let rawFiles = payload["files"], + let files = try? rawFiles.decode([CheckpointFile].self) else { + return .refresh + } + let assistantMessageID = payload["assistantMessageId"]?.stringValue + let checkpoint = CheckpointSummary( + turnId: turnID, + checkpointTurnCount: turnCount, + checkpointRef: checkpointRef, + status: status, + files: files, + assistantMessageId: assistantMessageID, + completedAt: completedAt + ) + if let existing = thread.checkpoints.first(where: { $0.turnId == turnID }), + existing.status != "missing", status == "missing" { + return .unchanged + } + var checkpoints = thread.checkpoints.filter { $0.turnId != turnID } + checkpoints.append(checkpoint) + checkpoints.sort { $0.checkpointTurnCount < $1.checkpointTurnCount } + + var latestTurn = thread.latestTurn + let stillRunning = thread.session?.status == "running" + && thread.session?.activeTurnId == turnID + if !stillRunning, latestTurn == nil || latestTurn?.turnId == turnID { + latestTurn = OrchestrationLatestTurn( + turnId: turnID, + state: status == "error" ? "error" : "completed", + requestedAt: latestTurn?.requestedAt ?? completedAt, + startedAt: latestTurn?.startedAt ?? completedAt, + completedAt: completedAt, + assistantMessageId: assistantMessageID + ) + } + return .updated( + replacing( + thread, + checkpoints: checkpoints, + latestTurn: latestTurn, + updatedAt: occurredAt + ) + ) + } + + private struct SettlementReplacement { + let override: String? + let settledAt: String? + let unsettledAt: String? + } + + private static func replacing( + _ thread: OrchestrationThread, + messages: [OrchestrationMessage]? = nil, + activities: [OrchestrationActivity]? = nil, + checkpoints: [CheckpointSummary]? = nil, + latestTurn: OrchestrationLatestTurn? = nil, + session: OrchestrationSession? = nil, + settlement: SettlementReplacement? = nil, + updatedAt: String + ) -> OrchestrationThread { + OrchestrationThread( + id: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest, + latestTurn: latestTurn ?? thread.latestTurn, + createdAt: thread.createdAt, + updatedAt: updatedAt, + archivedAt: thread.archivedAt, + settledOverride: settlement == nil ? thread.settledOverride : settlement?.override, + settledAt: settlement == nil ? thread.settledAt : settlement?.settledAt, + unsettledAt: settlement == nil ? thread.unsettledAt : settlement?.unsettledAt, + snoozedUntil: thread.snoozedUntil, + snoozedAt: thread.snoozedAt, + pinnedAt: thread.pinnedAt, + deletedAt: thread.deletedAt, + messages: messages ?? thread.messages, + activities: activities ?? thread.activities, + checkpoints: checkpoints ?? thread.checkpoints, + session: session ?? thread.session + ) + } + + private static func settledTurnState(_ status: String) -> String? { + switch status { + case "idle", "ready": "completed" + case "error": "error" + case "interrupted", "stopped": "interrupted" + default: nil + } + } + + private static func intValue(_ value: JSONValue?) -> Int? { + guard case let .number(number)? = value else { return nil } + return Int(exactly: number) + } + + private static func boolValue(_ value: JSONValue?) -> Bool? { + guard case let .bool(boolean)? = value else { return nil } + return boolean + } +} + +private struct AttachmentCacheKey: Hashable { + let environmentID: String + let attachmentID: String +} + +private struct CachedAttachmentURL { + let url: URL + let expiresAt: Date +} + +private struct EnvironmentShellLoad: Sendable { + let environment: Environment + let client: T3Client + let shell: OrchestrationShellSnapshot? + let config: ServerConfigSnapshot? +} + +private struct EntityWireOwner: Hashable { + let environmentID: String + let wireID: String +} + +private struct NativeProjectRoute { + let uiID: String + let wireID: String + let environmentID: String + let client: T3Client +} + +private struct PendingOlderThreadPage { + let snapshot: OrchestrationThreadDetailSnapshot + let epoch: Int + let threadID: String + let environmentID: String +} + +private struct NativeThreadRoute { + let uiID: String + let wireID: String + let environmentID: String + let client: T3Client +} + +private struct NativeSourceControlMonitorKey: Hashable { + let environmentID: String + let workingDirectory: String +} + +@MainActor +private final class NativeSourceControlMonitor { + let id = UUID() + var latestStatus: FeatureSourceControlStatus? + var continuations: [UUID: AsyncStream.Continuation] = [:] + var task: Task? +} + +private struct ProvisionalThreadRoute { + let environmentID: String + let wireID: String +} + +private struct PendingRequestRoute { + let threadID: String + let wireID: String +} + +private struct CommandIdentity: Equatable { + let commandID: String + let messageID: String + let createdAt: String + + init( + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) { + self.commandID = commandID + self.messageID = messageID + self.createdAt = createdAt + } +} + +private struct BootstrapSubmissionSignature: Equatable { + let projectID: String + let prompt: String + let model: ModelSelection + let runtimeMode: RuntimeMode + let interactionMode: InteractionMode + let workspaceMode: FeatureWorkspaceMode + let branch: String? + let worktreePath: String? + let startFromOrigin: Bool + let attachments: [FeatureUploadAttachment] +} + +private struct PendingBootstrapSubmission { + let signature: BootstrapSubmissionSignature + let threadID: String + let identity: CommandIdentity + let worktreeBranchName: String? +} + +private struct ThreadCreationSignature: Equatable { + let projectID: String + let title: String + let model: ModelSelection +} + +private struct PendingThreadCreation { + let signature: ThreadCreationSignature + let threadID: String +} + +private struct TurnSubmissionSignature: Equatable { + let text: String + let model: ModelSelection? + let runtimeMode: RuntimeMode + let interactionMode: InteractionMode + let attachments: [FeatureUploadAttachment] +} + +private struct PendingTurnSubmission { + let signature: TurnSubmissionSignature + let identity: CommandIdentity +} + +private enum NativeFeatureClientError: LocalizedError { + case notConnected + case environmentNotFound + case projectNotFound + case threadNotFound + case workspaceNotFound + case approvalNotFound + case inputRequestNotFound + case invalidProjectPath + case branchRequired + case deviceSessionNotFound + case currentDeviceUnknown + case missingScope(String) + case tooManyAttachments + case invalidAutomaticSettlementDays + + var errorDescription: String? { + switch self { + case .notConnected: "Connect to a T3 environment first." + case .environmentNotFound: "That T3 environment is no longer available." + case .projectNotFound: "The selected project is no longer available." + case .threadNotFound: "The selected thread is no longer available." + case .workspaceNotFound: "The thread workspace is no longer available." + case .approvalNotFound: "The approval request is no longer active." + case .inputRequestNotFound: "The input request is no longer active." + case .invalidProjectPath: "Enter a workspace path on the connected environment." + case .branchRequired: "Choose a base branch for the new worktree." + case .deviceSessionNotFound: "That device session is no longer active." + case .currentDeviceUnknown: "This installation has not registered for device access yet." + case .missingScope: "This connection does not have permission to manage devices." + case .tooManyAttachments: "You can attach up to 8 files per message." + case .invalidAutomaticSettlementDays: "Choose a value from 1 to 90 days." + } + } +} diff --git a/apps/swift-ios/App/NativeWorkspaceMapper.swift b/apps/swift-ios/App/NativeWorkspaceMapper.swift new file mode 100644 index 000000000000..e29eaa1c54ec --- /dev/null +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -0,0 +1,377 @@ +import Foundation + +enum NativeWorkspaceMapper { + static func files( + _ entries: [ProjectEntry], + directory: String? + ) -> [FeatureFileEntry] { + let directory = normalize(directory ?? "") + let prefix = directory.isEmpty ? "" : "\(directory)/" + var children: [String: FeatureFileEntry] = [:] + + for entry in entries { + let fullPath = normalize(entry.path) + guard fullPath.hasPrefix(prefix) else { continue } + let remainder = String(fullPath.dropFirst(prefix.count)) + guard !remainder.isEmpty else { continue } + + let component = remainder.split(separator: "/", maxSplits: 1).first.map(String.init)! + let childPath = prefix + component + let isNested = remainder.contains("/") + let kind: FeatureFileKind = isNested || entry.kind == .directory + ? .directory + : .file + if children[childPath]?.kind == .directory { + continue + } + children[childPath] = FeatureFileEntry( + path: childPath, + name: component, + kind: kind, + isHidden: component.hasPrefix(".") + ) + } + + return Array(children.values).featureFiltered(by: "", includesHidden: true) + } + + static func language(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "swift": "swift" + case "ts", "tsx": "typescript" + case "js", "jsx", "mjs", "cjs": "javascript" + case "json": "json" + case "md", "mdx": "markdown" + case "css", "scss": "css" + case "html", "htm": "html" + case "xml", "svg": "xml" + case "sh", "zsh", "bash": "shell" + case "py": "python" + case "rs": "rust" + case "go": "go" + case "rb": "ruby" + case "sql": "sql" + case "toml": "toml" + case "yml", "yaml": "yaml" + default: nil + } + } + + static func review(_ preview: ReviewDiffPreview) -> FeatureReview { + FeatureReview( + title: "Working tree", + baseReference: preview.sources.compactMap(\.baseRef).first, + files: preview.sources.flatMap(parseDiff), + isTruncated: preview.sources.contains(where: \.truncated) + ) + } + + static func sourceControl(_ status: VCSStatus) -> FeatureSourceControlStatus { + sourceControl( + isRepository: status.isRepo, + branch: status.refName, + files: status.workingTree.files, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pullRequest: status.pr + ) + } + + static func sourceControl( + local: VCSLocalStatus, + remote: VCSRemoteStatus? + ) -> FeatureSourceControlStatus { + sourceControl( + isRepository: local.isRepo, + branch: local.refName, + files: local.workingTree.files, + aheadCount: remote?.aheadCount ?? 0, + behindCount: remote?.behindCount ?? 0, + pullRequest: remote?.pr + ) + } + + private static func sourceControl( + isRepository: Bool, + branch: String?, + files: [VCSWorkingTreeFile], + aheadCount: Int, + behindCount: Int, + pullRequest: VCSChangeRequest? + ) -> FeatureSourceControlStatus { + FeatureSourceControlStatus( + isRepository: isRepository, + branch: branch, + aheadCount: aheadCount, + behindCount: behindCount, + files: files.map { + FeatureSourceControlFile( + path: $0.path, + state: .modified, + isStaged: false + ) + }, + pullRequest: pullRequest.map { + FeaturePullRequest( + number: $0.number, + title: $0.title, + state: $0.state, + url: URL(string: $0.url), + updatedAt: $0.updatedAt + ) + } + ) + } + + static func gitAction(_ action: FeatureSourceControlAction) -> GitStackedAction { + switch action { + case .commit: .commit + case .push: .push + case .createPullRequest: .createPullRequest + case .commitAndPush: .commitAndPush + case .commitPushAndCreatePullRequest: .commitPushAndPullRequest + case .pull: + // Pull has a dedicated VCS endpoint and never reaches this mapping. + .push + } + } + + static func terminal(_ snapshot: TerminalSessionSnapshot) -> FeatureTerminalSnapshot { + FeatureTerminalSnapshot( + threadID: snapshot.threadId, + terminalID: snapshot.terminalId, + state: terminalState(snapshot.status), + title: snapshot.label, + workingDirectory: snapshot.cwd, + buffer: snapshot.history, + exitCode: snapshot.exitCode, + updatedAt: snapshot.updatedAt + ) + } + + static func terminal(_ summary: TerminalSummary) -> FeatureTerminalSnapshot { + FeatureTerminalSnapshot( + threadID: summary.threadId, + terminalID: summary.terminalId, + state: terminalState(summary.status), + title: summary.label, + workingDirectory: summary.cwd, + exitCode: summary.exitCode, + hasRunningSubprocess: summary.hasRunningSubprocess, + updatedAt: summary.updatedAt + ) + } + + private static func terminalState(_ status: TerminalSessionStatus) -> FeatureTerminalState { + switch status { + case .starting: .starting + case .running: .running + case .exited: .exited + case .error: .failed + } + } + + private static func normalize(_ path: String) -> String { + path.replacingOccurrences(of: "\\", with: "/") + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + } + + private static func parseDiff(_ source: ReviewDiffSource) -> [FeatureReviewFile] { + let rawLines = source.diff.split( + separator: "\n", + omittingEmptySubsequences: false + ).map(String.init) + var files: [FeatureReviewFile] = [] + var currentPath: String? + var previousPath: String? + var change = FeatureReviewChangeKind.modified + var lines: [FeatureDiffLine] = [] + var oldLine: Int? + var newLine: Int? + var additions = 0 + var deletions = 0 + + func finishFile() { + guard let currentPath else { return } + files.append( + FeatureReviewFile( + path: currentPath, + previousPath: previousPath, + change: change, + additions: additions, + deletions: deletions, + lines: annotateChangedSpans(lines), + sourceKind: source.kind, + sourceBaseReference: source.baseRef, + sourceHeadReference: source.headRef + ) + ) + } + + for (index, line) in rawLines.enumerated() { + if line.hasPrefix("diff --git ") { + finishFile() + let parts = line.split(separator: " ") + currentPath = parts.count > 3 ? stripDiffPrefix(String(parts[3])) : source.title + previousPath = parts.count > 2 ? stripDiffPrefix(String(parts[2])) : nil + change = .modified + lines = [] + oldLine = nil + newLine = nil + additions = 0 + deletions = 0 + continue + } + if line.hasPrefix("new file mode ") { + change = .added + continue + } + if line.hasPrefix("deleted file mode ") { + change = .deleted + continue + } + if line.hasPrefix("rename from ") { + previousPath = String(line.dropFirst("rename from ".count)) + change = .renamed + continue + } + if line.hasPrefix("rename to ") { + currentPath = String(line.dropFirst("rename to ".count)) + change = .renamed + continue + } + if line.hasPrefix("Binary files ") || line == "GIT binary patch" { + change = .binary + continue + } + if line.hasPrefix("+++ ") { + let path = String(line.dropFirst(4)) + if path != "/dev/null" { currentPath = stripDiffPrefix(path) } + continue + } + if line.hasPrefix("--- ") { + let path = String(line.dropFirst(4)) + if path != "/dev/null" { previousPath = stripDiffPrefix(path) } + continue + } + if line.hasPrefix("@@") { + let ranges = line.split(separator: " ") + oldLine = ranges.count > 1 ? rangeStart(String(ranges[1])) : nil + newLine = ranges.count > 2 ? rangeStart(String(ranges[2])) : nil + lines.append( + FeatureDiffLine( + id: "\(source.id)-\(index)", + kind: .hunk, + text: line + ) + ) + continue + } + + let kind: FeatureDiffLineKind + let rendered: String + let renderedOld: Int? + let renderedNew: Int? + if line.hasPrefix("+") { + kind = .addition + rendered = String(line.dropFirst()) + renderedOld = nil + renderedNew = newLine + newLine = newLine.map { $0 + 1 } + additions += 1 + } else if line.hasPrefix("-") { + kind = .deletion + rendered = String(line.dropFirst()) + renderedOld = oldLine + renderedNew = nil + oldLine = oldLine.map { $0 + 1 } + deletions += 1 + } else if line.hasPrefix(" ") { + kind = .context + rendered = String(line.dropFirst()) + renderedOld = oldLine + renderedNew = newLine + oldLine = oldLine.map { $0 + 1 } + newLine = newLine.map { $0 + 1 } + } else { + continue + } + lines.append( + FeatureDiffLine( + id: "\(source.id)-\(index)", + kind: kind, + oldLine: renderedOld, + newLine: renderedNew, + text: rendered + ) + ) + } + finishFile() + + if files.isEmpty, !source.diff.isEmpty { + return [ + FeatureReviewFile( + path: source.title, + change: .modified, + additions: additions, + deletions: deletions, + lines: annotateChangedSpans(lines), + sourceKind: source.kind, + sourceBaseReference: source.baseRef, + sourceHeadReference: source.headRef + ), + ] + } + return files + } + + /// Git presents replacements as adjacent deletion/addition blocks. Pairing those + /// lines here keeps the view dumb and makes word-level highlighting stable on scroll. + private static func annotateChangedSpans( + _ source: [FeatureDiffLine] + ) -> [FeatureDiffLine] { + var lines = source + var index = 0 + while index < lines.count { + guard lines[index].kind == .deletion || lines[index].kind == .addition else { + index += 1 + continue + } + let start = index + while index < lines.count, + lines[index].kind == .deletion || lines[index].kind == .addition { + index += 1 + } + let changedIndices = start ..< index + let deletions = changedIndices.filter { lines[$0].kind == .deletion } + let additions = changedIndices.filter { lines[$0].kind == .addition } + for (deletionIndex, additionIndex) in zip(deletions, additions) { + let spans = FeatureDiffWordHighlighter.spans( + old: lines[deletionIndex].text, + new: lines[additionIndex].text + ) + lines[deletionIndex].spans = spans.old + lines[additionIndex].spans = spans.new + } + } + return lines + } + + private static func stripDiffPrefix(_ path: String) -> String { + if path.hasPrefix("a/") || path.hasPrefix("b/") { + return String(path.dropFirst(2)) + } + return path + } + + private static func rangeStart(_ range: String) -> Int? { + Int( + range + .drop(while: { $0 == "-" || $0 == "+" }) + .split(separator: ",", maxSplits: 1) + .first + ?? "" + ) + } +} diff --git a/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift b/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift new file mode 100644 index 000000000000..38f1d03034d1 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformAgentAwareness.swift @@ -0,0 +1,457 @@ +import ActivityKit +import Foundation +import WidgetKit + +extension Notification.Name { + static let platformLiveActivityChanged = Notification.Name( + "T3PlatformLiveActivityChanged" + ) +} + +enum PlatformAgentAwarenessProjection { + static let terminalVisibilityWindow: TimeInterval = 15 * 60 + static let maximumRows = 5 + static let minimumPersistenceInterval: TimeInterval = 30 + + static func aggregate( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> T3RelayAgentActivityAggregateState { + // Defensive against duplicate project IDs in aggregate snapshots; this + // runs on every snapshot revision, so it must never trap. + let projects = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + let eligible = snapshot.threads.filter { thread in + guard !thread.isArchived else { return false } + if isActive(thread.state) { return true } + guard thread.state == .completed || thread.state == .failed else { return false } + return now.timeIntervalSince(thread.updatedAt) < terminalVisibilityWindow + } + let rows = eligible.compactMap { thread -> T3RelayAgentActivityAggregateRow? in + guard let project = projects[thread.projectID] else { return nil } + let environmentID = thread.environmentID ?? project.environmentID + let threadID = thread.wireID ?? thread.id + let phase = phase(for: thread.state) + return T3RelayAgentActivityAggregateRow( + environmentId: environmentID, + threadId: threadID, + projectTitle: project.name, + threadTitle: thread.title, + modelTitle: modelTitle( + for: thread, + environmentID: environmentID, + snapshot: snapshot + ), + phase: phase, + status: status(for: thread.state), + updatedAt: thread.updatedAt.ISO8601Format(), + deepLink: PlatformRoute.thread( + environmentID: environmentID, + threadID: threadID + ).url?.absoluteString ?? "/" + ) + } + .sorted { left, right in + let leftPriority = priority(left.phase) + let rightPriority = priority(right.phase) + if leftPriority != rightPriority { return leftPriority < rightPriority } + return left.updatedAt > right.updatedAt + } + let visibleRows = Array(rows.prefix(maximumRows)) + let activeCount = eligible.count { isActive($0.state) } + let attentionCount = eligible.count { + $0.state == .waitingForApproval || $0.state == .waitingForInput + } + let subtitle: String + if attentionCount > 0 { + subtitle = attentionCount == 1 + ? "1 task needs attention" + : "\(attentionCount) tasks need attention" + } else if activeCount > 0 { + subtitle = activeCount == 1 ? "1 active task" : "\(activeCount) active tasks" + } else if visibleRows.contains(where: { $0.phase == .failed }) { + subtitle = "Agent work failed" + } else if !visibleRows.isEmpty { + subtitle = "Agent work completed" + } else { + subtitle = "Ready for a task" + } + return T3RelayAgentActivityAggregateState( + title: "T3 Code", + subtitle: subtitle, + activeCount: activeCount, + updatedAt: now.ISO8601Format(), + activities: visibleRows + ) + } + + static func widgetSnapshot( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> T3TaskWidgetSnapshot { + let aggregate = aggregate(snapshot: snapshot, now: now) + return T3TaskWidgetSnapshot( + updatedAt: aggregate.updatedAt, + tasks: aggregate.activities + ) + } + + static func nextTerminalExpiry( + snapshot: FeatureSnapshot, + now: Date = .now + ) -> Date? { + snapshot.threads.lazy + .filter { + !$0.isArchived && ($0.state == .completed || $0.state == .failed) + } + .map { $0.updatedAt.addingTimeInterval(terminalVisibilityWindow) } + .filter { $0 > now } + .min() + } + + private static func isActive(_ state: FeatureThreadState) -> Bool { + switch state { + case .queued, .working, .monitoring, .waitingForApproval, .waitingForInput: + true + case .idle, .failed, .completed: + false + } + } + + private static func phase(for state: FeatureThreadState) -> T3AgentActivityPhase { + switch state { + case .queued: .starting + case .working: .running + case .monitoring: .running + case .waitingForApproval: .waitingForApproval + case .waitingForInput: .waitingForInput + case .failed: .failed + case .completed: .completed + case .idle: .stale + } + } + + private static func status(for state: FeatureThreadState) -> String { + switch state { + case .queued: "Starting" + case .working: "Working" + case .monitoring: "Monitoring" + case .waitingForApproval: "Approval" + case .waitingForInput: "Input" + case .failed: "Failed" + case .completed: "Done" + case .idle: "Idle" + } + } + + private static func priority(_ phase: T3AgentActivityPhase) -> Int { + switch phase { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } + + private static func modelTitle( + for thread: FeatureThread, + environmentID: String, + snapshot: FeatureSnapshot + ) -> String { + let providers = snapshot.providersByEnvironment?[environmentID] ?? [] + let provider = thread.providerID.flatMap { providerID in + providers.first { $0.id == providerID } + } + if let modelID = thread.modelID, + let model = provider?.models.first(where: { $0.id == modelID }) + { + return model.name + } + return thread.modelID ?? provider?.name ?? thread.providerName ?? "" + } +} + +@MainActor +final class PlatformAgentAwarenessCoordinator { + static let shared = PlatformAgentAwarenessCoordinator() + + private let updateLiveActivity: @MainActor ( + T3RelayAgentActivityAggregateState, + Bool, + Date + ) async throws -> Void + private let endLiveActivities: @MainActor () async -> Void + + private struct Signature: Equatable { + let activeCount: Int + let subtitle: String + let rows: [T3RelayAgentActivityAggregateRow] + let enabled: Bool + } + + private struct Synchronization { + let signature: Signature + let aggregate: T3RelayAgentActivityAggregateState + let enabled: Bool + let now: Date + } + + private var activityUpdateTask: Task? + private var widgetUpdateTask: Task? + private var terminalExpiryTask: Task? + private var lastSignature: Signature? + private var inFlightSignature: Signature? + private var synchronizationGeneration = 0 + private var widgetGeneration = 0 + private var terminalExpiryGeneration = 0 + + init( + updateLiveActivity: @escaping @MainActor ( + T3RelayAgentActivityAggregateState, + Bool, + Date + ) async throws -> Void = { aggregate, enabled, now in + try await PlatformAgentAwarenessCoordinator.synchronizeLiveActivity( + aggregate: aggregate, + enabled: enabled, + now: now + ) + }, + endLiveActivities: @escaping @MainActor () async -> Void = { + await PlatformAgentAwarenessCoordinator.endAllLiveActivities() + } + ) { + self.updateLiveActivity = updateLiveActivity + self.endLiveActivities = endLiveActivities + } + + func synchronize(snapshot: FeatureSnapshot, liveActivitiesEnabled: Bool) { + let now = Date.now + scheduleTerminalExpiry( + snapshot: snapshot, + liveActivitiesEnabled: liveActivitiesEnabled, + now: now + ) + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + let signature = Signature( + activeCount: aggregate.activeCount, + subtitle: aggregate.subtitle, + // `updatedAt` advances throughout a turn without changing visible + // state. Bucket it so long work still refreshes ActivityKit's stale + // date without writing widget state for every shell delta. + rows: aggregate.activities.map { row in + var row = row + let bucket = floor( + now.timeIntervalSince1970 / PlatformAgentAwarenessProjection.minimumPersistenceInterval + ) * PlatformAgentAwarenessProjection.minimumPersistenceInterval + row.updatedAt = Date(timeIntervalSince1970: bucket).ISO8601Format() + return row + }, + enabled: liveActivitiesEnabled + ) + let synchronization = Synchronization( + signature: signature, + aggregate: aggregate, + enabled: liveActivitiesEnabled, + now: now + ) + if signature == lastSignature { + if inFlightSignature != nil { + activityUpdateTask?.cancel() + synchronizationGeneration &+= 1 + inFlightSignature = nil + activityUpdateTask = nil + } + return + } + guard signature != inFlightSignature else { return } + + let widgetSnapshot = T3TaskWidgetSnapshot( + updatedAt: aggregate.updatedAt, + tasks: aggregate.activities + ) + scheduleWidgetUpdate(widgetSnapshot) + + schedule(synchronization) + } + + /// Account sign-out invalidates the cached account-scoped projection before + /// removing its activity. Only a later snapshot may publish new content. + func resetAndResynchronizeLiveActivity() { + activityUpdateTask?.cancel() + terminalExpiryTask?.cancel() + terminalExpiryGeneration &+= 1 + terminalExpiryTask = nil + synchronizationGeneration &+= 1 + let generation = synchronizationGeneration + lastSignature = nil + inFlightSignature = nil + scheduleWidgetUpdate(.empty) + activityUpdateTask = Task { @MainActor [weak self] in + guard let self else { return } + await endLiveActivities() + guard synchronizationGeneration == generation else { return } + activityUpdateTask = nil + } + } + + private func schedule(_ synchronization: Synchronization) { + activityUpdateTask?.cancel() + synchronizationGeneration &+= 1 + let generation = synchronizationGeneration + inFlightSignature = synchronization.signature + activityUpdateTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await updateLiveActivity( + synchronization.aggregate, + synchronization.enabled, + synchronization.now + ) + try Task.checkCancellation() + guard synchronizationGeneration == generation, + inFlightSignature == synchronization.signature else { return } + lastSignature = synchronization.signature + inFlightSignature = nil + activityUpdateTask = nil + } catch { + guard synchronizationGeneration == generation, + inFlightSignature == synchronization.signature else { return } + // Keep the completed signature unchanged so the next identical + // snapshot retries a failed or cancelled ActivityKit operation. + inFlightSignature = nil + activityUpdateTask = nil + } + } + } + + private func scheduleWidgetUpdate(_ snapshot: T3TaskWidgetSnapshot) { + widgetUpdateTask?.cancel() + widgetGeneration &+= 1 + let generation = widgetGeneration + widgetUpdateTask = Task { @MainActor [weak self] in + let saved = await PlatformWidgetSnapshotWriter.shared.save( + snapshot, + generation: generation + ) + guard let self, saved, widgetGeneration == generation else { return } + WidgetCenter.shared.reloadTimelines(ofKind: "T3RecentTasksWidget") + widgetUpdateTask = nil + } + } + + private func scheduleTerminalExpiry( + snapshot: FeatureSnapshot, + liveActivitiesEnabled: Bool, + now: Date + ) { + terminalExpiryTask?.cancel() + terminalExpiryGeneration &+= 1 + let generation = terminalExpiryGeneration + guard let expiry = PlatformAgentAwarenessProjection.nextTerminalExpiry( + snapshot: snapshot, + now: now + ) else { + terminalExpiryTask = nil + return + } + let delay = max(0, expiry.timeIntervalSince(now)) + terminalExpiryTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard let self, + terminalExpiryGeneration == generation else { return } + terminalExpiryTask = nil + synchronize( + snapshot: snapshot, + liveActivitiesEnabled: liveActivitiesEnabled + ) + } + } + + private static func synchronizeLiveActivity( + aggregate: T3RelayAgentActivityAggregateState, + enabled: Bool, + now: Date + ) async throws { + try Task.checkCancellation() + let activities = Activity.activities + + guard enabled, ActivityAuthorizationInfo().areActivitiesEnabled else { + for activity in activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + try Task.checkCancellation() + notifyActivityChanged() + return + } + + let state = try LiveActivityAttributes.ContentState(aggregate: aggregate) + let content = ActivityContent( + state: state, + staleDate: now.addingTimeInterval(10 * 60) + ) + + if aggregate.activeCount == 0 { + for activity in activities { + await activity.end( + content, + dismissalPolicy: .after(now.addingTimeInterval(5 * 60)) + ) + } + try Task.checkCancellation() + notifyActivityChanged() + return + } + + if let primary = activities.first { + await primary.update(content) + for duplicate in activities.dropFirst() { + await duplicate.end(nil, dismissalPolicy: .immediate) + } + } else { + _ = try Activity.request( + attributes: LiveActivityAttributes(), + content: content, + pushType: .token + ) + } + try Task.checkCancellation() + notifyActivityChanged() + } + + private static func endAllLiveActivities() async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + notifyActivityChanged() + } + + private static func notifyActivityChanged() { + NotificationCenter.default.post(name: .platformLiveActivityChanged, object: nil) + } +} + +private actor PlatformWidgetSnapshotWriter { + static let shared = PlatformWidgetSnapshotWriter() + + private var latestGeneration = 0 + + func save(_ snapshot: T3TaskWidgetSnapshot, generation: Int) -> Bool { + guard generation >= latestGeneration else { return false } + latestGeneration = generation + do { + try T3TaskWidgetSnapshotStore.save(snapshot) + return true + } catch { + return false + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift b/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift new file mode 100644 index 000000000000..2debade12977 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformBackgroundRefresh.swift @@ -0,0 +1,93 @@ +import BackgroundTasks +import Foundation + +@MainActor +final class PlatformBackgroundRefreshCoordinator { + typealias RefreshAction = @MainActor @Sendable () async -> Bool + + static let shared = PlatformBackgroundRefreshCoordinator() + static var identifier: String { + "\(Bundle.main.bundleIdentifier ?? "com.t3tools.t3code.swiftui").refresh" + } + + private var refreshAction: RefreshAction? + private var isRegistered = false + + func install(refreshAction: @escaping RefreshAction) { + self.refreshAction = refreshAction + } + + func register() { + guard !isRegistered else { return } + isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.identifier, + using: nil + ) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + Task { @MainActor in + await Self.shared.handle(refreshTask) + } + } + } + + func schedule() { + guard isRegistered else { return } + let request = BGAppRefreshTaskRequest(identifier: Self.identifier) + request.earliestBeginDate = Date().addingTimeInterval( + PlatformBackgroundRefreshPolicy.minimumDelay + ) + try? BGTaskScheduler.shared.submit(request) + } + + private func handle(_ task: BGAppRefreshTask) async { + schedule() + guard let refreshAction else { + task.setTaskCompleted(success: false) + return + } + + // Install cancellation before creating the operation. If expiration + // wins the race, `install` immediately cancels the new task. + let cancellation = PlatformBackgroundRefreshCancellation() + task.expirationHandler = { + cancellation.cancel() + } + let operation = Task { @MainActor in + await refreshAction() + } + cancellation.install(operation) + let succeeded = await operation.value + task.setTaskCompleted(success: succeeded && !operation.isCancelled) + } +} + +private final class PlatformBackgroundRefreshCancellation: @unchecked Sendable { + private let lock = NSLock() + private var operation: Task? + private var didExpire = false + + func install(_ operation: Task) { + let shouldCancel = lock.withLock { + self.operation = operation + return didExpire + } + if shouldCancel { operation.cancel() } + } + + func cancel() { + let operation = lock.withLock { + didExpire = true + return self.operation + } + operation?.cancel() + } +} + +enum PlatformBackgroundRefreshPolicy { + /// iOS chooses the actual cadence. Fifteen minutes is merely the earliest + /// useful retry and avoids repeatedly asking the scheduler for immediate work. + static let minimumDelay: TimeInterval = 15 * 60 +} diff --git a/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift b/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift new file mode 100644 index 000000000000..28272f1ab063 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformCloudDelivery.swift @@ -0,0 +1,396 @@ +import ActivityKit +import CryptoKit +import Foundation +import Security +import UIKit + +enum PlatformInstallationIdentity { + private static let service = "com.t3tools.t3code.swiftui.installation" + private static let account = "device-id" + private static let fallbackKey = "swift-ios.installation-id.v1" + + static func value() -> String { + if let stored = readKeychainValue() { return stored } + let created = UUID().uuidString.lowercased() + let insertion: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(created.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemAdd(insertion as CFDictionary, nil) + if status == errSecSuccess { return created } + if status == errSecDuplicateItem, let winner = readKeychainValue() { return winner } + return value(defaults: .standard) + } + + /// Injectable fallback used only when Keychain is unavailable and by tests. + static func value(defaults: UserDefaults) -> String { + if let existing = defaults.string(forKey: fallbackKey), !existing.isEmpty { + return existing + } + let created = UUID().uuidString.lowercased() + defaults.set(created, forKey: fallbackKey) + return created + } + + private static func readKeychainValue() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + UUID(uuidString: value) != nil else { + return nil + } + return value.lowercased() + } +} + +enum PlatformCloudDeliveryRegistrationFactory { + static func registration( + deviceID: String, + deviceName: String, + systemVersion: OperatingSystemVersion, + appVersion: String?, + bundleID: String?, + pushToken: String?, + pushToStartToken: String?, + settings: FeatureSettings, + apsEnvironment: T3ConnectDeviceRegistration.APNSEnvironment + ) -> T3ConnectDeviceRegistration { + T3ConnectDeviceRegistration( + deviceID: deviceID, + label: deviceName, + iosMajorVersion: systemVersion.majorVersion, + appVersion: appVersion, + bundleID: bundleID, + apsEnvironment: apsEnvironment, + pushToken: pushToken, + pushToStartToken: pushToStartToken, + preferences: T3ConnectDevicePreferences( + liveActivitiesEnabled: settings.liveActivitiesEnabled, + notificationsEnabled: settings.notificationsEnabled + ) + ) + } +} + +/// Keeps the relay's device record aligned with this installation. Token values +/// only travel over DPoP-authenticated relay requests; local success caches store +/// SHA-256 fingerprints rather than reusable push credentials. +@MainActor +final class PlatformCloudDeliveryCoordinator { + static let shared = PlatformCloudDeliveryCoordinator() + + private let defaults: UserDefaults + private let tokenSink: PlatformPersistedDeviceTokenSink + private let deviceID: String + + private weak var controller: T3ConnectController? + private var settings: FeatureSettings? + private var needsRegistration = false + private var registrationTask: Task? + private var observerTasks: [Task] = [] + private var pushToStartTask: Task? + private var activityUpdatesTask: Task? + private var activityTokenTasks: [String: Task] = [:] + private var pendingActivityTokens: Set = [] + private var retryTask: Task? + private var observedAccountID: String? + + private let deviceFingerprintKey = "swift-ios.cloud-delivery-device.v1" + private let deviceRegisteredAtKey = "swift-ios.cloud-delivery-device-date.v1" + private let activityFingerprintKey = "swift-ios.cloud-delivery-activities.v1" + private let healingInterval: TimeInterval = 60 + + init( + defaults: UserDefaults = .standard, + tokenSink: PlatformPersistedDeviceTokenSink? = nil, + deviceID: String? = nil + ) { + self.defaults = defaults + self.tokenSink = tokenSink ?? .shared + self.deviceID = deviceID ?? PlatformInstallationIdentity.value() + } + + deinit { + registrationTask?.cancel() + pushToStartTask?.cancel() + activityUpdatesTask?.cancel() + retryTask?.cancel() + observerTasks.forEach { $0.cancel() } + activityTokenTasks.values.forEach { $0.cancel() } + } + + func install(controller: T3ConnectController) { + self.controller = controller + guard observerTasks.isEmpty else { + handleAccountChange(to: controller.account?.id) + requestRegistration() + return + } + observedAccountID = controller.account?.id + + for name in [Notification.Name.platformDeviceTokenChanged, .platformLiveActivityChanged] { + observerTasks.append(Task { @MainActor [weak self] in + for await _ in NotificationCenter.default.notifications(named: name) { + guard !Task.isCancelled else { return } + self?.refreshActivityTokenObservers() + self?.requestRegistration() + } + }) + } + observerTasks.append(Task { @MainActor [weak self] in + for await notification in NotificationCenter.default.notifications( + named: .t3ConnectSessionChanged + ) { + guard !Task.isCancelled, let self else { return } + guard let controller = self.controller, + let notificationController = notification.object as? T3ConnectController, + notificationController === controller else { continue } + handleAccountChange(to: controller.account?.id) + refreshActivityTokenObservers() + requestRegistration() + } + }) + + if #available(iOS 17.2, *) { + pushToStartTask = Task { @MainActor [weak self] in + for await _ in Activity.pushToStartTokenUpdates { + guard !Task.isCancelled else { return } + self?.requestRegistration() + } + } + } + activityUpdatesTask = Task { @MainActor [weak self] in + for await activity in Activity.activityUpdates { + guard !Task.isCancelled, let self else { return } + if let token = activity.pushToken?.hexadecimalString { + pendingActivityTokens.insert(token) + } + refreshActivityTokenObservers() + requestRegistration() + } + } + refreshActivityTokenObservers() + requestRegistration() + } + + func synchronize(settings: FeatureSettings) { + self.settings = settings + refreshActivityTokenObservers() + requestRegistration() + } + + private func refreshActivityTokenObservers() { + let activities = Activity.activities + let currentIDs = Set(activities.map(\.id)) + for id in Array(activityTokenTasks.keys) where !currentIDs.contains(id) { + activityTokenTasks.removeValue(forKey: id)?.cancel() + } + + for activity in activities { + if let token = activity.pushToken?.hexadecimalString { + pendingActivityTokens.insert(token) + } + guard activityTokenTasks[activity.id] == nil else { continue } + activityTokenTasks[activity.id] = Task { @MainActor [weak self] in + for await token in activity.pushTokenUpdates { + guard !Task.isCancelled else { return } + self?.pendingActivityTokens.insert(token.hexadecimalString) + self?.requestRegistration() + } + } + } + } + + private func requestRegistration() { + retryTask?.cancel() + retryTask = nil + needsRegistration = true + guard registrationTask == nil else { return } + registrationTask = Task { @MainActor [weak self] in + guard let self else { return } + while needsRegistration, !Task.isCancelled { + needsRegistration = false + await registerCurrentState() + await Task.yield() + } + registrationTask = nil + if needsRegistration { requestRegistration() } + } + } + + private func registerCurrentState() async { + guard let controller, let settings else { return } + let registration = currentRegistration(settings: settings) + guard registration.iosMajorVersion >= 18 else { return } + controller.rememberRegisteredDevice(id: deviceID) + + let accountBeforeRequest = controller.account?.id + let fingerprint = Self.fingerprint( + registration, + accountID: accountBeforeRequest + ) + let canReuseRegistration = accountBeforeRequest != nil + && defaults.string(forKey: deviceFingerprintKey) == fingerprint + && Date.now.timeIntervalSince( + defaults.object(forKey: deviceRegisteredAtKey) as? Date ?? .distantPast + ) < healingInterval + + do { + if !canReuseRegistration { + try await controller.registerDevice(registration) + guard accountBeforeRequest == nil + || controller.account?.id == accountBeforeRequest else { + needsRegistration = true + return + } + defaults.set( + Self.fingerprint(registration, accountID: controller.account?.id), + forKey: deviceFingerprintKey + ) + defaults.set(Date.now, forKey: deviceRegisteredAtKey) + } + + guard let accountID = controller.account?.id else { return } + let now = Date.now.timeIntervalSince1970 + var completed = (defaults.dictionary(forKey: activityFingerprintKey) ?? [:]) + .compactMapValues { ($0 as? NSNumber)?.doubleValue } + .filter { now - $0.value < healingInterval } + for token in Array(pendingActivityTokens) { + let tokenFingerprint = Self.fingerprint( + "\(deviceID)|\(token)", + accountID: accountID + ) + guard completed[tokenFingerprint] == nil else { + pendingActivityTokens.remove(token) + continue + } + try await controller.registerLiveActivity( + T3ConnectLiveActivityRegistration( + deviceID: deviceID, + activityPushToken: token + ) + ) + guard controller.account?.id == accountID else { + needsRegistration = true + return + } + completed[tokenFingerprint] = now + pendingActivityTokens.remove(token) + } + let bounded = completed.sorted { $0.value > $1.value }.prefix(8) + defaults.set( + Dictionary(uniqueKeysWithValues: bounded.map { ($0.key, $0.value) }), + forKey: activityFingerprintKey + ) + scheduleRetry(after: healingInterval) + } catch is CancellationError { + return + } catch is T3ConnectAuthError { + return + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + scheduleRetry(after: 15) + return + } + } catch { + // Signed-out and offline states are expected. The next account, + // network, foreground, or token event retries without noisy UI. + scheduleRetry(after: 15) + return + } + } + + private func scheduleRetry(after delay: TimeInterval) { + retryTask?.cancel() + retryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + retryTask = nil + refreshActivityTokenObservers() + requestRegistration() + } + } + + private func clearSuccessfulRegistrationCache() { + defaults.removeObject(forKey: deviceFingerprintKey) + defaults.removeObject(forKey: deviceRegisteredAtKey) + defaults.removeObject(forKey: activityFingerprintKey) + } + + private func handleAccountChange(to accountID: String?) { + guard observedAccountID != accountID else { return } + clearSuccessfulRegistrationCache() + pendingActivityTokens.removeAll() + PlatformAgentAwarenessCoordinator.shared.resetAndResynchronizeLiveActivity() + observedAccountID = accountID + } + + private func currentRegistration(settings: FeatureSettings) -> T3ConnectDeviceRegistration { + let version = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String + let pushToStartToken: String? = if #available(iOS 17.2, *) { + Activity.pushToStartToken?.hexadecimalString + } else { + nil + } + var effectiveSettings = settings + effectiveSettings.notificationsEnabled = settings.notificationsEnabled + && PlatformNotificationService.shared.enabled + && tokenSink.currentToken != nil + effectiveSettings.liveActivitiesEnabled = settings.liveActivitiesEnabled + && ActivityAuthorizationInfo().areActivitiesEnabled + return PlatformCloudDeliveryRegistrationFactory.registration( + deviceID: deviceID, + deviceName: UIDevice.current.name, + systemVersion: ProcessInfo.processInfo.operatingSystemVersion, + appVersion: version, + bundleID: Bundle.main.bundleIdentifier, + pushToken: tokenSink.currentToken, + pushToStartToken: pushToStartToken, + settings: effectiveSettings, + apsEnvironment: Self.apsEnvironment + ) + } + + private static var apsEnvironment: T3ConnectDeviceRegistration.APNSEnvironment { + #if DEBUG + .sandbox + #else + .production + #endif + } + + private static func fingerprint( + _ value: Value, + accountID: String? + ) -> String { + let data = (try? JSONEncoder.t3.encode(value)) ?? Data() + return fingerprint(Data((accountID ?? "unloaded").utf8) + data) + } + + private static func fingerprint(_ value: String, accountID: String?) -> String { + fingerprint(Data("\(accountID ?? "unloaded")|\(value)".utf8)) + } + + private static func fingerprint(_ data: Data) -> String { + Data(SHA256.hash(data: data)).hexadecimalString + } +} + +private extension Data { + var hexadecimalString: String { + map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/swift-ios/App/Platform/PlatformDeepLinks.swift b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift new file mode 100644 index 000000000000..94d96d941649 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformDeepLinks.swift @@ -0,0 +1,308 @@ +import Foundation + +enum PlatformRoute: Codable, Hashable, Identifiable, Sendable { + #if DEBUG + static let nativeScheme = "t3code-swiftui-dev" + #else + static let nativeScheme = "t3code-swiftui" + #endif + + case connection(endpoint: String, token: String?) + case environment(id: String) + case project(environmentID: String?, projectID: String) + case thread(environmentID: String?, threadID: String) + case newTask(environmentID: String?, projectID: String?) + + var id: String { + switch self { + case let .connection(endpoint, token): + "connection:\(endpoint):\(token ?? "")" + case let .environment(id): + "environment:\(id)" + case let .project(environmentID, projectID): + "project:\(environmentID ?? ""):\(projectID)" + case let .thread(environmentID, threadID): + "thread:\(environmentID ?? ""):\(threadID)" + case let .newTask(environmentID, projectID): + "new-task:\(environmentID ?? ""):\(projectID ?? "")" + } + } + + var url: URL? { + var components = URLComponents() + components.scheme = Self.nativeScheme + + switch self { + case let .connection(endpoint, token): + components.host = "connect" + components.queryItems = [URLQueryItem(name: "endpoint", value: endpoint)] + if let token { + components.queryItems?.append(URLQueryItem(name: "token", value: token)) + } + case let .environment(id): + components.host = "environments" + components.queryItems = [URLQueryItem(name: "environment", value: id)] + case let .project(environmentID, projectID): + components.host = "projects" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + URLQueryItem(name: "project", value: projectID), + ].compactMap { $0 } + case let .thread(environmentID, threadID): + components.host = "threads" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + URLQueryItem(name: "thread", value: threadID), + ].compactMap { $0 } + case let .newTask(environmentID, projectID): + components.host = "new-task" + components.queryItems = [ + environmentID.map { URLQueryItem(name: "environment", value: $0) }, + projectID.map { URLQueryItem(name: "project", value: $0) }, + ].compactMap { $0 } + } + return components.url + } +} + +enum PlatformDeepLinkError: LocalizedError, Equatable { + case unsupportedURL + case missingIdentifier + case invalidIdentifier + + var errorDescription: String? { + switch self { + case .unsupportedURL: + "That T3 Code link is not supported." + case .missingIdentifier: + "That T3 Code link is missing its destination." + case .invalidIdentifier: + "That T3 Code link contains an invalid destination." + } + } +} + +enum PlatformDeepLinkParser { + private static let trustedWebHosts: Set = [ + "app.t3.codes", + "t3.codes", + "www.t3.codes", + ] + + static func parse(_ url: URL) throws -> PlatformRoute { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased() + else { + throw PlatformDeepLinkError.unsupportedURL + } + + let query = queryValues(components.queryItems ?? []) + if ["t3", "t3code", "t3code-swiftui", "t3code-swiftui-dev"].contains(scheme) { + let segments = customSchemeSegments(components) + if isConnectionRoute(segments: segments, query: query) { + return try connectionRoute(url) + } + return try navigationRoute(segments: segments, query: query) + } + + guard ["http", "https"].contains(scheme) else { + throw PlatformDeepLinkError.unsupportedURL + } + + guard let host = components.host?.lowercased(), trustedWebHosts.contains(host) else { + throw PlatformDeepLinkError.unsupportedURL + } + + let segments = pathSegments(components.percentEncodedPath) + if isConnectionRoute(segments: segments, query: query) { + return try connectionRoute(url) + } + + if let explicit = try? navigationRoute(segments: segments, query: query) { + return explicit + } + + // Web thread routes use /:environmentID/:threadID. + if segments.count >= 2 { + return .thread( + environmentID: try validatedIdentifier(segments[0]), + threadID: try validatedIdentifier(segments[1]) + ) + } + throw PlatformDeepLinkError.unsupportedURL + } + + static func parse(_ value: String) throws -> PlatformRoute { + guard let url = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw PlatformDeepLinkError.unsupportedURL + } + return try parse(url) + } + + private static func navigationRoute( + segments: [String], + query: [String: String] + ) throws -> PlatformRoute { + let head = segments.first?.lowercased() ?? "" + let tail = Array(segments.dropFirst()) + let queryEnvironment = query["environment"] ?? query["environmentid"] ?? query["env"] + let queryProject = query["project"] ?? query["projectid"] + let queryThread = query["thread"] ?? query["threadid"] + + switch head { + case "thread", "threads": + let values = try routeIdentifiers( + tail: tail, + queryEnvironment: queryEnvironment, + queryDestination: queryThread + ) + return .thread(environmentID: values.environmentID, threadID: values.destinationID) + case "project", "projects": + let values = try routeIdentifiers( + tail: tail, + queryEnvironment: queryEnvironment, + queryDestination: queryProject + ) + return .project(environmentID: values.environmentID, projectID: values.destinationID) + case "environment", "environments", "server", "servers": + guard let rawID = tail.first ?? queryEnvironment else { + throw PlatformDeepLinkError.missingIdentifier + } + return .environment(id: try validatedIdentifier(rawID)) + case "new", "new-task", "compose": + return .newTask( + environmentID: try queryEnvironment.map(validatedIdentifier), + projectID: try queryProject.map(validatedIdentifier) + ) + default: + if let queryThread { + return .thread( + environmentID: try queryEnvironment.map(validatedIdentifier), + threadID: try validatedIdentifier(queryThread) + ) + } + if let queryProject { + return .project( + environmentID: try queryEnvironment.map(validatedIdentifier), + projectID: try validatedIdentifier(queryProject) + ) + } + throw PlatformDeepLinkError.unsupportedURL + } + } + + private static func routeIdentifiers( + tail: [String], + queryEnvironment: String?, + queryDestination: String? + ) throws -> (environmentID: String?, destinationID: String) { + if let queryDestination { + return ( + try queryEnvironment.map(validatedIdentifier), + try validatedIdentifier(queryDestination) + ) + } + if tail.count >= 2 { + return ( + try validatedIdentifier(tail[0]), + try validatedIdentifier(tail[1]) + ) + } + guard let destination = tail.first else { + throw PlatformDeepLinkError.missingIdentifier + } + return (try queryEnvironment.map(validatedIdentifier), try validatedIdentifier(destination)) + } + + private static func connectionRoute(_ url: URL) throws -> PlatformRoute { + do { + let details = try ConnectionDetailsParser.parse(url.absoluteString) + return .connection(endpoint: details.endpoint, token: details.pairingCode) + } catch { + throw PlatformDeepLinkError.unsupportedURL + } + } + + private static func isConnectionRoute( + segments: [String], + query: [String: String] + ) -> Bool { + let head = segments.first?.lowercased() + return ["connect", "pair", "pairing"].contains(head) + || query["pairingurl"] != nil + || query["pairing_url"] != nil + || query["endpoint"] != nil + || query["server"] != nil + || query["host"] != nil + } + + private static func customSchemeSegments(_ components: URLComponents) -> [String] { + var result: [String] = [] + if let host = components.host, !host.isEmpty { + result.append(host) + } + result.append(contentsOf: pathSegments(components.percentEncodedPath)) + return result + } + + private static func pathSegments(_ percentEncodedPath: String) -> [String] { + percentEncodedPath + .split(separator: "/", omittingEmptySubsequences: true) + .map { String($0).removingPercentEncoding ?? String($0) } + } + + private static func queryValues(_ items: [URLQueryItem]) -> [String: String] { + items.reduce(into: [:]) { result, item in + guard let value = item.value, !value.isEmpty else { return } + result[item.name.lowercased()] = value + } + } + + private static func validatedIdentifier(_ rawValue: String) throws -> String { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { throw PlatformDeepLinkError.missingIdentifier } + guard value.utf8.count <= 1_024, + value != ".", + value != "..", + value.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) + else { + throw PlatformDeepLinkError.invalidIdentifier + } + return value + } +} + +/// One-shot storage bridges app intents and notification launches to the live scene. +final class PlatformRouteMailbox: @unchecked Sendable { + static let shared = PlatformRouteMailbox() + + private let defaults: UserDefaults + private let key: String + private let lock = NSLock() + + init(defaults: UserDefaults = .standard, key: String = "swift-ios.pending-platform-route.v1") { + self.defaults = defaults + self.key = key + } + + func put(_ route: PlatformRoute) { + lock.withLock { + defaults.set(try? JSONEncoder().encode(route), forKey: key) + } + } + + func take() -> PlatformRoute? { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return nil } + defaults.removeObject(forKey: key) + return try? JSONDecoder().decode(PlatformRoute.self, from: data) + } + } + + func peek() -> PlatformRoute? { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(PlatformRoute.self, from: data) + } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformFeedback.swift b/apps/swift-ios/App/Platform/PlatformFeedback.swift new file mode 100644 index 000000000000..f50b1be5a77a --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformFeedback.swift @@ -0,0 +1,66 @@ +import UIKit + +enum PlatformFeedbackKind: Equatable, Sendable { + case success + case warning + case error +} + +struct PlatformThreadSignal: Equatable, Sendable { + let kind: PlatformFeedbackKind + let thread: FeatureThread +} + +enum PlatformThreadTransitionClassifier { + /// Previous states are kept as a bare `[id: state]` map so each home + /// revision retains a dictionary of enums, not a copy of every thread. + static func signals( + previous: [String: FeatureThreadState]?, + current: [FeatureThread] + ) -> [PlatformThreadSignal] { + guard let previous else { return [] } + + return current.compactMap { thread in + guard let oldState = previous[thread.id], oldState != thread.state else { return nil } + let kind: PlatformFeedbackKind? = switch thread.state { + case .waitingForApproval, .waitingForInput: + .warning + case .failed: + .error + case .completed where oldState == .working + || oldState == .queued + || oldState == .monitoring: + .success + default: + nil + } + return kind.map { PlatformThreadSignal(kind: $0, thread: thread) } + } + } +} + +@MainActor +final class PlatformHapticEngine { + static let shared = PlatformHapticEngine() + + func emit(_ kind: PlatformFeedbackKind, enabled: Bool) { + guard enabled else { return } + let generator = UINotificationFeedbackGenerator() + generator.prepare() + switch kind { + case .success: + generator.notificationOccurred(.success) + case .warning: + generator.notificationOccurred(.warning) + case .error: + generator.notificationOccurred(.error) + } + } + + func selection(enabled: Bool) { + guard enabled else { return } + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + } +} diff --git a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift new file mode 100644 index 000000000000..49da8570d236 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift @@ -0,0 +1,437 @@ +import Foundation +import Observation +import SwiftUI + +enum PlatformIncomingShareError: LocalizedError, Equatable { + case missingImage(String) + case invalidImage(String) + case missingFile(String) + case invalidFile(String) + case invalidEnvelope + + var errorDescription: String? { + switch self { + case let .missingImage(name): + "The shared image \(name) is no longer available. Share it again to retry." + case let .invalidImage(name): + "The shared image \(name) is incomplete or too large. Share it again to retry." + case let .missingFile(name): + "The shared file \(name) is no longer available. Share it again to retry." + case let .invalidFile(name): + "The shared file \(name) is incomplete or too large. Share it again to retry." + case .invalidEnvelope: + "This shared item is invalid. Share it again to retry." + } + } +} + +struct PlatformIncomingShareSource: Sendable { + var loadAll: @Sendable () async -> [T3IncomingShareEnvelope] + var data: @Sendable (T3IncomingShareImage) async throws -> Data + var fileURL: @Sendable (T3IncomingShareFile) async throws -> URL + var remove: @Sendable (String) async throws -> Void + + init( + loadAll: @escaping @Sendable () async -> [T3IncomingShareEnvelope], + data: @escaping @Sendable (T3IncomingShareImage) async throws -> Data, + remove: @escaping @Sendable (String) async throws -> Void, + fileURL: @escaping @Sendable (T3IncomingShareFile) async throws -> URL = { file in + guard let url = T3IncomingShareStore.fileURL(for: file) else { + throw PlatformIncomingShareError.missingFile(file.fileName) + } + return url + } + ) { + self.loadAll = loadAll + self.data = data + self.fileURL = fileURL + self.remove = remove + } + + static let live = PlatformIncomingShareSource( + loadAll: { + await Task.detached(priority: .utility) { + T3IncomingShareStore.loadAll() + }.value + }, + data: { image in + guard let root = T3SharedContainer.rootURL?.standardizedFileURL, + let url = T3IncomingShareStore.fileURL(for: image)?.standardizedFileURL, + url.path.hasPrefix(root.path + "/") else { + throw PlatformIncomingShareError.missingImage(image.fileName) + } + let data = try await Task.detached(priority: .userInitiated) { + guard FileManager.default.fileExists(atPath: url.path) else { + throw PlatformIncomingShareError.missingImage(image.fileName) + } + return try Data(contentsOf: url, options: .mappedIfSafe) + }.value + guard !data.isEmpty, + data.count <= T3IncomingShareStore.maximumImageBytes, + data.count == image.byteCount else { + throw PlatformIncomingShareError.invalidImage(image.fileName) + } + return data + }, + remove: { id in + guard UUID(uuidString: id) != nil else { + throw PlatformIncomingShareError.invalidEnvelope + } + try await Task.detached(priority: .utility) { + try T3IncomingShareStore.remove(id: id) + }.value + }, + fileURL: { file in + guard let root = T3SharedContainer.rootURL?.standardizedFileURL, + let url = T3IncomingShareStore.fileURL(for: file)?.standardizedFileURL, + url.path.hasPrefix(root.path + "/") else { + throw PlatformIncomingShareError.missingFile(file.fileName) + } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, + let byteCount = values.fileSize, + byteCount > 0, + byteCount <= T3IncomingShareStore.maximumFileBytes, + byteCount == file.byteCount else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + return url + } + ) +} + +struct PlatformIncomingShareDraftRepository: Sendable { + var importContent: @Sendable ( + _ shareID: String, + _ text: String, + _ attachments: [FeatureDraftAttachment], + _ key: String, + _ maximumAttachmentCount: Int + ) async throws -> FeatureComposerDraft + + static let live = PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumAttachmentCount in + try await FeatureComposerDraftStore.shared.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumAttachmentCount + ) + } + ) +} + +/// Moves one extension envelope into the durable new-task draft. The saved +/// attachment identifiers make the operation idempotent if inbox cleanup fails +/// after the atomic draft write. +struct PlatformIncomingSharePipeline: Sendable { + static let maximumAttachmentCount = 8 + + private let source: PlatformIncomingShareSource + private let drafts: PlatformIncomingShareDraftRepository + private let prepareImage: @Sendable (Data, Int) async throws -> FeatureDraftAttachment + private let attachmentFileStore: ManagedAttachmentFileStore + + init( + source: PlatformIncomingShareSource = .live, + drafts: PlatformIncomingShareDraftRepository = .live, + prepareImage: @escaping @Sendable (Data, Int) async throws -> FeatureDraftAttachment = { + data, + ordinal in + try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + }, + attachmentFileStore: ManagedAttachmentFileStore = ManagedAttachmentFileStore() + ) { + self.source = source + self.drafts = drafts + self.prepareImage = prepareImage + self.attachmentFileStore = attachmentFileStore + } + + func pendingEnvelopes() async -> [T3IncomingShareEnvelope] { + await source.loadAll() + } + + func importEnvelope( + _ envelope: T3IncomingShareEnvelope, + into project: FeatureProject, + draftKey: String? = nil + ) async throws -> FeatureComposerDraft { + guard UUID(uuidString: envelope.id) != nil else { + throw PlatformIncomingShareError.invalidEnvelope + } + guard envelope.images.count + envelope.files.count <= Self.maximumAttachmentCount else { + throw PlatformIncomingShareError.invalidEnvelope + } + let key = draftKey ?? FeatureComposerDraftStore.newTaskKey(project: project) + var prepared: [FeatureDraftAttachment] = [] + prepared.reserveCapacity(envelope.images.count + envelope.files.count) + for (offset, image) in envelope.images.enumerated() { + let data = try await source.data(image) + let attachment = try await prepareImage( + data, + offset + 1 + ) + prepared.append(Self.stableAttachment(attachment, for: image)) + } + for file in envelope.files { + guard let attachmentID = UUID(uuidString: file.id) else { + throw PlatformIncomingShareError.invalidEnvelope + } + let sourceURL = try await source.fileURL(file) + let ownedFile: FeatureOwnedAttachmentFile + do { + ownedFile = try attachmentFileStore.copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: file.fileName, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + } catch ManagedAttachmentFileError.alreadyExists { + ownedFile = try Self.existingOwnedFile( + in: attachmentFileStore, + sourceURL: sourceURL, + attachmentID: attachmentID, + file: file + ) + } + guard ownedFile.byteCount == file.byteCount else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + prepared.append(FeatureDraftAttachment( + id: attachmentID, + ownedFile: ownedFile, + thumbnailData: nil, + filename: file.fileName, + mimeType: file.mimeType, + uploadedReference: nil + )) + } + + let merged = try await drafts.importContent( + envelope.id, + envelope.text, + prepared, + key, + Self.maximumAttachmentCount + ) + + // The repository's actor operation atomically merges the latest draft + // and records the share ID. Never acknowledge the inbox before it ends. + try await source.remove(envelope.id) + return merged + } + + private static func existingOwnedFile( + in store: ManagedAttachmentFileStore, + sourceURL: URL, + attachmentID: UUID, + file: T3IncomingShareFile + ) throws -> FeatureOwnedAttachmentFile { + let pathExtension = URL(fileURLWithPath: file.fileName).pathExtension + let ownedName = pathExtension.isEmpty + ? attachmentID.uuidString + : "\(attachmentID.uuidString).\(pathExtension.lowercased())" + let existing = try store.resolvedFile(fileName: ownedName, byteCount: file.byteCount) + let values = try existing.url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, + values.fileSize == file.byteCount, + try filesMatch(sourceURL, existing.url) else { + throw PlatformIncomingShareError.invalidFile(file.fileName) + } + return existing + } + + private static func filesMatch(_ lhsURL: URL, _ rhsURL: URL) throws -> Bool { + let lhs = try FileHandle(forReadingFrom: lhsURL) + let rhs = try FileHandle(forReadingFrom: rhsURL) + defer { try? lhs.close(); try? rhs.close() } + while true { + let left = try lhs.read(upToCount: 256 * 1_024) ?? Data() + let right = try rhs.read(upToCount: 256 * 1_024) ?? Data() + guard left == right else { return false } + if left.isEmpty { return true } + } + } + + private static func stableAttachment( + _ attachment: FeatureDraftAttachment, + for image: T3IncomingShareImage + ) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: UUID(uuidString: image.id) ?? attachment.id, + data: attachment.data, + thumbnailData: attachment.thumbnailData, + filename: attachment.filename, + mimeType: attachment.mimeType + ) + } +} + +@MainActor +@Observable +final class PlatformIncomingShareCoordinator { + private(set) var pendingEnvelope: T3IncomingShareEnvelope? + private(set) var isImporting = false + + private let pipeline: PlatformIncomingSharePipeline + private var isRefreshing = false + private var lastNoProjectNoticeID: String? + + init(pipeline: PlatformIncomingSharePipeline = PlatformIncomingSharePipeline()) { + self.pipeline = pipeline + } + + /// Returns true once per pending envelope when the app cannot offer a + /// destination. The envelope remains in the shared container. + func refresh(hasProjects: Bool) async -> Bool { + guard pendingEnvelope == nil, !isRefreshing, !isImporting else { + return pendingEnvelope != nil + && !hasProjects + && markNoProjectNoticeIfNeeded() + } + isRefreshing = true + let envelopes = await pipeline.pendingEnvelopes() + isRefreshing = false + pendingEnvelope = envelopes.first + guard pendingEnvelope != nil, !hasProjects else { return false } + return markNoProjectNoticeIfNeeded() + } + + func dismissDestination() { + guard !isImporting else { return } + pendingEnvelope = nil + } + + func importPending(into project: FeatureProject, draftKey: String? = nil) async throws { + guard let pendingEnvelope, !isImporting else { return } + isImporting = true + do { + _ = try await pipeline.importEnvelope( + pendingEnvelope, + into: project, + draftKey: draftKey + ) + self.pendingEnvelope = nil + lastNoProjectNoticeID = nil + isImporting = false + } catch { + isImporting = false + throw error + } + } + + private func markNoProjectNoticeIfNeeded() -> Bool { + guard let id = pendingEnvelope?.id, + lastNoProjectNoticeID != id else { + return false + } + lastNoProjectNoticeID = id + return true + } +} + +struct PlatformIncomingShareDestinationSheet: View { + let envelope: T3IncomingShareEnvelope + let projects: [FeatureProject] + let environments: [FeatureEnvironment] + let isImporting: Bool + let onCancel: () -> Void + let onSelect: (FeatureProject) -> Void + + var body: some View { + NavigationStack { + List { + if !summary.isEmpty { + Section { + Text(summary) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .listRowBackground(Color(uiColor: .systemBackground)) + } + + Section("Choose a project") { + ForEach(projects) { project in + Button { + onSelect(project) + } label: { + HStack(spacing: 12) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 3) { + Text(project.name) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + if let environmentName = environmentName(for: project) { + Text(environmentName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + if isImporting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .frame(minHeight: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isImporting) + .listRowBackground(Color(uiColor: .systemBackground)) + } + } + + if !envelope.warnings.isEmpty { + Section { + ForEach(envelope.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(uiColor: .systemBackground)) + } + } + .scrollContentBackground(.hidden) + .background(Color(uiColor: .systemBackground)) + .navigationTitle("Start a task") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: onCancel) + .disabled(isImporting) + } + } + } + .background(Color(uiColor: .systemBackground).ignoresSafeArea()) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(isImporting) + } + + private var summary: String { + let attachmentCount = envelope.images.count + envelope.files.count + if !envelope.text.isEmpty, attachmentCount > 0 { + return "\(envelope.text)\n\(attachmentCount) file\(attachmentCount == 1 ? "" : "s")" + } + if !envelope.text.isEmpty { return envelope.text } + guard attachmentCount > 0 else { return "" } + return "\(attachmentCount) shared file\(attachmentCount == 1 ? "" : "s")" + } + + private func environmentName(for project: FeatureProject) -> String? { + guard environments.count > 1 else { return nil } + return environments.first { $0.id == project.environmentID }?.name + } +} diff --git a/apps/swift-ios/App/Platform/PlatformNotifications.swift b/apps/swift-ios/App/Platform/PlatformNotifications.swift new file mode 100644 index 000000000000..576b7ebf10d7 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformNotifications.swift @@ -0,0 +1,277 @@ +@preconcurrency import UserNotifications +import UIKit + +extension Notification.Name { + static let platformRouteReceived = Notification.Name("T3PlatformRouteReceived") + static let platformDeviceTokenChanged = Notification.Name("T3PlatformDeviceTokenChanged") +} + +enum PlatformNotificationPayload { + private static let routeKeys = ["t3_route", "route", "url", "deep_link", "deeplink"] + + static func route(from userInfo: [AnyHashable: Any]) -> PlatformRoute? { + for key in routeKeys { + if let value = value(named: key, in: userInfo), + let route = try? PlatformDeepLinkParser.parse(value) { + return route + } + } + + let environmentID = value(named: "environment_id", in: userInfo) + ?? value(named: "environmentId", in: userInfo) + if let threadID = value(named: "thread_id", in: userInfo) + ?? value(named: "threadId", in: userInfo) { + return .thread(environmentID: environmentID, threadID: threadID) + } + if let projectID = value(named: "project_id", in: userInfo) + ?? value(named: "projectId", in: userInfo) { + return .project(environmentID: environmentID, projectID: projectID) + } + return nil + } + + private static func value(named name: String, in userInfo: [AnyHashable: Any]) -> String? { + userInfo.first { key, _ in + String(describing: key).caseInsensitiveCompare(name) == .orderedSame + }.flatMap { _, value in + let string = value as? String + return string?.isEmpty == false ? string : nil + } + } +} + +@MainActor +protocol PlatformDeviceTokenSink: AnyObject { + func registered(token: String) + func registrationFailed(_ error: Error) + func invalidated() +} + +/// Persists the APNs identity and publishes a seam for server registration. +/// The environment client can subscribe without coupling UIApplicationDelegate to transport code. +@MainActor +final class PlatformPersistedDeviceTokenSink: PlatformDeviceTokenSink { + static let shared = PlatformPersistedDeviceTokenSink() + + private let defaults: UserDefaults + private let key = "swift-ios.apns-device-token.v1" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var currentToken: String? { + defaults.string(forKey: key) + } + + func registered(token: String) { + defaults.set(token, forKey: key) + NotificationCenter.default.post( + name: .platformDeviceTokenChanged, + object: nil, + userInfo: ["token": token] + ) + } + + func registrationFailed(_ error: Error) { + NotificationCenter.default.post( + name: .platformDeviceTokenChanged, + object: nil, + userInfo: ["error": error.localizedDescription] + ) + } + + func invalidated() { + defaults.removeObject(forKey: key) + NotificationCenter.default.post(name: .platformDeviceTokenChanged, object: nil) + } +} + +@MainActor +final class PlatformNotificationService: NSObject, UNUserNotificationCenterDelegate { + static let shared = PlatformNotificationService() + + private let center: UNUserNotificationCenter + private let tokenSink: any PlatformDeviceTokenSink + private(set) var enabled = false + + init( + center: UNUserNotificationCenter = .current(), + tokenSink: (any PlatformDeviceTokenSink)? = nil + ) { + self.center = center + self.tokenSink = tokenSink ?? PlatformPersistedDeviceTokenSink.shared + super.init() + } + + func installDelegate() { + center.delegate = self + } + + @discardableResult + func synchronize(enabled: Bool) async -> Bool { + installDelegate() + + guard enabled else { + self.enabled = false + UIApplication.shared.unregisterForRemoteNotifications() + center.removeAllPendingNotificationRequests() + center.removeAllDeliveredNotifications() + tokenSink.invalidated() + return false + } + + let settings = await center.notificationSettings() + let authorized = isAuthorized(settings.authorizationStatus) + self.enabled = authorized + if authorized { + UIApplication.shared.registerForRemoteNotifications() + } + return authorized + } + + /// Call only in response to an explicit user action such as saving the + /// Notifications toggle. Startup synchronization never presents a prompt. + @discardableResult + func requestAuthorization() async -> Bool { + installDelegate() + let settings = await center.notificationSettings() + let authorized: Bool + switch settings.authorizationStatus { + case .notDetermined: + authorized = (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) == true + case .authorized, .provisional, .ephemeral: + authorized = true + case .denied: + authorized = false + @unknown default: + authorized = false + } + + enabled = authorized + if authorized { + UIApplication.shared.registerForRemoteNotifications() + } + return authorized + } + + func schedule(_ signal: PlatformThreadSignal) async { + guard enabled else { return } + let settings = await center.notificationSettings() + guard [.authorized, .provisional, .ephemeral].contains(settings.authorizationStatus) else { + return + } + + let content = UNMutableNotificationContent() + content.title = notificationTitle(for: signal.kind) + content.body = signal.thread.title + content.sound = .default + content.threadIdentifier = signal.thread.id + if let url = PlatformRoute.thread( + environmentID: signal.thread.environmentID, + threadID: signal.thread.wireID ?? signal.thread.id + ).url { + content.userInfo = ["t3_route": url.absoluteString] + } + + let request = UNNotificationRequest( + identifier: "thread:\(signal.thread.id):\(signal.thread.state.rawValue)", + content: content, + trigger: nil + ) + try? await center.add(request) + } + + func didRegisterForRemoteNotifications(deviceToken: Data) { + tokenSink.registered(token: deviceToken.map { String(format: "%02x", $0) }.joined()) + } + + func didFailToRegisterForRemoteNotifications(_ error: Error) { + tokenSink.registrationFailed(error) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping @Sendable () -> Void + ) { + let route = PlatformNotificationPayload.route( + from: response.notification.request.content.userInfo + ) + DispatchQueue.main.async { + defer { completionHandler() } + guard let route else { return } + PlatformRouteMailbox.shared.put(route) + NotificationCenter.default.post( + name: .platformRouteReceived, + object: nil, + userInfo: ["route": route] + ) + } + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + await MainActor.run { enabled ? [.banner, .sound] : [] } + } + + private func notificationTitle(for kind: PlatformFeedbackKind) -> String { + switch kind { + case .success: + "Task completed" + case .warning: + "T3 Code needs you" + case .error: + "Task failed" + } + } + + private func isAuthorized(_ status: UNAuthorizationStatus) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + true + case .notDetermined, .denied: + false + @unknown default: + false + } + } +} + +@MainActor +final class T3PlatformAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + PlatformBackgroundRefreshCoordinator.shared.register() + PlatformNotificationService.shared.installDelegate() + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + PlatformNotificationService.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + PlatformNotificationService.shared.didFailToRegisterForRemoteNotifications(error) + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + completionHandler( + PlatformNotificationPayload.route(from: userInfo) == nil ? .noData : .newData + ) + } +} diff --git a/apps/swift-ios/App/Platform/PlatformRootView.swift b/apps/swift-ios/App/Platform/PlatformRootView.swift new file mode 100644 index 000000000000..b4fe3ab1223f --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -0,0 +1,388 @@ +import SwiftUI + +struct PlatformRootView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable private var model: FeatureRootModel + + @State private var navigationRequest: FeatureWorkspaceNavigationRequest? + @State private var pendingRoute: PlatformRoute? + @State private var previousThreadStates: [String: FeatureThreadState]? + @State private var lastNotificationPreference: Bool? + @State private var incomingShareCoordinator = PlatformIncomingShareCoordinator() + @State private var incomingShareNeedsProject = false + @State private var importedShareProjectID: String? + @State private var recentThreadsPersistenceTask: Task? + + init(model: FeatureRootModel) { + self.model = model + } + + var body: some View { + FeatureRootView( + model: model, + navigationRequest: navigationRequest, + onNavigationRequestConsumed: { requestID in + guard navigationRequest?.id == requestID else { return } + navigationRequest = nil + } + ) + .environment(\.openURL, OpenURLAction { url in + // Links tapped inside the app (message Markdown above all) would + // otherwise leave for Safari or be rejected by an unregistered + // scheme, so keep the ones this device can already show. + guard let route = PlatformInAppLinkRouter.route(for: url, in: model.snapshot) else { + return .systemAction + } + handle(route) + return .handled + }) + .onOpenURL { url in + handle(url: url, letOnboardingConfirmConnection: true) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + guard let url = activity.webpageURL else { return } + handle(url: url, letOnboardingConfirmConnection: false) + } + .onReceive(NotificationCenter.default.publisher(for: .platformRouteReceived)) { note in + guard let route = note.userInfo?["route"] as? PlatformRoute else { return } + _ = PlatformRouteMailbox.shared.take() + handle(route) + } + .onReceive(NotificationCenter.default.publisher(for: .t3ConnectSessionChanged)) { note in + guard let capability = model.client as? any T3ConnectCapable, + let controller = note.object as? T3ConnectController, + controller === capability.t3ConnectController else { return } + let previousAccountID = note.userInfo?["previousAccountID"] as? String + let accountID = note.userInfo?["accountID"] as? String + guard Self.shouldRemoveManagedEnvironments( + previousAccountID: previousAccountID, + accountID: accountID, + isSigningOut: model.isSigningOutT3Connect + ) else { return } + Task { @MainActor in + guard !model.isSigningOutT3Connect else { return } + await model.removeManagedEnvironmentsAfterAccountChange() + } + } + .onChange(of: model.isLoading, initial: true) { _, isLoading in + guard !isLoading else { return } + processThreadChanges() + synchronizeNotificationPreference() + synchronizeCloudDelivery() + consumePendingRouteIfPossible() + consumeMailboxRouteIfAvailable() + refreshIncomingShares() + } + .onChange(of: model.homePresentationRevision) { _, _ in + processThreadChanges() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + consumeMailboxRouteIfAvailable() + synchronizeNotificationPreference() + synchronizeCloudDelivery() + refreshIncomingShares() + } else if phase == .background { + PlatformBackgroundRefreshCoordinator.shared.schedule() + } + } + .onChange(of: model.snapshot.settings.notificationsEnabled) { _, _ in + synchronizeNotificationPreference() + synchronizeCloudDelivery() + } + .onChange(of: model.snapshot.settings.liveActivitiesEnabled) { _, _ in + synchronizeAgentAwareness() + synchronizeCloudDelivery() + } + .onChange(of: model.snapshot.projects.map(\.id)) { _, _ in + refreshIncomingShares() + } + .sheet(item: presentedIncomingShare, onDismiss: openImportedShareDraft) { envelope in + PlatformIncomingShareDestinationSheet( + envelope: envelope, + projects: incomingShareProjects, + environments: model.snapshot.environments, + isImporting: incomingShareCoordinator.isImporting, + onCancel: incomingShareCoordinator.dismissDestination, + onSelect: importIncomingShare(into:) + ) + } + .alert("Create a project to continue", isPresented: $incomingShareNeedsProject) { + Button("Not now", role: .cancel) {} + Button("Create project") { + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: nil) + ) + } + } message: { + Text("Your share is saved. Connect an environment and create a project to finish importing it.") + } + } + + static func shouldRemoveManagedEnvironments( + previousAccountID: String?, + accountID: String?, + isSigningOut: Bool + ) -> Bool { + guard !isSigningOut, let previousAccountID else { return false } + return previousAccountID != accountID + } + + private var incomingShareProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot).sorted { + if $0.name.localizedStandardCompare($1.name) == .orderedSame { + return $0.environmentID < $1.environmentID + } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + + private var presentedIncomingShare: Binding { + Binding( + get: { + guard !incomingShareProjects.isEmpty else { return nil } + return incomingShareCoordinator.pendingEnvelope + }, + set: { value in + guard value == nil, importedShareProjectID == nil else { return } + incomingShareCoordinator.dismissDestination() + } + ) + } + + private var shouldShowWorkspace: Bool { + FeatureRootPresentation.showsWorkspace( + snapshot: model.snapshot, + isManagingConnections: model.isManagingConnections + ) + } + + private func handle(url: URL, letOnboardingConfirmConnection: Bool) { + do { + let route = try PlatformDeepLinkParser.parse(url) + if case .connection = route, + letOnboardingConfirmConnection, + !shouldShowWorkspace { + // ConnectionOnboardingView owns the confirmation UI for cold pairing links. + return + } + handle(route) + } catch { + model.errorMessage = error.localizedDescription + } + } + + private func handle(_ route: PlatformRoute) { + guard !model.isLoading else { + pendingRoute = route + return + } + Task { @MainActor in + await consume(route) + } + } + + private func consumePendingRouteIfPossible() { + guard let route = pendingRoute else { return } + pendingRoute = nil + handle(route) + } + + private func consumeMailboxRouteIfAvailable() { + guard !model.isLoading, let route = PlatformRouteMailbox.shared.take() else { return } + handle(route) + } + + private func synchronizeNotificationPreference() { + guard !model.isLoading else { return } + let preference = model.snapshot.settings.notificationsEnabled + let previous = lastNotificationPreference + lastNotificationPreference = preference + + Task { + let authorized: Bool + if preference, previous == false { + // The model changes only after Settings is explicitly saved. + authorized = await PlatformNotificationService.shared.requestAuthorization() + } else { + authorized = await PlatformNotificationService.shared.synchronize(enabled: preference) + } + guard preference, !authorized, model.snapshot.settings.notificationsEnabled else { + return + } + + // Keep the app toggle honest when authorization is absent or revoked. + var settings = model.snapshot.settings + settings.notificationsEnabled = false + await model.saveSettings(settings) + } + } + + private func synchronizeCloudDelivery() { + guard !model.isLoading else { return } + PlatformCloudDeliveryCoordinator.shared.synchronize( + settings: model.snapshot.settings + ) + } + + private func refreshIncomingShares() { + guard !model.isLoading else { return } + let hasProjects = !incomingShareProjects.isEmpty + Task { @MainActor in + if await incomingShareCoordinator.refresh(hasProjects: hasProjects) { + incomingShareNeedsProject = true + } + } + } + + private func importIncomingShare(into project: FeatureProject) { + guard !incomingShareCoordinator.isImporting else { return } + importedShareProjectID = project.id + Task { @MainActor in + do { + try await incomingShareCoordinator.importPending( + into: project, + draftKey: FeatureComposerDraftStore.newTaskKey( + project: project, + in: model.snapshot + ) + ) + } catch { + importedShareProjectID = nil + model.errorMessage = error.localizedDescription + } + } + } + + private func openImportedShareDraft() { + guard let projectID = importedShareProjectID else { return } + importedShareProjectID = nil + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: projectID) + ) + PlatformHapticEngine.shared.emit( + .success, + enabled: model.snapshot.settings.hapticsEnabled + ) + } + + @MainActor + private func consume(_ route: PlatformRoute) async { + switch route { + case let .connection(endpoint, token): + if await model.pair(endpoint: endpoint, token: token) { + PlatformHapticEngine.shared.emit( + .success, + enabled: model.snapshot.settings.hapticsEnabled + ) + } + case let .environment(id): + guard await enableEnvironmentIfNeeded(id) else { return } + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .thread(environmentID, threadID): + guard await enableEnvironmentIfNeeded(environmentID), + let thread = PlatformRouteResolver.thread( + in: model.snapshot, + environmentID: environmentID, + id: threadID + ) + else { + if model.errorMessage == nil { model.errorMessage = "That thread is not available on this device." } + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .thread(id: thread.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .project(environmentID, projectID): + guard await enableEnvironmentIfNeeded(environmentID), + let project = PlatformRouteResolver.project( + in: model.snapshot, + environmentID: environmentID, + id: projectID + ) + else { + if model.errorMessage == nil { model.errorMessage = "That project is not available on this device." } + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .project(id: project.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + case let .newTask(environmentID, projectID): + guard await enableEnvironmentIfNeeded(environmentID) else { return } + let resolvedProject = projectID.flatMap { + PlatformRouteResolver.project( + in: model.snapshot, + environmentID: environmentID, + id: $0 + ) + } + if projectID != nil, resolvedProject == nil { + model.errorMessage = "That project is not available on this device." + return + } + navigationRequest = FeatureWorkspaceNavigationRequest( + destination: .newTask(projectID: resolvedProject?.id) + ) + PlatformHapticEngine.shared.selection( + enabled: model.snapshot.settings.hapticsEnabled + ) + } + } + + @MainActor + private func enableEnvironmentIfNeeded(_ id: String?) async -> Bool { + guard let id else { return true } + guard let environment = model.snapshot.environments.first(where: { $0.id == id }) else { + model.errorMessage = "That environment is not saved on this device." + return false + } + guard !environment.isEnabled else { return true } + return await model.setEnvironmentEnabled(id, enabled: true) + } + + /// Home revisions are coalesced by FeatureRootModel, so this performs one + /// bounded scan per meaningful snapshot change rather than on every render. + private func processThreadChanges() { + let current = model.snapshot.threads.reduce(into: [String: FeatureThreadState]()) { + $0[$1.id] = $1.state + } + let signals = PlatformThreadTransitionClassifier.signals( + previous: previousThreadStates, + current: model.snapshot.threads + ) + previousThreadStates = current + recentThreadsPersistenceTask?.cancel() + let threads = model.snapshot.threads + recentThreadsPersistenceTask = Task.detached(priority: .utility) { + guard !Task.isCancelled else { return } + PlatformRecentThreadStore.shared.update(from: threads) + } + synchronizeAgentAwareness() + + for signal in signals { + if scenePhase == .active { + PlatformHapticEngine.shared.emit( + signal.kind, + enabled: model.snapshot.settings.hapticsEnabled + ) + } else if model.snapshot.settings.notificationsEnabled { + Task { await PlatformNotificationService.shared.schedule(signal) } + } + } + } + + private func synchronizeAgentAwareness() { + PlatformAgentAwarenessCoordinator.shared.synchronize( + snapshot: model.snapshot, + liveActivitiesEnabled: model.snapshot.settings.liveActivitiesEnabled + ) + } +} diff --git a/apps/swift-ios/App/Platform/PlatformRouteResolver.swift b/apps/swift-ios/App/Platform/PlatformRouteResolver.swift new file mode 100644 index 000000000000..4b93b99512e9 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformRouteResolver.swift @@ -0,0 +1,88 @@ +import Foundation + +enum PlatformRouteResolver { + static func thread( + in snapshot: FeatureSnapshot, + environmentID: String?, + id: String + ) -> FeatureThread? { + let matches = snapshot.threads.filter { thread in + (environmentID == nil || thread.environmentID == environmentID) + && (thread.id == id || thread.wireID == id) + } + guard environmentID != nil || matches.count == 1 else { return nil } + return matches.max { $0.updatedAt < $1.updatedAt } + } + + static func project( + in snapshot: FeatureSnapshot, + environmentID: String?, + id: String + ) -> FeatureProject? { + let matches = snapshot.projects.filter { project in + (environmentID == nil || project.environmentID == environmentID) + && (project.id == id || project.wireID == id) + } + guard environmentID != nil || matches.count == 1 else { return nil } + return matches.first + } +} + +/// Decides which links tapped inside the app navigate in place instead of +/// being handed to the system. +/// +/// A T3 link only stays in the app when it names a destination this device can +/// already show, so unknown web links keep opening on the web instead of +/// failing with an in-app error. Pairing links are always left to the system so +/// onboarding keeps owning connection confirmation. +enum PlatformInAppLinkRouter { + static func route(for url: URL, in snapshot: FeatureSnapshot) -> PlatformRoute? { + guard let route = try? PlatformDeepLinkParser.parse(url) else { return nil } + + switch route { + case .connection: + return nil + case let .thread(environmentID, threadID): + guard isSavedEnvironment(environmentID, in: snapshot), + PlatformRouteResolver.thread( + in: snapshot, + environmentID: environmentID, + id: threadID + ) != nil + else { + return nil + } + return route + case let .project(environmentID, projectID): + guard isSavedEnvironment(environmentID, in: snapshot), + PlatformRouteResolver.project( + in: snapshot, + environmentID: environmentID, + id: projectID + ) != nil + else { + return nil + } + return route + case let .environment(id): + guard isSavedEnvironment(id, in: snapshot) else { return nil } + return route + case let .newTask(environmentID, projectID): + guard isSavedEnvironment(environmentID, in: snapshot) else { return nil } + guard let projectID else { return route } + guard PlatformRouteResolver.project( + in: snapshot, + environmentID: environmentID, + id: projectID + ) != nil else { + return nil + } + return route + } + } + + private static func isSavedEnvironment(_ id: String?, in snapshot: FeatureSnapshot) -> Bool { + guard let id else { return true } + return snapshot.environments.contains { $0.id == id } + } +} diff --git a/apps/swift-ios/App/Platform/PlatformShortcuts.swift b/apps/swift-ios/App/Platform/PlatformShortcuts.swift new file mode 100644 index 000000000000..b8378d984092 --- /dev/null +++ b/apps/swift-ios/App/Platform/PlatformShortcuts.swift @@ -0,0 +1,142 @@ +import AppIntents +import Foundation + +struct PlatformRecentThreadRecord: Codable, Equatable, Sendable { + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + let updatedAt: Date +} + +final class PlatformRecentThreadStore: @unchecked Sendable { + static let shared = PlatformRecentThreadStore() + + private let defaults: UserDefaults + private let key: String + private let lock = NSLock() + + init(defaults: UserDefaults = .standard, key: String = "swift-ios.recent-threads.v1") { + self.defaults = defaults + self.key = key + } + + func update(from threads: [FeatureThread]) { + let records = threads + .filter { !$0.isArchived } + .sorted { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id < rhs.id + } + .prefix(12) + .map { + PlatformRecentThreadRecord( + id: $0.id, + environmentID: $0.environmentID, + wireID: $0.wireID ?? $0.id, + title: $0.title, + environmentName: $0.environmentName, + updatedAt: $0.updatedAt + ) + } + lock.withLock { + defaults.set(try? JSONEncoder().encode(records), forKey: key) + } + } + + func records() -> [PlatformRecentThreadRecord] { + lock.withLock { + guard let data = defaults.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([PlatformRecentThreadRecord].self, from: data)) ?? [] + } + } +} + +struct PlatformRecentThreadEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "T3 Code Thread") + static let defaultQuery = PlatformRecentThreadQuery() + + let id: String + let environmentID: String? + let wireID: String + let title: String + let environmentName: String? + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: "\(title)", + subtitle: environmentName.map { "\($0)" } + ) + } + + init(record: PlatformRecentThreadRecord) { + id = record.id + environmentID = record.environmentID + wireID = record.wireID + title = record.title + environmentName = record.environmentName + } +} + +struct PlatformRecentThreadQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [PlatformRecentThreadEntity] { + let requested = Set(identifiers) + return PlatformRecentThreadStore.shared.records() + .filter { requested.contains($0.id) } + .map(PlatformRecentThreadEntity.init) + } + + func suggestedEntities() async throws -> [PlatformRecentThreadEntity] { + PlatformRecentThreadStore.shared.records().map(PlatformRecentThreadEntity.init) + } +} + +struct NewT3TaskIntent: AppIntent { + static let title: LocalizedStringResource = "New T3 Code Task" + static let description = IntentDescription("Open the native composer and start a task.") + static let openAppWhenRun = true + + func perform() async throws -> some IntentResult { + PlatformRouteMailbox.shared.put(.newTask(environmentID: nil, projectID: nil)) + return .result() + } +} + +struct OpenRecentT3ThreadIntent: AppIntent { + static let title: LocalizedStringResource = "Open Recent T3 Code Thread" + static let description = IntentDescription("Open a recent thread in T3 Code.") + static let openAppWhenRun = true + + @Parameter(title: "Thread") + var thread: PlatformRecentThreadEntity + + func perform() async throws -> some IntentResult { + PlatformRouteMailbox.shared.put( + .thread(environmentID: thread.environmentID, threadID: thread.wireID) + ) + return .result() + } +} + +struct T3PlatformShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewT3TaskIntent(), + phrases: [ + "Start a task in \(.applicationName)", + "New task in \(.applicationName)", + ], + shortTitle: "New Task", + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: OpenRecentT3ThreadIntent(), + phrases: [ + "Open a recent thread in \(.applicationName)", + ], + shortTitle: "Open Thread", + systemImageName: "bubble.left.and.bubble.right" + ) + } +} diff --git a/apps/swift-ios/App/RootView.swift b/apps/swift-ios/App/RootView.swift new file mode 100644 index 000000000000..cb2ec784aca6 --- /dev/null +++ b/apps/swift-ios/App/RootView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// Owns app-wide presentation while the injected feature root owns product navigation. +struct RootView: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .background(T3Colors.background.ignoresSafeArea()) + .tint(T3Colors.accent) + } +} diff --git a/apps/swift-ios/App/T3CodeApp.swift b/apps/swift-ios/App/T3CodeApp.swift new file mode 100644 index 000000000000..d2854016003a --- /dev/null +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +@main +@MainActor +struct T3CodeApp: App { + @UIApplicationDelegateAdaptor(T3PlatformAppDelegate.self) private var appDelegate + @State private var model: FeatureRootModel + + init() { + let client = NativeFeatureClient() + let model = FeatureRootModel(client: client) + _model = State(initialValue: model) + PlatformCloudDeliveryCoordinator.shared.install( + controller: client.t3ConnectController + ) + PlatformBackgroundRefreshCoordinator.shared.install { [weak model] in + guard let model else { return false } + return await model.refreshInBackground() + } + } + + var body: some Scene { + WindowGroup { + RootView { + PlatformRootView(model: model) + } + } + } +} diff --git a/apps/swift-ios/Config/Local.xcconfig.example b/apps/swift-ios/Config/Local.xcconfig.example new file mode 100644 index 000000000000..dd71f95500f1 --- /dev/null +++ b/apps/swift-ios/Config/Local.xcconfig.example @@ -0,0 +1,32 @@ +// Local identity override for the SwiftUI client. +// +// Copy this file to Local.xcconfig (same directory, gitignored) and edit it to +// build with your own Apple Developer team and bundle identifiers. Every key is +// optional: an unset key keeps the upstream default from T3Code.xcconfig. +// Nothing here is read by CI or committed. + +// Apple Developer team id used by automatic signing (Xcode > Settings > +// Accounts). Required for device builds and archives; simulator builds with +// CODE_SIGNING_ALLOWED=NO ignore it. +DEVELOPMENT_TEAM = ABCDE12345 + +// Bundle identifier prefix. Every bundle derives from it: +// app (Debug: .dev) +// widgets .widgets (Debug: .dev.widgets) +// share .sharing (Debug: .dev.sharing) +// tests .tests +// App Group group. (Debug: group..dev) +// Register the App Group identifiers under your team before a device build; +// widgets and the share extension need the shared container. +T3CODE_BUNDLE_IDENTIFIER_PREFIX = com.example.t3code.swiftui + +// Home Screen display name per build configuration. Widget and share-extension +// names stay upstream's (T3CODE_WIDGET_DISPLAY_NAME, T3CODE_SHARE_DISPLAY_NAME +// in project.pbxproj) unless you override those too. +T3CODE_DEBUG_DISPLAY_NAME = T3 Swift Dev +T3CODE_RELEASE_DISPLAY_NAME = T3 Code SwiftUI + +// Optional T3 Connect settings (see README.md "Build configuration"). Leave +// unset for direct pairing only. +// T3CODE_CLERK_PUBLISHABLE_KEY = +// T3CODE_RELAY_URL = diff --git a/apps/swift-ios/Config/T3Code.xcconfig b/apps/swift-ios/Config/T3Code.xcconfig new file mode 100644 index 000000000000..c38aaf06401c --- /dev/null +++ b/apps/swift-ios/Config/T3Code.xcconfig @@ -0,0 +1,14 @@ +// Project-level identity defaults for the SwiftUI client. These values match +// the upstream project; every target's bundle identifier, App Group, and +// display name is derived from them in project.pbxproj. +// +// To build with your own Apple team and identifiers, copy Local.xcconfig.example +// to Local.xcconfig (gitignored) and set the keys there. Local.xcconfig is +// included last, so it wins over the defaults below. Settings passed on the +// xcodebuild command line still override both. + +T3CODE_BUNDLE_IDENTIFIER_PREFIX = com.t3tools.t3code.swiftui +T3CODE_DEBUG_DISPLAY_NAME = T3 Swift Dev +T3CODE_RELEASE_DISPLAY_NAME = T3 Code SwiftUI + +#include? "Local.xcconfig" diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift new file mode 100644 index 000000000000..dff4f5970c69 --- /dev/null +++ b/apps/swift-ios/Core/Attachments.swift @@ -0,0 +1,218 @@ +import Foundation + +public enum ImageAttachmentError: LocalizedError, Equatable, Sendable { + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case invalidName + case invalidMIMEType + + public var errorDescription: String? { + switch self { + case .empty: "The selected image is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The image is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .invalidName: "The image needs a valid file name." + case .invalidMIMEType: "The selected file is not a supported image." + } + } +} + +public enum FileAttachmentError: LocalizedError, Equatable, Sendable { + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case invalidName + case invalidMIMEType + case invalidFileURL + case unsupported + case tooMany(maximum: Int) + + public var errorDescription: String? { + switch self { + case .empty: "The selected file is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The file is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .invalidName: "The file needs a valid name." + case .invalidMIMEType: "The file needs a valid MIME type." + case .invalidFileURL: "The attachment file is no longer available." + case .unsupported: "This environment does not support file attachments." + case let .tooMany(maximum): "You can attach up to \(maximum) files per message." + } + } +} + +public struct UploadedAttachmentReference: Codable, Equatable, Sendable { + public let environmentID: String + public let attachmentID: String + + public init(environmentID: String, attachmentID: String) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +/// A validated turn attachment. Images can remain inline for older servers. +/// Generic files always stay file-backed and require the upload capability. +public struct UploadChatAttachment: Equatable, Sendable { + public static let maximumBytes = 10 * 1024 * 1024 + public static let maximumFileBytes = 50 * 1024 * 1024 + + enum Source: Equatable, Sendable { + case imageData(Data) + case file(URL) + } + + public let id: UUID + public let type: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + public let uploadedReference: UploadedAttachmentReference? + let source: Source + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: UploadedAttachmentReference? = nil + ) throws { + guard !data.isEmpty else { throw ImageAttachmentError.empty } + guard data.count <= Self.maximumBytes else { + throw ImageAttachmentError.tooLarge( + actualBytes: data.count, + maximumBytes: Self.maximumBytes + ) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { + throw ImageAttachmentError.invalidName + } + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard normalizedMIME.hasPrefix("image/"), normalizedMIME.count <= 100 else { + throw ImageAttachmentError.invalidMIMEType + } + self.id = id + type = "image" + self.name = normalizedName + self.mimeType = normalizedMIME + sizeBytes = data.count + self.uploadedReference = uploadedReference + source = .imageData(data) + } + + public init( + id: UUID = UUID(), + fileURL: URL, + name: String, + mimeType: String, + sizeBytes: Int, + uploadedReference: UploadedAttachmentReference? = nil + ) throws { + guard fileURL.isFileURL else { throw FileAttachmentError.invalidFileURL } + guard sizeBytes > 0 else { throw FileAttachmentError.empty } + guard sizeBytes <= Self.maximumFileBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: sizeBytes, + maximumBytes: Self.maximumFileBytes + ) + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty, normalizedName.count <= 255 else { + throw FileAttachmentError.invalidName + } + let normalizedMIME = mimeType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalizedMIME.isEmpty, normalizedMIME.count <= 100, + !normalizedMIME.contains(where: { $0.isWhitespace || $0.isNewline }) else { + throw FileAttachmentError.invalidMIMEType + } + self.id = id + type = "file" + self.name = normalizedName + self.mimeType = normalizedMIME + self.sizeBytes = sizeBytes + self.uploadedReference = uploadedReference + source = .file(fileURL) + } + + var jsonValue: JSONValue { + var value: [String: JSONValue] = [ + "type": .string(type), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ] + if case let .imageData(data) = source { + value["dataUrl"] = .string( + "data:\(mimeType);base64,\(data.base64EncodedString())" + ) + } + return .object(value) + } + + func uploadedJSONValue(id: String) -> JSONValue { + .object([ + "type": .string(type), + "id": .string(id), + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ]) + } +} + +/// Keeps the existing image API and call sites source-compatible. +public typealias UploadChatImageAttachment = UploadChatAttachment + +public struct AttachmentCreateUploadURLResult: Codable, Equatable, Sendable { + public let attachmentId: String + public let relativeUrl: String + public let expiresAt: Double +} + +public enum AssetResource: Equatable, Sendable { + case workspaceFile(threadID: String, path: String) + case mediaFile(threadID: String, path: String) + case attachment(id: String, fileName: String? = nil, mimeType: String? = nil) + case projectFavicon(cwd: String) + + var jsonValue: JSONValue { + switch self { + case let .workspaceFile(threadID, path): + return .object([ + "_tag": .string("workspace-file"), + "threadId": .string(threadID), + "path": .string(path), + ]) + case let .mediaFile(threadID, path): + return .object([ + "_tag": .string("media-file"), + "threadId": .string(threadID), + "path": .string(path), + ]) + case let .attachment(id, fileName, mimeType): + var value: [String: JSONValue] = [ + "_tag": .string("attachment"), + "attachmentId": .string(id), + ] + if let fileName { value["fileName"] = .string(fileName) } + if let mimeType { value["mimeType"] = .string(mimeType) } + return .object(value) + case let .projectFavicon(cwd): + return .object([ + "_tag": .string("project-favicon"), + "cwd": .string(cwd), + ]) + } + } +} + +public struct AssetCreateURLResult: Codable, Equatable, Sendable { + public let relativeUrl: String + /// Unix epoch milliseconds from the server contract. + public let expiresAt: Double +} + +public struct ResolvedAssetURL: Equatable, Sendable { + public let url: URL + public let expiresAt: Date +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift new file mode 100644 index 000000000000..f85ad8de0d6e --- /dev/null +++ b/apps/swift-ios/Core/HTTP.swift @@ -0,0 +1,664 @@ +import Foundation + +public protocol HTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) + func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) +} + +public extension HTTPTransport { + /// Test transports can keep recording URLRequest bodies without providing + /// a second transport implementation. Production overrides this method. + func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) + { + var request = request + request.httpBody = try Data(contentsOf: fileURL) + return try await data(for: request) + } +} + +/// The Core transport deliberately knows nothing about Clerk or the relay. +/// A managed-environment adapter supplies request-bound DPoP proofs and can +/// reacquire a bound access token when the current one expires or is rejected. +public protocol ManagedEnvironmentAuthorizing: Sendable { + func credentialRequiresRefresh( + _ credential: EnvironmentCredential, + environment: Environment + ) async throws -> Bool + + func authorize( + _ request: URLRequest, + environment: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest + + func refreshCredential( + for environment: Environment, + replacing credential: EnvironmentCredential + ) async throws -> EnvironmentCredential +} + +public struct URLSessionHTTPTransport: HTTPTransport { + private let session: URLSession + + public init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = [ + "Accept-Encoding": HTTPRequestPolicy.acceptEncoding, + ] + self.session = URLSession(configuration: configuration) + } + } + + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + // URLSession transparently decodes gzip responses before returning + // their body. Applying the policy here is a final guard for requests + // constructed outside EnvironmentAPI. + let (data, response) = try await session.data(for: HTTPRequestPolicy.prepare(request)) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPError.invalidResponse + } + return (data, httpResponse) + } + + public func upload(for request: URLRequest, fromFile fileURL: URL) async throws + -> (Data, HTTPURLResponse) + { + let (data, response) = try await session.upload( + for: HTTPRequestPolicy.prepare(request), + fromFile: fileURL + ) + guard let httpResponse = response as? HTTPURLResponse else { + throw HTTPError.invalidResponse + } + return (data, httpResponse) + } +} + +/// Shared wire-level defaults for HTTP requests. +/// +/// Foundation's URL loading system transparently decompresses gzip response +/// bodies. The explicit offer matters because T3 only compresses JSON when the +/// client advertises support. +public enum HTTPRequestPolicy { + public static let acceptEncoding = "gzip" + + public static func prepare(_ request: URLRequest) -> URLRequest { + var prepared = request + if prepared.value(forHTTPHeaderField: "Accept-Encoding") == nil { + prepared.setValue(acceptEncoding, forHTTPHeaderField: "Accept-Encoding") + } + if prepared.value(forHTTPHeaderField: "Accept") == nil { + prepared.setValue("application/json", forHTTPHeaderField: "Accept") + } + return prepared + } +} + +public enum HTTPError: LocalizedError, Sendable { + case invalidResponse + case status(Int, message: String, traceID: String?) + case missingCredential + case incompatibleCredential + case managedAuthorizationUnavailable + + public var errorDescription: String? { + switch self { + case .invalidResponse: + "The server returned an invalid response." + case let .status(status, message, traceID): + traceID.map { "\(message) (trace \($0))" } ?? "\(message) (HTTP \(status))" + case .missingCredential: + "This environment has no saved credential." + case .incompatibleCredential: + "This environment's saved authentication method is invalid. Connect it again." + case .managedAuthorizationUnavailable: + "This build cannot authorize a managed T3 Connect environment." + } + } +} + +enum DPoPFailureReason: Decodable, Equatable, Sendable { + case timeWindow + case keyMismatch + case requestMismatch + case tokenMismatch + case replay + case invalidProof + case unknown + + init(from decoder: any Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "time_window": self = .timeWindow + case "key_mismatch": self = .keyMismatch + case "request_mismatch": self = .requestMismatch + case "token_mismatch": self = .tokenMismatch + case "replay": self = .replay + case "invalid_proof": self = .invalidProof + default: self = .unknown + } + } +} + +enum DPoPFailurePresentation { + static let clockHint = + "Hint: Check that automatic date and time is enabled on both devices, then try again." + static let unknownHint = + "Hint: Try again. If it still fails, clock skew may be the cause; check that automatic date and time is enabled on both devices." + static let retryHint = "Hint: Try again. If the problem continues, copy the trace ID." + + static func message(_ message: String, reason: DPoPFailureReason?) -> String { + let hint = if reason == .timeWindow { + clockHint + } else if reason == nil { + unknownHint + } else { + retryHint + } + return "\(message) \(hint)" + } +} + +struct EnvironmentErrorBody: Decodable { + let message: String? + let reason: String? + let dpopFailureReason: DPoPFailureReason? + let traceId: String? +} + +public actor EnvironmentAPI { + private static let managedRefreshMargin: TimeInterval = 60 + + private let transport: any HTTPTransport + private let credentials: any CredentialStore + private let managedAuthorization: (any ManagedEnvironmentAuthorizing)? + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + credentials: any CredentialStore, + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil + ) { + self.transport = transport + self.credentials = credentials + self.managedAuthorization = managedAuthorization + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + try await send( + URLRequest(url: endpoint(httpBaseURL, path: "/.well-known/t3/environment")), + as: EnvironmentDescriptor.self + ) + } + + public func shellSnapshot( + for environment: Environment, + timeoutInterval: TimeInterval? = nil + ) async throws + -> OrchestrationShellSnapshot + { + try await authorized( + environment: environment, + path: "/api/orchestration/shell", + method: "GET", + timeoutInterval: timeoutInterval, + as: OrchestrationShellSnapshot.self + ) + } + + public func readModel(for environment: Environment) async throws -> OrchestrationReadModel { + try await authorized( + environment: environment, + path: "/api/orchestration/snapshot", + method: "GET", + as: OrchestrationReadModel.self + ) + } + + public func threadSnapshot( + id: String, + environment: Environment, + turnLimit: Int? = nil, + beforeCursor: String? = nil + ) async throws -> OrchestrationThreadDetailSnapshot { + let encodedID = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + var queryItems: [URLQueryItem] = [] + if let turnLimit { + queryItems.append(URLQueryItem(name: "turnLimit", value: String(turnLimit))) + } + if let beforeCursor { + queryItems.append(URLQueryItem(name: "beforeCursor", value: beforeCursor)) + } + return try await authorized( + environment: environment, + path: "/api/orchestration/threads/\(encodedID)", + queryItems: queryItems, + method: "GET", + as: OrchestrationThreadDetailSnapshot.self + ) + } + + public func dispatch( + _ command: JSONValue, + environment: Environment + ) async throws -> DispatchResult { + try await authorized( + environment: environment, + path: "/api/orchestration/dispatch", + method: "POST", + body: JSONEncoder.t3.encode(command), + as: DispatchResult.self + ) + } + + public func pullRequestDiff( + _ input: PullRequestDiffInput, + environment: Environment + ) async throws -> PullRequestDiffResult { + try await authorized( + environment: environment, + path: "/api/pull-requests/diff", + method: "POST", + body: JSONEncoder.t3.encode(input), + timeoutInterval: 60, + as: PullRequestDiffResult.self + ) + } + + /// Upload URLs carry their own short-lived signature and do not need the + /// environment's bearer token or DPoP authorization headers. + public func uploadAttachment( + _ data: Data, + mimeType: String, + to url: URL + ) async throws { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = data + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + request.setValue(String(data.count), forHTTPHeaderField: "Content-Length") + + let (responseData, response) = try await transport.data( + for: HTTPRequestPolicy.prepare(request) + ) + guard (200...299).contains(response.statusCode) else { + let detail = String(data: responseData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw HTTPError.status( + response.statusCode, + message: detail.flatMap { $0.isEmpty ? nil : $0 } ?? "Image upload failed.", + traceID: nil + ) + } + } + + /// Production uses URLSession's file upload API so large attachments never + /// become one in-memory Data value. + public func uploadAttachment( + fileURL: URL, + byteCount: Int, + mimeType: String, + to url: URL + ) async throws { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + request.setValue(String(byteCount), forHTTPHeaderField: "Content-Length") + + let (responseData, response) = try await transport.upload( + for: HTTPRequestPolicy.prepare(request), + fromFile: fileURL + ) + guard (200...299).contains(response.statusCode) else { + let detail = String(data: responseData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw HTTPError.status( + response.statusCode, + message: detail.flatMap { $0.isEmpty ? nil : $0 } ?? "File upload failed.", + traceID: nil + ) + } + } + + public func webSocketTicket(for environment: Environment) async throws -> WebSocketTicket { + try await authorized( + environment: environment, + path: "/api/auth/websocket-ticket", + method: "POST", + as: WebSocketTicket.self + ) + } + + public func session(for environment: Environment) async throws -> AuthSessionState { + try await authorized( + environment: environment, + path: "/api/auth/session", + method: "GET", + as: AuthSessionState.self + ) + } + + public func clientSessions(for environment: Environment) async throws + -> [AuthClientSession] + { + try await authorized( + environment: environment, + path: "/api/auth/clients", + method: "GET", + as: [AuthClientSession].self + ) + } + + public func revokeClientSession( + id: String, + environment: Environment + ) async throws -> AuthClientSessionRevokeResult { + try await authorized( + environment: environment, + path: "/api/auth/clients/revoke", + method: "POST", + body: JSONEncoder.t3.encode(["sessionId": id]), + as: AuthClientSessionRevokeResult.self + ) + } + + public func revokeOtherClientSessions( + for environment: Environment + ) async throws -> AuthOtherClientSessionsRevokeResult { + try await authorized( + environment: environment, + path: "/api/auth/clients/revoke-others", + method: "POST", + as: AuthOtherClientSessionsRevokeResult.self + ) + } + + public func prism(_ input: PrismRequest, environment: Environment) async throws -> PrismResponse { + guard input.path.hasPrefix("/"), !input.path.contains(".."), + !input.path.contains("?"), !input.path.contains("#") else { + throw HTTPError.invalidResponse + } + let body = try input.body.map { try JSONEncoder().encode($0) } + return try await authorized( + environment: environment, + path: "/api/fork/prism" + input.path, + method: input.method, + body: body, + as: PrismResponse.self + ) + } + + private func authorized( + environment: Environment, + path: String, + queryItems: [URLQueryItem] = [], + method: String, + body: Data? = nil, + timeoutInterval: TimeInterval? = nil, + as type: Result.Type + ) async throws -> Result { + guard let credential = try await credentials.credential(for: environment.id) else { + throw HTTPError.missingCredential + } + + switch environment.kind { + case .bearer, .local: + guard credential.authorizationMethod == .bearer else { + throw HTTPError.incompatibleCredential + } + var request = makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ) + if let timeoutInterval { + request.timeoutInterval = timeoutInterval + } + request.setValue( + "Bearer \(credential.accessToken)", + forHTTPHeaderField: "Authorization" + ) + return try await send(request, as: type) + + case .managedDPoP: + guard credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id else { + throw HTTPError.incompatibleCredential + } + guard let managedAuthorization else { + throw HTTPError.managedAuthorizationUnavailable + } + + var current = credential + let bindingRequiresRefresh = try await managedAuthorization + .credentialRequiresRefresh(current, environment: environment) + if current.expiresAt?.timeIntervalSinceNow ?? 0 <= Self.managedRefreshMargin + || bindingRequiresRefresh { + current = try await refreshManagedCredential( + current, + environment: environment, + using: managedAuthorization + ) + } + var request = try await managedAuthorization.authorize( + makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ), + environment: environment, + credential: current + ) + if let timeoutInterval { + request.timeoutInterval = timeoutInterval + } + do { + return try await send(request, as: type) + } catch let error as HTTPError where error.isRejectedAuthorization { + if let saved = try await newestUsableManagedCredential( + replacing: current, + environment: environment, + using: managedAuthorization + ) { + current = saved + } else { + current = try await refreshManagedCredential( + current, + environment: environment, + using: managedAuthorization + ) + } + var retry = try await managedAuthorization.authorize( + makeRequest( + environment: environment, + path: path, + queryItems: queryItems, + method: method, + body: body + ), + environment: environment, + credential: current + ) + if let timeoutInterval { + retry.timeoutInterval = timeoutInterval + } + return try await send(retry, as: type) + } + } + } + + private func makeRequest( + environment: Environment, + path: String, + queryItems: [URLQueryItem], + method: String, + body: Data? + ) -> URLRequest { + var request = URLRequest( + url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) + ) + request.httpMethod = method + request.httpBody = body + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func refreshManagedCredential( + _ credential: EnvironmentCredential, + environment: Environment, + using managedAuthorization: any ManagedEnvironmentAuthorizing + ) async throws -> EnvironmentCredential { + if let current = try await newestUsableManagedCredential( + replacing: credential, + environment: environment, + using: managedAuthorization + ) { + return current + } + let refreshed = try await managedAuthorization.refreshCredential( + for: environment, + replacing: credential + ) + guard refreshed.authorizationMethod == .dpop, + refreshed.managedEnvironmentID == environment.id, + refreshed.proofKeyThumbprint?.isEmpty == false else { + throw HTTPError.incompatibleCredential + } + guard try await credentials.replaceCredential( + refreshed, + ifMatching: credential, + for: environment.id + ) else { + if let current = try await newestUsableManagedCredential( + replacing: credential, + environment: environment, + using: managedAuthorization + ) { + return current + } + throw HTTPError.missingCredential + } + return refreshed + } + + private func newestUsableManagedCredential( + replacing credential: EnvironmentCredential, + environment: Environment, + using managedAuthorization: any ManagedEnvironmentAuthorizing + ) async throws -> EnvironmentCredential? { + guard let saved = try await credentials.credential(for: environment.id), + saved != credential, + saved.authorizationMethod == .dpop, + saved.managedEnvironmentID == environment.id, + saved.proofKeyThumbprint?.isEmpty == false, + saved.expiresAt?.timeIntervalSinceNow ?? 0 > Self.managedRefreshMargin else { + return nil + } + let requiresRefresh = try await managedAuthorization.credentialRequiresRefresh( + saved, + environment: environment + ) + guard !requiresRefresh else { return nil } + return saved + } + + private func send( + _ request: URLRequest, + as type: Result.Type + ) async throws -> Result { + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(EnvironmentErrorBody.self, from: data) + let message: String + if response.statusCode == 401, + request.value(forHTTPHeaderField: "DPoP") != nil, + body?.reason == "invalid_credential" + { + message = DPoPFailurePresentation.message( + "The environment credential is invalid.", + reason: body?.dpopFailureReason + ) + } else { + message = body?.message ?? body?.reason ?? "Environment request failed." + } + throw HTTPError.status( + response.statusCode, + message: message, + traceID: body?.traceId + ) + } + return try JSONDecoder.t3.decode(type, from: data) + } +} + +private extension HTTPError { + var isRejectedAuthorization: Bool { + guard case let .status(status, _, _) = self else { return false } + return status == 401 + } +} + +public struct DispatchResult: Codable, Equatable, Sendable { + public let sequence: Int +} + +public struct WebSocketTicket: Codable, Equatable, Sendable { + public let ticket: String + public let expiresAt: String +} + +public struct AuthSessionState: Codable, Equatable, Sendable { + public let authenticated: Bool + public let scopes: [String]? + public let sessionMethod: String? + public let expiresAt: String? +} + +public struct AuthClientMetadata: Codable, Equatable, Sendable { + public let label: String? + public let ipAddress: String? + public let userAgent: String? + public let deviceType: String + public let os: String? + public let browser: String? +} + +public struct AuthClientSession: Codable, Identifiable, Equatable, Sendable { + public var id: String { sessionId } + + public let sessionId: String + public let subject: String + public let scopes: [String] + public let method: String + public let client: AuthClientMetadata + public let issuedAt: String + public let expiresAt: String + public let lastConnectedAt: String? + public let connected: Bool + public let current: Bool +} + +public struct AuthClientSessionRevokeResult: Codable, Equatable, Sendable { + public let revoked: Bool +} + +public struct AuthOtherClientSessionsRevokeResult: Codable, Equatable, Sendable { + public let revokedCount: Int +} + +func endpoint(_ baseURL: URL, path: String, queryItems: [URLQueryItem] = []) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.path = path + components.queryItems = queryItems.isEmpty ? nil : queryItems + components.fragment = nil + return components.url! +} diff --git a/apps/swift-ios/Core/JSONValue.swift b/apps/swift-ios/Core/JSONValue.swift new file mode 100644 index 000000000000..c149d9a19037 --- /dev/null +++ b/apps/swift-ios/Core/JSONValue.swift @@ -0,0 +1,111 @@ +import Foundation + +/// A lossless, Sendable JSON representation used at protocol boundaries that +/// intentionally carry provider-defined payloads. +public enum JSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case integer(Int64) + case unsignedInteger(UInt64) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int64.self) { + let double = Double(value) + self = Int64(exactly: double) == value ? .number(double) : .integer(value) + } else if let value = try? container.decode(UInt64.self) { + let double = Double(value) + self = UInt64(exactly: double) == value + ? .number(double) + : .unsignedInteger(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .integer(value): + try container.encode(value) + case let .unsignedInteger(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } + + public subscript(key: String) -> JSONValue? { + guard case let .object(object) = self else { return nil } + return object[key] + } + + public var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + public static func encode( + _ value: T, + encoder: JSONEncoder = .t3 + ) throws -> JSONValue { + let data = try encoder.encode(value) + return try JSONDecoder.t3.decode(JSONValue.self, from: data) + } + + public func decode( + _ type: T.Type, + decoder: JSONDecoder = .t3 + ) throws -> T { + // The intermediate bytes are discarded immediately, so skip the + // deterministic-output formatting the wire encoder pays for. + try decoder.decode(type, from: JSONEncoder.t3Intermediate.encode(self)) + } +} + +// Encoders and decoders are configured once and never mutated afterwards, so +// shared instances are safe for concurrent use and avoid rebuilding coder +// state on every RPC message. +extension JSONEncoder { + /// Deterministic output for wire payloads. + public static let t3: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return encoder + }() + + /// Throwaway intermediate encoding (JSONValue -> concrete type bridging). + static let t3Intermediate: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return encoder + }() +} + +extension JSONDecoder { + public static let t3 = JSONDecoder() +} diff --git a/apps/swift-ios/Core/LocalNetworkProbe.swift b/apps/swift-ios/Core/LocalNetworkProbe.swift new file mode 100644 index 000000000000..878a55606a8c --- /dev/null +++ b/apps/swift-ios/Core/LocalNetworkProbe.swift @@ -0,0 +1,179 @@ +import Foundation + +public struct ConnectionProbeResult: Equatable, Sendable { + public let baseURL: URL + public let descriptor: EnvironmentDescriptor + public let latency: Duration +} + +public enum ConnectionProbeError: LocalizedError, Equatable, Sendable { + case invalidURL + case unavailableHost(String) + case timeout(String) + case likelyLocalNetworkDenied(String) + case serverRejected(status: Int, message: String) + case transport(String) + + public var errorDescription: String? { + switch self { + case .invalidURL: + "Enter a valid T3 server address." + case let .unavailableHost(host): + "Could not reach \(host). Check that the server is running and both devices are online." + case let .timeout(host): + "\(host) did not respond in time." + case let .likelyLocalNetworkDenied(host): + "Local Network access appears to be off for T3 Code. Allow it in Settings, then retry \(host)." + case let .serverRejected(status, message): + "\(message) (HTTP \(status))" + case let .transport(message): + message + } + } +} + +/// Performs the public environment-descriptor request before token exchange. +/// +/// iOS does not expose a direct Local Network privacy authorization query. A +/// real connection attempt is the authoritative way to trigger/check access; +/// failures carrying POSIX permission evidence are reported separately from an +/// offline server. +public actor LocalNetworkProbe { + private let transport: any HTTPTransport + + public init(transport: any HTTPTransport = URLSessionHTTPTransport()) { + self.transport = transport + } + + public func probe( + address rawAddress: String, + timeout: TimeInterval = 5 + ) async throws -> ConnectionProbeResult { + let fields: PairingInputFields + do { + fields = try PairingURL.parseFields(rawAddress) + } catch { + throw ConnectionProbeError.invalidURL + } + guard let baseURL = try? PairingURL.httpBaseURL(for: fields.host), + let host = baseURL.host else { + throw ConnectionProbeError.invalidURL + } + + var request = URLRequest( + url: endpoint(baseURL, path: "/.well-known/t3/environment"), + timeoutInterval: max(1, timeout) + ) + request.httpMethod = "GET" + let clock = ContinuousClock() + let startedAt = clock.now + do { + let (data, response) = try await transport.data( + for: HTTPRequestPolicy.prepare(request) + ) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(JSONValue.self, from: data) + throw ConnectionProbeError.serverRejected( + status: response.statusCode, + message: body?["message"]?.stringValue + ?? body?["reason"]?.stringValue + ?? "The server rejected the connection probe." + ) + } + let descriptor = try JSONDecoder.t3.decode(EnvironmentDescriptor.self, from: data) + return ConnectionProbeResult( + baseURL: baseURL, + descriptor: descriptor, + latency: startedAt.duration(to: clock.now) + ) + } catch let error as ConnectionProbeError { + throw error + } catch { + throw Self.classify(error, host: host, isLocal: Self.isLocalHost(host)) + } + } + + public static func classify( + _ error: any Error, + host: String, + isLocal: Bool + ) -> ConnectionProbeError { + let nsError = error as NSError + let urlCode = URLError.Code(rawValue: nsError.code) + if nsError.domain == NSURLErrorDomain { + switch urlCode { + case .timedOut: + return .timeout(host) + case .cannotFindHost, .dnsLookupFailed, .cannotConnectToHost: + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .unavailableHost(host) + case .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, + .dataNotAllowed: + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .unavailableHost(host) + default: + break + } + } + if isLocal, hasPermissionDenialEvidence(nsError) { + return .likelyLocalNetworkDenied(host) + } + return .transport(nsError.localizedDescription) + } + + public static func isLocalHost(_ host: String) -> Bool { + let value = host + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + .lowercased() + if value == "localhost" || value == "::1" || value.hasSuffix(".local") { + return true + } + if value.hasPrefix("10.") + || value.hasPrefix("127.") + || value.hasPrefix("192.168.") + || value.hasPrefix("169.254.") + { + return true + } + let octets = value.split(separator: ".").compactMap { Int($0) } + if octets.count == 4, octets[0] == 172, (16...31).contains(octets[1]) { + return true + } + // IPv6 unique-local and link-local ranges. + return value.hasPrefix("fc") + || value.hasPrefix("fd") + || value.hasPrefix("fe8") + || value.hasPrefix("fe9") + || value.hasPrefix("fea") + || value.hasPrefix("feb") + } + + private static func hasPermissionDenialEvidence(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, error.code == 1 || error.code == 13 { + return true + } + let description = [ + error.localizedDescription, + error.localizedFailureReason, + error.localizedRecoverySuggestion, + String(describing: error.userInfo), + ] + .compactMap { $0 } + .joined(separator: " ") + .lowercased() + if description.contains("local network prohibited") + || description.contains("localnetworkdenied") + || description.contains("local network denied") + { + return true + } + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return hasPermissionDenialEvidence(underlying) + } + return false + } +} diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift new file mode 100644 index 000000000000..d08971232cf2 --- /dev/null +++ b/apps/swift-ios/Core/Models.swift @@ -0,0 +1,699 @@ +import Foundation + +/// Arrays sent by the server are allowed to grow new element variants before a +/// mobile release catches up. Decode each element independently so one future +/// project, thread, message, or activity cannot discard the rest of a snapshot. +@propertyWrapper +public struct ForwardCompatibleArray: Codable, Equatable, Sendable +where Element: Codable & Equatable & Sendable { + public var wrappedValue: [Element] + + public init(wrappedValue: [Element]) { + self.wrappedValue = wrappedValue + } + + public init(from decoder: any Decoder) throws { + var container = try decoder.unkeyedContainer() + var values: [Element] = [] + values.reserveCapacity(container.count ?? 0) + while !container.isAtEnd { + // `superDecoder` advances the unkeyed container even when the + // element itself is not understood by this client. + let elementDecoder = try container.superDecoder() + if let value = try? Element(from: elementDecoder) { + values.append(value) + } + } + wrappedValue = values + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.unkeyedContainer() + for value in wrappedValue { + try container.encode(value) + } + } +} + +public enum EnvironmentKind: String, Codable, Sendable { + case bearer + case local + case managedDPoP = "managed-dpop" +} + +public struct Environment: Codable, Identifiable, Equatable, Sendable { + public let id: String + public var label: String + public var httpBaseURL: URL + public var webSocketBaseURL: URL + public var kind: EnvironmentKind + public var descriptor: EnvironmentDescriptor? + public var isEnabled: Bool + + public init( + id: String, + label: String, + httpBaseURL: URL, + webSocketBaseURL: URL, + kind: EnvironmentKind = .bearer, + descriptor: EnvironmentDescriptor? = nil, + isEnabled: Bool = true + ) { + self.id = id + self.label = label + self.httpBaseURL = httpBaseURL + self.webSocketBaseURL = webSocketBaseURL + self.kind = kind + self.descriptor = descriptor + self.isEnabled = isEnabled + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case httpBaseURL + case webSocketBaseURL + case kind + case descriptor + case isEnabled + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + label = try container.decode(String.self, forKey: .label) + httpBaseURL = try container.decode(URL.self, forKey: .httpBaseURL) + webSocketBaseURL = try container.decode(URL.self, forKey: .webSocketBaseURL) + kind = try container.decodeIfPresent(EnvironmentKind.self, forKey: .kind) ?? .bearer + descriptor = try container.decodeIfPresent(EnvironmentDescriptor.self, forKey: .descriptor) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + } +} + +public struct EnvironmentDescriptor: Codable, Equatable, Sendable { + public struct Platform: Codable, Equatable, Sendable { + public let os: String + public let arch: String + } + + public struct Capabilities: Codable, Equatable, Sendable { + public struct FileAttachments: Codable, Equatable, Sendable { + public let maxUploadBytes: Int + } + + public let repositoryIdentity: Bool + public let connectionProbe: Bool? + public let attachmentUploads: Bool? + public let fileAttachments: FileAttachments? + public let pullRequests: Bool? + public let threadSettlement: Bool? + public let threadAutoSettlement: Bool? + public let threadSnooze: Bool? + public let threadPinning: Bool? + public let threadTitleRegeneration: Bool? + public let threadPullRequestLinking: Bool? + public let serverSelfUpdate: String? + public let serverSelfUpdateProgress: Bool? + public let forkFlags: [String: Bool]? + + private enum CodingKeys: String, CodingKey { + case repositoryIdentity + case connectionProbe + case attachmentUploads + case fileAttachments + case pullRequests + case threadSettlement + case threadAutoSettlement + case threadSnooze + case threadPinning + case threadTitleRegeneration + case threadPullRequestLinking + case serverSelfUpdate + case serverSelfUpdateProgress + case forkFlags + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + repositoryIdentity = + try container.decodeIfPresent(Bool.self, forKey: .repositoryIdentity) ?? false + forkFlags = try container.decodeIfPresent([String: Bool].self, forKey: .forkFlags) + connectionProbe = try container.decodeIfPresent(Bool.self, forKey: .connectionProbe) + attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) + fileAttachments = try container.decodeIfPresent( + FileAttachments.self, + forKey: .fileAttachments + ) + pullRequests = try container.decodeIfPresent(Bool.self, forKey: .pullRequests) + threadSettlement = try container.decodeIfPresent(Bool.self, forKey: .threadSettlement) + threadAutoSettlement = try container.decodeIfPresent( + Bool.self, + forKey: .threadAutoSettlement + ) + threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze) + threadPinning = try container.decodeIfPresent(Bool.self, forKey: .threadPinning) + threadTitleRegeneration = try container.decodeIfPresent( + Bool.self, + forKey: .threadTitleRegeneration + ) + threadPullRequestLinking = try container.decodeIfPresent( + Bool.self, + forKey: .threadPullRequestLinking + ) + serverSelfUpdate = try container.decodeIfPresent(String.self, forKey: .serverSelfUpdate) + serverSelfUpdateProgress = try container.decodeIfPresent( + Bool.self, + forKey: .serverSelfUpdateProgress + ) + } + } + + public let environmentId: String + public let label: String + public let platform: Platform + public let serverVersion: String + public let capabilities: Capabilities +} + +public struct ProviderUploadFeedbackResult: Codable, Equatable, Sendable { + public let feedbackId: String +} + +public enum EnvironmentCredentialAuthorizationMethod: String, Codable, Sendable { + case bearer + case dpop +} + +public struct EnvironmentCredential: Codable, Equatable, Sendable, + CustomStringConvertible, CustomDebugStringConvertible +{ + public let accessToken: String + public let expiresAt: Date? + public let scopes: [String] + public let authorizationMethod: EnvironmentCredentialAuthorizationMethod + public let managedEnvironmentID: String? + public let proofKeyThumbprint: String? + + public init(accessToken: String, expiresAt: Date? = nil, scopes: [String] = []) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + authorizationMethod = .bearer + managedEnvironmentID = nil + proofKeyThumbprint = nil + } + + public static func managedDPoP( + accessToken: String, + expiresAt: Date, + scopes: [String], + environmentID: String, + proofKeyThumbprint: String + ) -> EnvironmentCredential { + EnvironmentCredential( + accessToken: accessToken, + expiresAt: expiresAt, + scopes: scopes, + authorizationMethod: .dpop, + managedEnvironmentID: environmentID, + proofKeyThumbprint: proofKeyThumbprint + ) + } + + public var description: String { + "EnvironmentCredential(method: \(authorizationMethod.rawValue), token: )" + } + + public var debugDescription: String { description } + + private init( + accessToken: String, + expiresAt: Date?, + scopes: [String], + authorizationMethod: EnvironmentCredentialAuthorizationMethod, + managedEnvironmentID: String?, + proofKeyThumbprint: String? + ) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.scopes = scopes + self.authorizationMethod = authorizationMethod + self.managedEnvironmentID = managedEnvironmentID + self.proofKeyThumbprint = proofKeyThumbprint + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case expiresAt + case scopes + case authorizationMethod + case managedEnvironmentID + case proofKeyThumbprint + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try container.decode(String.self, forKey: .accessToken) + expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) + scopes = try container.decodeIfPresent([String].self, forKey: .scopes) ?? [] + authorizationMethod = try container.decodeIfPresent( + EnvironmentCredentialAuthorizationMethod.self, + forKey: .authorizationMethod + ) ?? .bearer + managedEnvironmentID = try container.decodeIfPresent( + String.self, + forKey: .managedEnvironmentID + ) + proofKeyThumbprint = try container.decodeIfPresent( + String.self, + forKey: .proofKeyThumbprint + ) + + if authorizationMethod == .dpop { + guard expiresAt != nil, + managedEnvironmentID?.isEmpty == false, + proofKeyThumbprint?.isEmpty == false else { + throw DecodingError.dataCorruptedError( + forKey: .authorizationMethod, + in: container, + debugDescription: "A DPoP credential is missing its binding metadata." + ) + } + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(accessToken, forKey: .accessToken) + try container.encodeIfPresent(expiresAt, forKey: .expiresAt) + try container.encode(scopes, forKey: .scopes) + try container.encode(authorizationMethod, forKey: .authorizationMethod) + try container.encodeIfPresent(managedEnvironmentID, forKey: .managedEnvironmentID) + try container.encodeIfPresent(proofKeyThumbprint, forKey: .proofKeyThumbprint) + } +} + +public struct ModelSelection: Codable, Equatable, Sendable { + public struct OptionSelection: Codable, Equatable, Sendable { + public let id: String + public let value: JSONValue + + public init(id: String, value: JSONValue) { + self.id = id + self.value = value + } + } + + public let instanceId: String + public let model: String + public let options: [OptionSelection]? + + public init(instanceId: String, model: String, options: [OptionSelection]? = nil) { + self.instanceId = instanceId + self.model = model + self.options = options + } + + private enum CodingKeys: String, CodingKey { + case instanceId, provider, model, options + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + instanceId = try container.decodeIfPresent(String.self, forKey: .instanceId) + ?? container.decode(String.self, forKey: .provider) + model = try container.decode(String.self, forKey: .model) + if let canonical = try? container.decode([OptionSelection].self, forKey: .options) { + options = canonical + } else if let legacy = try? container.decode( + [String: JSONValue].self, + forKey: .options + ) { + options = legacy.keys.sorted().map { OptionSelection(id: $0, value: legacy[$0]!) } + } else { + options = nil + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(instanceId, forKey: .instanceId) + try container.encode(model, forKey: .model) + try container.encodeIfPresent(options, forKey: .options) + } +} + +public struct RepositoryIdentity: Codable, Equatable, Sendable { + public struct Locator: Codable, Equatable, Sendable { + public let source: String + public let remoteName: String + public let remoteUrl: String + } + + public let canonicalKey: String + public let locator: Locator + public let rootPath: String? + public let displayName: String? + public let provider: String? + public let owner: String? + public let name: String? +} + +public struct ProjectScript: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let command: String + public let icon: String + public let runOnWorktreeCreate: Bool + public let previewUrl: String? + public let autoOpenPreview: Bool? +} + +public struct OrchestrationProject: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let workspaceRoot: String + public let repositoryIdentity: RepositoryIdentity? + public let defaultModelSelection: ModelSelection? + public let scripts: [ProjectScript] + public let createdAt: String + public let updatedAt: String + public let deletedAt: String? +} + +public enum RuntimeMode: String, Codable, CaseIterable, Sendable { + case approvalRequired = "approval-required" + case autoAcceptEdits = "auto-accept-edits" + case auto + case fullAccess = "full-access" +} + +public enum InteractionMode: String, Codable, CaseIterable, Sendable { + case `default` + case plan +} + +public struct OrchestrationLatestTurn: Codable, Equatable, Sendable { + public let turnId: String + public let state: String + public let requestedAt: String + public let startedAt: String? + public let completedAt: String? + public let assistantMessageId: String? +} + +public struct OrchestrationSession: Codable, Equatable, Sendable { + public let threadId: String + public let status: String + public let providerName: String? + public let providerInstanceId: String? + public let runtimeMode: RuntimeMode + public let activeTurnId: String? + public let lastError: String? + public let updatedAt: String +} + +public enum OrchestrationBackgroundLiveness: String, Codable, Equatable, Sendable { + case working + case monitoring +} + +public struct ThreadLinkedPullRequest: Codable, Equatable, Hashable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + public let url: String + + public init(projectId: String, repository: String, number: Int, url: String) { + self.projectId = projectId + self.repository = repository + self.number = number + self.url = url + } +} + +public struct OrchestrationThreadShell: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let projectId: String + public let title: String + public let modelSelection: ModelSelection + public let runtimeMode: RuntimeMode + public let interactionMode: InteractionMode + public let branch: String? + public let worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public var unsettledAt: String? = nil + public let snoozedUntil: String? + public let snoozedAt: String? + public let pinnedAt: String? + public let session: OrchestrationSession? + public let latestUserMessageAt: String? + public let hasPendingApprovals: Bool + public let hasPendingUserInput: Bool + public let hasActionableProposedPlan: Bool + public let backgroundLiveness: OrchestrationBackgroundLiveness? +} + +public struct OrchestrationMessage: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let role: String + public let text: String + public let attachments: [ChatAttachment]? + public let turnId: String? + public let streaming: Bool + public let createdAt: String + public let updatedAt: String +} + +public struct ChatAttachment: Codable, Identifiable, Equatable, Sendable { + public let type: String + public let id: String + public let name: String + public let mimeType: String + public let sizeBytes: Int +} + +public struct OrchestrationActivity: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let tone: String + public let kind: String + public let summary: String + public let payload: JSONValue + public let turnId: String? + public let sequence: Int? + public let createdAt: String +} + +public struct CheckpointFile: Codable, Equatable, Sendable { + public let path: String + public let kind: String + public let additions: Int + public let deletions: Int +} + +public struct CheckpointSummary: Codable, Equatable, Sendable { + public let turnId: String + public let checkpointTurnCount: Int + public let checkpointRef: String + public let status: String + public let files: [CheckpointFile] + public let assistantMessageId: String? + public let completedAt: String +} + +public struct OrchestrationThread: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let projectId: String + public let title: String + public let modelSelection: ModelSelection + public let runtimeMode: RuntimeMode + public let interactionMode: InteractionMode + public let branch: String? + public let worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? = nil + public let latestTurn: OrchestrationLatestTurn? + public let createdAt: String + public let updatedAt: String + public let archivedAt: String? + public let settledOverride: String? + public let settledAt: String? + public var unsettledAt: String? = nil + public let snoozedUntil: String? + public let snoozedAt: String? + public let pinnedAt: String? + public let deletedAt: String? + @ForwardCompatibleArray public var messages: [OrchestrationMessage] + @ForwardCompatibleArray public var activities: [OrchestrationActivity] + @ForwardCompatibleArray public var checkpoints: [CheckpointSummary] + public let session: OrchestrationSession? +} + +public struct OrchestrationShellSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + @ForwardCompatibleArray public var projects: [OrchestrationProject] + @ForwardCompatibleArray public var threads: [OrchestrationThreadShell] + public let updatedAt: String +} + +public struct OrchestrationReadModel: Codable, Equatable, Sendable { + public let snapshotSequence: Int + @ForwardCompatibleArray public var projects: [OrchestrationProject] + @ForwardCompatibleArray public var threads: [OrchestrationThread] + public let updatedAt: String +} + +public struct OrchestrationThreadDetailSnapshot: Codable, Equatable, Sendable { + public let snapshotSequence: Int + public let thread: OrchestrationThread + public let page: OrchestrationThreadDetailPage? + + public init( + snapshotSequence: Int, + thread: OrchestrationThread, + page: OrchestrationThreadDetailPage? = nil + ) { + self.snapshotSequence = snapshotSequence + self.thread = thread + self.page = page + } +} + +public struct OrchestrationThreadDetailPage: Codable, Equatable, Sendable { + public let beforeCursor: String? + public let hasMore: Bool + public let snapshotSequence: Int + public let threadSequence: Int? + + public init( + beforeCursor: String?, + hasMore: Bool, + snapshotSequence: Int, + threadSequence: Int? = nil + ) { + self.beforeCursor = beforeCursor + self.hasMore = hasMore + self.snapshotSequence = snapshotSequence + self.threadSequence = threadSequence + } +} + +public enum ShellStreamItem: Decodable, Sendable { + case synchronized + case snapshot(OrchestrationShellSnapshot) + case projectUpserted(sequence: Int, project: OrchestrationProject) + case projectRemoved(sequence: Int, projectID: String) + case threadUpserted(sequence: Int, thread: OrchestrationThreadShell) + case threadRemoved(sequence: Int, threadID: String) + /// A newer server emitted a delta this build cannot reduce. The live client + /// should fetch an authoritative shell snapshot and keep the stream alive. + case refreshRequired + + private enum CodingKeys: String, CodingKey { + case kind, sequence, snapshot, project, projectId, thread, threadId + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "synchronized": + self = .synchronized + case "snapshot": + guard let snapshot = try? container.decode( + OrchestrationShellSnapshot.self, + forKey: .snapshot + ) else { + self = .refreshRequired + return + } + self = .snapshot(snapshot) + case "project-upserted": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let project = try? container.decode( + OrchestrationProject.self, + forKey: .project + ) else { + self = .refreshRequired + return + } + self = .projectUpserted( + sequence: sequence, + project: project + ) + case "project-removed": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let projectID = try? container.decode(String.self, forKey: .projectId) else { + self = .refreshRequired + return + } + self = .projectRemoved( + sequence: sequence, + projectID: projectID + ) + case "thread-upserted": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let thread = try? container.decode( + OrchestrationThreadShell.self, + forKey: .thread + ) else { + self = .refreshRequired + return + } + self = .threadUpserted( + sequence: sequence, + thread: thread + ) + case "thread-removed": + guard let sequence = try? container.decode(Int.self, forKey: .sequence), + let threadID = try? container.decode(String.self, forKey: .threadId) else { + self = .refreshRequired + return + } + self = .threadRemoved( + sequence: sequence, + threadID: threadID + ) + default: + self = .refreshRequired + } + } +} + +public enum ThreadStreamItem: Decodable, Sendable { + case synchronized + case snapshot(OrchestrationThreadDetailSnapshot) + case event(JSONValue) + + private enum CodingKeys: String, CodingKey { case kind, snapshot, event } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "synchronized": + self = .synchronized + case "snapshot": + guard let snapshot = try? container.decode( + OrchestrationThreadDetailSnapshot.self, + forKey: .snapshot + ) else { + self = .event(.null) + return + } + self = .snapshot(snapshot) + case "event": + self = .event((try? container.decode(JSONValue.self, forKey: .event)) ?? .null) + default: + // The detail reducer already treats an unrecognized event as an + // authoritative-refresh request. Reuse that path without adding a + // second stream state or terminating the subscription. + self = .event(.null) + } + } +} diff --git a/apps/swift-ios/Core/PairingService.swift b/apps/swift-ios/Core/PairingService.swift new file mode 100644 index 000000000000..9cf2b41cc1e0 --- /dev/null +++ b/apps/swift-ios/Core/PairingService.swift @@ -0,0 +1,159 @@ +import Foundation + +public struct TokenExchangeResult: Decodable, Sendable { + public let accessToken: String + public let issuedTokenType: String + public let tokenType: String + public let expiresIn: Double + public let scope: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} + +public actor PairingService { + private let transport: any HTTPTransport + private let environmentStore: EnvironmentStore + private let credentialStore: any CredentialStore + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + environmentStore: EnvironmentStore, + credentialStore: any CredentialStore + ) { + self.transport = transport + self.environmentStore = environmentStore + self.credentialStore = credentialStore + } + + @discardableResult + public func pair( + url pairingURL: String, + label clientLabel: String? = nil + ) async throws -> Environment { + try await pair(target: PairingURL.resolve(pairingURL), clientLabel: clientLabel) + } + + @discardableResult + public func pair( + host: String, + code: String, + label clientLabel: String? = nil + ) async throws -> Environment { + try await pair( + target: PairingURL.resolve(host: host, pairingCode: code), + clientLabel: clientLabel + ) + } + + private func pair( + target: PairingTarget, + clientLabel: String? + ) async throws -> Environment { + let api = EnvironmentAPI(transport: transport, credentials: credentialStore) + let descriptor = try await api.descriptor(at: target.httpBaseURL) + let access = try await exchange(target: target, clientLabel: clientLabel) + guard access.tokenType == "Bearer" else { + throw HTTPError.status( + 400, + message: "The environment issued an unsupported \(access.tokenType) token.", + traceID: nil + ) + } + let environment = Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: target.httpBaseURL, + webSocketBaseURL: target.webSocketBaseURL, + descriptor: descriptor + ) + let credential = EnvironmentCredential( + accessToken: access.accessToken, + expiresAt: Date().addingTimeInterval(access.expiresIn), + scopes: access.scope.split(separator: " ").map(String.init) + ) + // Store the secret first. A catalog record must never point at a + // credential that failed to persist. Capture the previous credential in + // the same actor operation so a concurrent refresh cannot be lost. + let previousCredential = try await credentialStore.swapCredential( + credential, + for: environment.id + ) + do { + try await environmentStore.upsert(environment) + if try await environmentStore.activeEnvironmentID() == nil { + try await environmentStore.setActiveEnvironment(id: environment.id) + } + } catch { + if let previousCredential { + _ = try? await credentialStore.replaceCredential( + previousCredential, + ifMatching: credential, + for: environment.id + ) + } else { + _ = try? await credentialStore.removeCredential( + ifMatching: credential, + for: environment.id + ) + } + throw error + } + return environment + } + + private func exchange( + target: PairingTarget, + clientLabel: String? + ) async throws -> TokenExchangeResult { + var fields = [ + URLQueryItem( + name: "grant_type", + value: "urn:ietf:params:oauth:grant-type:token-exchange" + ), + URLQueryItem(name: "subject_token", value: target.credential), + URLQueryItem( + name: "subject_token_type", + value: "urn:t3:params:oauth:token-type:environment-bootstrap" + ), + URLQueryItem( + name: "requested_token_type", + value: "urn:ietf:params:oauth:token-type:access_token" + ), + URLQueryItem(name: "client_device_type", value: "mobile"), + URLQueryItem(name: "client_os", value: "iOS"), + URLQueryItem(name: "client_surface", value: "mobile"), + ] + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + !appVersion.isEmpty { + fields.append(URLQueryItem(name: "client_app_version", value: appVersion)) + } + if let clientLabel, !clientLabel.isEmpty { + fields.append(URLQueryItem(name: "client_label", value: clientLabel)) + } + var form = URLComponents() + form.queryItems = fields + var request = URLRequest(url: endpoint(target.httpBaseURL, path: "/oauth/token")) + request.httpMethod = "POST" + request.httpBody = form.percentEncodedQuery?.data(using: .utf8) + request.setValue( + "application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type" + ) + let (data, response) = try await transport.data(for: HTTPRequestPolicy.prepare(request)) + guard (200..<300).contains(response.statusCode) else { + let body = try? JSONDecoder.t3.decode(JSONValue.self, from: data) + throw HTTPError.status( + response.statusCode, + message: body?["reason"]?.stringValue ?? "Pairing failed.", + traceID: body?["traceId"]?.stringValue + ) + } + return try JSONDecoder.t3.decode(TokenExchangeResult.self, from: data) + } +} diff --git a/apps/swift-ios/Core/PairingURL.swift b/apps/swift-ios/Core/PairingURL.swift new file mode 100644 index 000000000000..d07b87a51705 --- /dev/null +++ b/apps/swift-ios/Core/PairingURL.swift @@ -0,0 +1,302 @@ +import Foundation + +public struct PairingTarget: Equatable, Sendable { + public let credential: String + public let httpBaseURL: URL + public let webSocketBaseURL: URL +} + +/// Display fields produced from pasted text or a scanned QR payload. +public struct PairingInputFields: Equatable, Sendable { + public let host: String + public let pairingCode: String + public let label: String? +} + +public enum PairingURLError: LocalizedError, Equatable { + case emptyInput + case invalidURL + case unsupportedScheme + case missingToken + case missingHost + case emptyQRCode + case invalidQRCode + + public var errorDescription: String? { + switch self { + case .emptyInput: "Enter a server address." + case .invalidURL: "Pairing URL is invalid." + case .unsupportedScheme: "Pairing URL uses an unsupported scheme." + case .missingToken: "Pairing URL is missing its token." + case .missingHost: "Pairing URL is missing its environment host." + case .emptyQRCode: "Scanned QR code did not contain a pairing URL." + case .invalidQRCode: "Scanned QR code is not a T3 pairing link." + } + } +} + +public enum PairingURL { + private static let supportedSchemes = Set(["http", "https", "ws", "wss"]) + + /// Resolves a complete pairing link from a clipboard, universal link, or + /// QR scanner. `t3code://pair?pairingUrl=...` wrappers are unwrapped. + public static func resolve(_ rawValue: String) throws -> PairingTarget { + let extracted = try extractPairingURL(from: rawValue, qrInput: false) + let fields = try parseFields(extracted) + return try directTarget(host: fields.host, credential: requireToken(fields.pairingCode)) + } + + /// Resolves split form fields. If the host field contains a complete + /// pairing URL, its embedded token wins. This lets pasting a full link into + /// the host field immediately populate both inputs. + public static func resolve(host: String, pairingCode: String) throws -> PairingTarget { + let fields = try parseFields(host) + let embeddedCode = fields.pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + let code = embeddedCode.isEmpty ? pairingCode : embeddedCode + return try directTarget(host: fields.host, credential: requireToken(code)) + } + + /// Splits a complete URL or loose `host code` connection string into the + /// two fields shown by onboarding. + public static func parseFields(_ rawValue: String) throws -> PairingInputFields { + let extracted = try extractPairingURL(from: rawValue, qrInput: false) + + if let loose = looseHostAndCode(extracted) { + let normalized = try normalizedBaseURL(loose.host) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: loose.code, + label: nil + ) + } + + guard let components = strictURLComponents(extracted) else { + // Bare hosts are valid form input even before the code is entered. + let normalized = try normalizedBaseURL(extracted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: "", + label: nil + ) + } + try requireSupportedScheme(components.scheme) + + let query = components.queryItems ?? [] + let fragment = queryItems(fromFragment: components.fragment) + let token = (fragment + query) + .first(where: { $0.name.caseInsensitiveCompare("token") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = query + .first(where: { $0.name.caseInsensitiveCompare("label") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let hosted = query + .first(where: { $0.name.caseInsensitiveCompare("host") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines), + !hosted.isEmpty + { + let normalized = try normalizedBaseURL(hosted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: token, + label: label?.isEmpty == false ? label : nil + ) + } + + guard components.host != nil else { throw PairingURLError.missingHost } + let normalized = try normalizedBaseURL(extracted) + return PairingInputFields( + host: displayHost(normalized), + pairingCode: token, + label: label?.isEmpty == false ? label : nil + ) + } + + /// Extracts a pairing URL from a QR payload. Native deep links generated + /// by the React Native client are accepted alongside ordinary URLs. + public static func pairingURL(fromQRCode payload: String) throws -> String { + try extractPairingURL(from: payload, qrInput: true) + } + + public static func build(host: String, pairingCode: String) throws -> String { + let base = try normalizedBaseURL(host) + var components = URLComponents(url: base, resolvingAgainstBaseURL: false)! + components.path = "/pair" + components.percentEncodedFragment = URLComponents().withQueryItems([ + URLQueryItem(name: "token", value: try requireToken(pairingCode)), + ]).percentEncodedQuery + guard let value = components.url?.absoluteString else { + throw PairingURLError.invalidURL + } + return value + } + + /// Converts any supported pairing transport into the HTTP origin used by + /// onboarding's environment probe. + static func httpBaseURL(for rawValue: String) throws -> URL { + try httpBaseURL(from: normalizedBaseURL(rawValue)) + } + + private static func extractPairingURL( + from rawValue: String, + qrInput: Bool + ) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw qrInput ? PairingURLError.emptyQRCode : PairingURLError.emptyInput + } + + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == "t3code" + else { + return trimmed + } + let wrapped = components.queryItems? + .first(where: { $0.name.caseInsensitiveCompare("pairingUrl") == .orderedSame })? + .value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !wrapped.isEmpty else { + throw qrInput ? PairingURLError.invalidQRCode : PairingURLError.invalidURL + } + return wrapped + } + + private static func looseHostAndCode(_ value: String) -> (host: String, code: String)? { + // URLs legitimately contain percent-encoded or query whitespace, so + // only use this fallback when the last whitespace-delimited token + // looks like a compact pairing code. + let fields = value.split(whereSeparator: \.isWhitespace).map(String.init) + guard fields.count >= 2, let code = fields.last, + code.range(of: #"^[A-Za-z0-9_-]{4,256}$"#, options: .regularExpression) != nil + else { + return nil + } + let host = fields.dropLast().joined(separator: " ") + guard host.contains(".") || host.contains(":") || host.hasPrefix("/") else { + return nil + } + return (host, code) + } + + private static func directTarget(host: String, credential: String) throws -> PairingTarget { + try target(baseURL: normalizedBaseURL(host), credential: credential) + } + + private static func normalizedBaseURL(_ rawValue: String) throws -> URL { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw PairingURLError.emptyInput } + + let withoutLeadingSlashes = trimmed.replacingOccurrences( + of: #"^/+"#, + with: "", + options: .regularExpression + ) + let normalized: String + if withoutLeadingSlashes.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression + ) != nil { + normalized = withoutLeadingSlashes + } else { + normalized = "https://\(withoutLeadingSlashes)" + } + guard var components = URLComponents(string: normalized), + components.url != nil + else { + throw PairingURLError.invalidURL + } + try requireSupportedScheme(components.scheme) + guard components.host != nil else { throw PairingURLError.missingHost } + components.path = "/" + components.query = nil + components.fragment = nil + guard let base = components.url else { throw PairingURLError.invalidURL } + return base + } + + private static func strictURLComponents(_ value: String) -> URLComponents? { + guard value.range( + of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, + options: .regularExpression + ) != nil else { + return nil + } + return URLComponents(string: value) + } + + private static func queryItems(fromFragment fragment: String?) -> [URLQueryItem] { + guard let fragment, !fragment.isEmpty else { return [] } + var components = URLComponents() + components.percentEncodedQuery = fragment + return components.queryItems ?? [] + } + + private static func displayHost(_ url: URL) -> String { + var value = url.absoluteString + if value.hasSuffix("/") { value.removeLast() } + return value + } + + private static func target(baseURL: URL, credential: String) throws -> PairingTarget { + guard var http = URLComponents(url: baseURL, resolvingAgainstBaseURL: false), + var socket = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { + throw PairingURLError.invalidURL + } + switch http.scheme?.lowercased() { + case "ws": http.scheme = "http" + case "wss": http.scheme = "https" + default: break + } + switch socket.scheme?.lowercased() { + case "http": socket.scheme = "ws" + case "https": socket.scheme = "wss" + default: break + } + guard let httpURL = http.url, let socketURL = socket.url else { + throw PairingURLError.invalidURL + } + return PairingTarget( + credential: credential, + httpBaseURL: httpURL, + webSocketBaseURL: socketURL + ) + } + + private static func httpBaseURL(from baseURL: URL) throws -> URL { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { + throw PairingURLError.invalidURL + } + switch components.scheme?.lowercased() { + case "ws": components.scheme = "http" + case "wss": components.scheme = "https" + default: break + } + guard let url = components.url else { throw PairingURLError.invalidURL } + return url + } + + private static func requireSupportedScheme(_ scheme: String?) throws { + guard supportedSchemes.contains(scheme?.lowercased() ?? "") else { + throw PairingURLError.unsupportedScheme + } + } + + private static func requireToken(_ token: String?) throws -> String { + let trimmed = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { throw PairingURLError.missingToken } + return trimmed + } +} + +private extension URLComponents { + func withQueryItems(_ items: [URLQueryItem]) -> URLComponents { + var copy = self + copy.queryItems = items + return copy + } +} diff --git a/apps/swift-ios/Core/Persistence.swift b/apps/swift-ios/Core/Persistence.swift new file mode 100644 index 000000000000..cadcd3781cc2 --- /dev/null +++ b/apps/swift-ios/Core/Persistence.swift @@ -0,0 +1,352 @@ +import Foundation +import Security + +public protocol CredentialStore: Sendable { + func credential(for environmentID: String) async throws -> EnvironmentCredential? + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) async throws + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) async throws -> EnvironmentCredential? + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool + func removeCredential(for environmentID: String) async throws + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool +} + +protocol KeychainCredentialBackend: Sendable { + func credential(for environmentID: String) throws -> EnvironmentCredential? + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) throws + func removeCredential(for environmentID: String) throws +} + +public enum CredentialStoreError: LocalizedError, Sendable { + case keychain(OSStatus) + case invalidData + + public var errorDescription: String? { + switch self { + case let .keychain(status): + SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error \(status)." + case .invalidData: + "The saved environment credential is invalid." + } + } +} + +/// Access tokens are deliberately isolated from the environment catalog so +/// catalog exports and backups never contain authentication material. +public actor KeychainCredentialStore: CredentialStore { + private static let keychainLock = NSLock() + private let service: String + private let accessibility: CFString + private let backend: (any KeychainCredentialBackend)? + + public init( + service: String = "codes.t3.swift-ios.environment-credentials", + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + backend = nil + } + + init( + service: String, + backend: any KeychainCredentialBackend, + accessibility: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ) { + self.service = service + self.accessibility = accessibility + self.backend = backend + } + + public func credential(for environmentID: String) throws -> EnvironmentCredential? { + try Self.keychainLock.withLock { + try readCredential(for: environmentID) + } + } + + public func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws { + try Self.keychainLock.withLock { + try writeCredential(credential, for: environmentID) + } + } + + public func removeCredential(for environmentID: String) throws { + try Self.keychainLock.withLock { + try deleteCredential(for: environmentID) + } + } + + public func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws -> EnvironmentCredential? { + try Self.keychainLock.withLock { + let previousCredential = try readCredential(for: environmentID) + try writeCredential(credential, for: environmentID) + return previousCredential + } + } + + public func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) throws -> Bool { + try Self.keychainLock.withLock { + guard try readCredential(for: environmentID) == expected else { return false } + try writeCredential(credential, for: environmentID) + return true + } + } + + public func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) throws -> Bool { + try Self.keychainLock.withLock { + guard try readCredential(for: environmentID) == expected else { return false } + try deleteCredential(for: environmentID) + return true + } + } + + private func readCredential(for environmentID: String) throws -> EnvironmentCredential? { + if let backend { + return try backend.credential(for: environmentID) + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw CredentialStoreError.keychain(status) } + guard let data = item as? Data else { throw CredentialStoreError.invalidData } + do { + return try JSONDecoder.t3.decode(EnvironmentCredential.self, from: data) + } catch { + throw CredentialStoreError.invalidData + } + } + + private func writeCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) throws { + if let backend { + try backend.setCredential(credential, for: environmentID) + return + } + let data = try JSONEncoder.t3.encode(credential) + let lookup: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: accessibility, + ] + let updateStatus = SecItemUpdate(lookup as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecItemNotFound { + var insertion = lookup + attributes.forEach { insertion[$0.key] = $0.value } + let status = SecItemAdd(insertion as CFDictionary, nil) + guard status == errSecSuccess else { throw CredentialStoreError.keychain(status) } + } else if updateStatus != errSecSuccess { + throw CredentialStoreError.keychain(updateStatus) + } + } + + private func deleteCredential(for environmentID: String) throws { + if let backend { + try backend.removeCredential(for: environmentID) + return + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: environmentID, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialStoreError.keychain(status) + } + } +} + +public actor InMemoryCredentialStore: CredentialStore { + private var credentials: [String: EnvironmentCredential] + + public init(credentials: [String: EnvironmentCredential] = [:]) { + self.credentials = credentials + } + + public func credential(for environmentID: String) -> EnvironmentCredential? { + credentials[environmentID] + } + + public func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + credentials[environmentID] = credential + } + + public func removeCredential(for environmentID: String) { + credentials.removeValue(forKey: environmentID) + } + + public func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + credentials.updateValue(credential, forKey: environmentID) + } + + public func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard credentials[environmentID] == expected else { return false } + credentials[environmentID] = credential + return true + } + + public func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard credentials[environmentID] == expected else { return false } + credentials.removeValue(forKey: environmentID) + return true + } +} + +public actor EnvironmentStore { + private struct Document: Codable { + let version: Int + var environments: [Environment] + var activeEnvironmentID: String? + } + + public let fileURL: URL + + /// Snapshot publishes read the catalog several times a second, so the + /// decoded document is cached and invalidated by writes on this actor. + private var cached: Document? + + public init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("environments.json", isDirectory: false) + } + } + + public func load() throws -> [Environment] { + try loadDocument().environments + } + + public func activeEnvironmentID() throws -> String? { + try loadDocument().activeEnvironmentID + } + + public func setActiveEnvironment(id: String?) throws { + var document = try loadDocument() + document.activeEnvironmentID = id + try save(document) + } + + @discardableResult + public func setEnabled(id: String, enabled: Bool) throws -> [Environment] { + var document = try loadDocument() + guard let index = document.environments.firstIndex(where: { $0.id == id }) else { + return document.environments + } + document.environments[index].isEnabled = enabled + if !enabled, document.activeEnvironmentID == id { + document.activeEnvironmentID = document.environments.first { + $0.isEnabled && $0.id != id + }?.id + } + try save(document) + return document.environments + } + + public func save(_ environments: [Environment]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var document = try loadDocument() + document.environments = environments + try save(document) + } + + @discardableResult + public func upsert(_ environment: Environment) throws -> [Environment] { + var environments = try load() + if let index = environments.firstIndex(where: { $0.id == environment.id }) { + environments[index] = environment + } else { + environments.append(environment) + } + try save(environments) + return environments + } + + @discardableResult + public func remove(id: String) throws -> [Environment] { + var document = try loadDocument() + document.environments.removeAll { $0.id == id } + if document.activeEnvironmentID == id { + document.activeEnvironmentID = document.environments.first(where: \.isEnabled)?.id + } + try save(document) + return document.environments + } + + private func loadDocument() throws -> Document { + if let cached { return cached } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return Document(version: 1, environments: [], activeEnvironmentID: nil) + } + let data = try Data(contentsOf: fileURL) + let document = try JSONDecoder.t3.decode(Document.self, from: data) + cached = document + return document + } + + private func save(_ document: Document) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try JSONEncoder.t3.encode(document).write(to: fileURL, options: .atomic) + cached = document + } +} diff --git a/apps/swift-ios/Core/PrismWireModels.swift b/apps/swift-ios/Core/PrismWireModels.swift new file mode 100644 index 000000000000..bf345c46beb2 --- /dev/null +++ b/apps/swift-ios/Core/PrismWireModels.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct PrismAccount: Decodable, Identifiable, Sendable { + public let id: String + public let provider: String + public let label: String + public let email: String? + public let disabled: Bool + public let weight: Int? + public let lifecycle: PrismAccountLifecycle? +} + +public struct PrismAccountLifecycle: Decodable, Sendable { + public let status: String? + public let unavailable: Bool? + public let expiresAt: String? + public let lastRefreshedAt: String? + public let refreshNotBefore: String? + public let retryAt: String? + public let lastErrorStatus: Int? + public let requiresLogin: Bool? +} + +/// Additive responses keep carried clients compatible with older gateways. +public struct PrismResponse: Decodable, Sendable { + public let state: String? + public let role: String? + public let version: String? + public let lastError: String? + public let lastSyncError: String? + public let accounts: [PrismAccount]? + public let sessionId: String? + public let authUrl: String? + public let flow: String? + public let userCode: String? + public let status: String? + public let strategy: String? +} + +public struct PrismRequest: Sendable { + public let path: String + public let method: String + public let body: [String: JSONValue]? + + public init(_ path: String, method: String = "GET", body: [String: JSONValue]? = nil) { + self.path = path + self.method = method + self.body = body + } + + public static func component(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "" + } +} diff --git a/apps/swift-ios/Core/PullRequestWireModels.swift b/apps/swift-ios/Core/PullRequestWireModels.swift new file mode 100644 index 000000000000..a7303e64499d --- /dev/null +++ b/apps/swift-ios/Core/PullRequestWireModels.swift @@ -0,0 +1,525 @@ +import Foundation + +public enum PullRequestInvolvement: String, Codable, CaseIterable, Sendable { + case all + case reviewing + case authored +} + +public enum PullRequestState: String, Codable, CaseIterable, Sendable { + case open + case closed + case merged +} + +public enum PullRequestListState: String, Codable, CaseIterable, Sendable { + case all + case open + case closed + case merged +} + +public enum PullRequestReviewDecision: String, Codable, Sendable { + case approved + case changesRequested = "changes-requested" + case reviewRequired = "review-required" +} + +public enum PullRequestChecksState: String, Codable, Sendable { + case passing + case failing + case pending +} + +public enum PullRequestMergeability: String, Codable, Sendable { + case mergeable + case conflicting + case unknown +} + +public enum PullRequestAction: String, Codable, CaseIterable, Sendable { + case merge + case ready + case draft + case close + case reopen + case updateBranch = "update-branch" + case enableAutoMerge = "enable-auto-merge" + case disableAutoMerge = "disable-auto-merge" +} + +public enum PullRequestMergeMethod: String, Codable, CaseIterable, Sendable { + case merge + case squash + case rebase +} + +public enum PullRequestUpdateMethod: String, Codable, CaseIterable, Sendable { + case merge + case rebase +} + +public enum PullRequestBaseComparison: String, Codable, Sendable { + case upToDate = "up-to-date" + case behind + case unknown +} + +public struct PullRequestActor: Codable, Equatable, Sendable { + public let login: String + public let name: String? + public let avatarUrl: String? +} + +public struct PullRequestLabel: Codable, Equatable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let color: String? +} + +public enum PullRequestCheckStatus: String, Codable, Sendable { + case pending + case success + case failure + case skipped + case neutral + case cancelled +} + +public struct PullRequestCheck: Codable, Equatable, Sendable, Identifiable { + public var id: String { name } + public let name: String + public let status: PullRequestCheckStatus + public let description: String? + public let url: String? +} + +public enum PullRequestReactionContent: String, Codable, CaseIterable, Sendable { + case thumbsUp = "thumbs-up" + case thumbsDown = "thumbs-down" + case laugh + case hooray + case confused + case heart + case rocket + case eyes +} + +public struct PullRequestReaction: Codable, Equatable, Sendable, Identifiable { + public var id: PullRequestReactionContent { content } + public let content: PullRequestReactionContent + public let count: Int + public let actors: [String] + public let viewerHasReacted: Bool +} + +public enum PullRequestCommentKind: String, Codable, Sendable { + case issueComment = "issue-comment" + case reviewComment = "review-comment" + case review +} + +public struct PullRequestComment: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let kind: PullRequestCommentKind + public let author: PullRequestActor? + public let body: String + public let createdAt: String + public let url: String? + public let path: String? + public let reviewState: String? + public let reactions: [PullRequestReaction]? +} + +public enum PullRequestDiffSide: String, Codable, Sendable { + case left + case right +} + +public enum PullRequestReviewVerdict: String, Codable, CaseIterable, Sendable { + case comment + case approve + case requestChanges = "request-changes" +} + +public struct PullRequestThreadComment: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let author: PullRequestActor? + public let body: String + public let createdAt: String + public let url: String? + public let reactions: [PullRequestReaction]? +} + +public struct PullRequestReviewThread: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let path: String + public let line: Int? + public let side: PullRequestDiffSide + public let isResolved: Bool + public let isOutdated: Bool + public let comments: [PullRequestThreadComment] + public let commentCount: Int? + public let nextCommentsCursor: String? +} + +public struct PullRequestCommit: Codable, Equatable, Sendable, Identifiable { + public var id: String { oid } + public let oid: String + public let messageHeadline: String + public let committedDate: String + public let additions: Int? + public let deletions: Int? + public let authors: [PullRequestActor]? +} + +public struct PullRequestReviewCapabilities: Codable, Equatable, Sendable { + public let inlineComment: Bool + public let reply: Bool + public let resolve: Bool + public let verdicts: [PullRequestReviewVerdict] +} + +public struct PullRequestEditCapabilities: Codable, Equatable, Sendable { + public let changeRequest: Bool + public let comment: Bool +} + +public struct PullRequestReviewerCapabilities: Codable, Equatable, Sendable { + public let request: Bool + public let listCandidates: Bool +} + +public struct PullRequestCapabilities: Codable, Equatable, Sendable { + public let diff: Bool + public let comment: Bool + public let actions: [PullRequestAction] + public let mergeMethods: [PullRequestMergeMethod] + public let updateMethods: [PullRequestUpdateMethod]? + public let search: Bool + public let reactions: Bool? + public let review: PullRequestReviewCapabilities + public let reviewers: PullRequestReviewerCapabilities + public let edit: PullRequestEditCapabilities? +} + +public struct PullRequestViewerPermissions: Codable, Equatable, Sendable { + public let actions: [PullRequestAction] + public let comment: Bool + public let resolve: Bool + public let verdicts: [PullRequestReviewVerdict] + public let requestReviewers: Bool + public let updateMethods: [PullRequestUpdateMethod]? +} + +public struct PullRequestMergeCapabilities: Codable, Equatable, Sendable { + public let merge: Bool + public let squash: Bool + public let rebase: Bool +} + +public struct PullRequestListFilters: Codable, Equatable, Sendable { + public let draft: String? + public let review: String? + public let checks: String? + public let labels: [[String]]? + public let excludedLabels: [String]? + public let author: String? + + public init( + draft: String? = nil, + review: String? = nil, + checks: String? = nil, + labels: [[String]]? = nil, + excludedLabels: [String]? = nil, + author: String? = nil + ) { + self.draft = draft + self.review = review + self.checks = checks + self.labels = labels + self.excludedLabels = excludedLabels + self.author = author + } +} + +public struct PullRequestListInput: Codable, Equatable, Sendable { + public let state: PullRequestListState + public let involvement: PullRequestInvolvement? + public let filters: PullRequestListFilters? + public let projectId: String? + public let projectIds: [String]? + public let host: String? + public let limit: Int? + public let cursors: [String: String]? + public let query: String? + + public init( + state: PullRequestListState = .open, + involvement: PullRequestInvolvement? = .all, + filters: PullRequestListFilters? = nil, + projectId: String? = nil, + projectIds: [String]? = nil, + host: String? = nil, + limit: Int? = 99, + cursors: [String: String]? = nil, + query: String? = nil + ) { + self.state = state + self.involvement = involvement + self.filters = filters + self.projectId = projectId + self.projectIds = projectIds + self.host = host + self.limit = limit + self.cursors = cursors + self.query = query + } +} + +public struct PullRequestListEntry: Codable, Equatable, Sendable, Identifiable { + public var id: String { "\(host) \(repository)#\(number)" } + public let provider: SourceControlProviderKind + public let host: String + public let projectId: String + public let projectTitle: String + public let repository: String + public let number: Int + public let title: String + public let url: String + public let author: PullRequestActor? + public let headBranch: String + public let baseBranch: String + public let state: PullRequestState + public let isDraft: Bool + public let mergeability: PullRequestMergeability + public let additions: Int + public let deletions: Int + public let createdAt: String + public let updatedAt: String + public let viewerReviewRequested: Bool + public let labels: [PullRequestLabel] + public let reviewDecision: PullRequestReviewDecision? + public let checksState: PullRequestChecksState? +} + +public struct PullRequestProviderSummary: Codable, Equatable, Sendable { + public let host: String + public let kind: SourceControlProviderKind + public let searchesOnHost: Bool + public let projectCount: Int + public let configured: Bool + public let detail: String? +} + +public struct PullRequestListProjectError: Codable, Equatable, Sendable, Identifiable { + public var id: String { projectId } + public let projectId: String + public let projectTitle: String + public let message: String +} + +public struct PullRequestListResult: Codable, Equatable, Sendable { + public let viewers: [String: String] + public let providers: [PullRequestProviderSummary] + public let entries: [PullRequestListEntry] + public let errors: [PullRequestListProjectError] + public let truncated: Bool + public let nextCursors: [String: String] + + func appending(_ page: Self) -> Self { + var entryIDs = Set(entries.map(\.id)) + var providerHosts = Set(providers.map(\.host)) + var errorProjectIDs = Set(errors.map(\.projectId)) + + return Self( + viewers: viewers.merging(page.viewers) { _, latest in latest }, + providers: providers + page.providers.filter { + providerHosts.insert($0.host).inserted + }, + entries: entries + page.entries.filter { + entryIDs.insert($0.id).inserted + }, + errors: errors + page.errors.filter { + errorProjectIDs.insert($0.projectId).inserted + }, + truncated: page.truncated, + nextCursors: page.nextCursors + ) + } +} + +public struct PullRequestRef: Codable, Equatable, Hashable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + + public init(projectId: String, repository: String, number: Int) { + self.projectId = projectId + self.repository = repository + self.number = number + } + + var jsonObject: [String: JSONValue] { + get throws { + guard case let .object(value) = try JSONValue.encode(self) else { return [:] } + return value + } + } +} + +public struct PullRequestDetail: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let capabilities: PullRequestCapabilities + public let viewerPermissions: PullRequestViewerPermissions + public let projectId: String + public let projectTitle: String + public let workspaceRoot: String + public let repository: String + public let number: Int + public let title: String + public let body: String + public let url: String + public let author: PullRequestActor? + public let state: PullRequestState + public let isDraft: Bool + public let mergeability: PullRequestMergeability + public let additions: Int + public let deletions: Int + public let changedFiles: Int + public let headBranch: String + public let baseBranch: String + public let createdAt: String + public let updatedAt: String + public let mergedAt: String? + public let closedAt: String? + public let reviewers: [PullRequestActor] + public let labels: [PullRequestLabel] + public let checks: [PullRequestCheck] + public let mergeCapabilities: PullRequestMergeCapabilities + public let viewer: String? + public let baseComparison: PullRequestBaseComparison? + public let behindBy: Int? + public let autoMergeEnabled: Bool? +} + +public struct PullRequestActivity: Codable, Equatable, Sendable { + public let author: PullRequestActor? + public let reviewers: [PullRequestActor]? + public let comments: [PullRequestComment] + public let commentCount: Int + public let commentsTruncated: Bool + public let reviewThreads: [PullRequestReviewThread] + public let commits: [PullRequestCommit] + public let reactions: [PullRequestReaction]? +} + +public struct PullRequestDiffInput: Codable, Equatable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + public let cursor: String? + public let commit: String? +} + +public struct PullRequestOmittedFileStat: Codable, Equatable, Sendable { + public let path: String + public let additions: Double + public let deletions: Double +} + +public struct PullRequestDiffResult: Codable, Equatable, Sendable { + public let patch: String + public let truncated: Bool + public let nextCursor: String? + public let omittedFileStats: [PullRequestOmittedFileStat]? +} + +public struct PullRequestReviewPosition: Codable, Equatable, Sendable { + public let kind: String + public let newLine: Int? + public let oldLine: Int? + public let side: PullRequestDiffSide? + + public static func added(_ line: Int) -> Self { + .init(kind: "added", newLine: line, oldLine: nil, side: nil) + } + + public static func deleted(_ line: Int) -> Self { + .init(kind: "deleted", newLine: nil, oldLine: line, side: nil) + } + + public static func context(old: Int, new: Int, side: PullRequestDiffSide) -> Self { + .init(kind: "context", newLine: new, oldLine: old, side: side) + } +} + +public struct PullRequestReviewCommentDraft: Codable, Equatable, Sendable, Identifiable { + public var id = UUID() + public let path: String + public let oldPath: String? + public let position: PullRequestReviewPosition + public var body: String + + enum CodingKeys: String, CodingKey { case path, oldPath, position, body } + + public init( + id: UUID = UUID(), + path: String, + oldPath: String? = nil, + position: PullRequestReviewPosition, + body: String + ) { + self.id = id + self.path = path + self.oldPath = oldPath + self.position = position + self.body = body + } +} + +public struct PullRequestReviewerCandidate: Codable, Equatable, Sendable, Identifiable { + public let login: String + public let name: String? + public let avatarUrl: String? + public let id: String + public let kind: String + public let isRequested: Bool +} + +public struct PullRequestReviewerCandidateList: Codable, Equatable, Sendable { + public let candidates: [PullRequestReviewerCandidate] + public let truncated: Bool +} + +public struct FeaturePullRequestEnvironmentList: Identifiable, Equatable, Sendable { + public var id: String { environmentID } + public let environmentID: String + public let environmentName: String + public let result: PullRequestListResult? + public let errorMessage: String? + + public init( + environmentID: String, + environmentName: String, + result: PullRequestListResult?, + errorMessage: String? + ) { + self.environmentID = environmentID + self.environmentName = environmentName + self.result = result + self.errorMessage = errorMessage + } +} + +public struct FeaturePullRequestTarget: Hashable, Sendable { + public let environmentID: String + public let environmentName: String + public let reference: PullRequestRef + + public init(environmentID: String, environmentName: String, reference: PullRequestRef) { + self.environmentID = environmentID + self.environmentName = environmentName + self.reference = reference + } +} diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift new file mode 100644 index 000000000000..dc486d8d4da3 --- /dev/null +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -0,0 +1,327 @@ +import Foundation + +public struct ServerProviderAuthSnapshot: Codable, Equatable, Sendable { + public let status: String + public let type: String? + public let label: String? + public let email: String? +} + +public struct ServerProviderOptionChoice: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let isDefault: Bool? +} + +public struct ServerSelectOptionDescriptor: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let options: [ServerProviderOptionChoice] + public let currentValue: String? + public let promptInjectedValues: [String]? +} + +public struct ServerBooleanOptionDescriptor: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let description: String? + public let currentValue: Bool? +} + +public enum ServerProviderOptionDescriptor: Codable, Equatable, Sendable { + case select(ServerSelectOptionDescriptor) + case boolean(ServerBooleanOptionDescriptor) + + private enum CodingKeys: String, CodingKey { case type } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "select": + self = .select(try ServerSelectOptionDescriptor(from: decoder)) + case "boolean": + self = .boolean(try ServerBooleanOptionDescriptor(from: decoder)) + case let type: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown provider option type \(type)" + ) + } + } + + public func encode(to encoder: any Encoder) throws { + switch self { + case let .select(value): + try value.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("select", forKey: .type) + case let .boolean(value): + try value.encode(to: encoder) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("boolean", forKey: .type) + } + } +} + +public struct ServerModelCapabilities: Codable, Equatable, Sendable { + public let optionDescriptors: [ServerProviderOptionDescriptor]? +} + +public struct ServerProviderModelSnapshot: Codable, Identifiable, Equatable, Sendable { + public var id: String { slug } + + public let slug: String + public let name: String + public let shortName: String? + public let subProvider: String? + public let isCustom: Bool + public let isDefault: Bool? + public let isLegacy: Bool? + public let capabilities: ServerModelCapabilities? +} + +public struct ServerProviderSlashCommandSnapshot: Codable, Equatable, Sendable { + public struct Input: Codable, Equatable, Sendable { + public let hint: String + } + + public let name: String + public let description: String? + public let input: Input? +} + +public struct ServerProviderSkillSnapshot: Codable, Equatable, Sendable { + public let name: String + public let description: String? + public let path: String + public let scope: String? + public let enabled: Bool + public let displayName: String? + public let shortDescription: String? +} + +public struct ServerProviderSnapshot: Codable, Identifiable, Equatable, Sendable { + public var id: String { instanceId } + + public let instanceId: String + public let driver: String + public let displayName: String? + public let accentColor: String? + public let badgeLabel: String? + public let showInteractionModeToggle: Bool? + public let requiresNewThreadForModelChange: Bool? + public let enabled: Bool + public let installed: Bool + public let version: String? + public let status: String + public let auth: ServerProviderAuthSnapshot + public let checkedAt: String + public let message: String? + public let availability: String? + public let unavailableReason: String? + public let models: [ServerProviderModelSnapshot] + public let slashCommands: [ServerProviderSlashCommandSnapshot]? + public let skills: [ServerProviderSkillSnapshot]? +} + +public enum ServerThreadEnvironmentMode: String, Codable, Equatable, Sendable { + case local + case worktree +} + +public enum ServerProjectGroupingMode: String, Codable, Equatable, Sendable { + case repository + case repositoryPath = "repository_path" + case separate +} + +/// New-thread preferences are server-authoritative, so every saved environment +/// can resolve these differently even though they share one mobile client. +public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + public let defaultThreadEnvMode: ServerThreadEnvironmentMode + public let newWorktreesStartFromOrigin: Bool + public let sidebarProjectGroupingMode: ServerProjectGroupingMode? + public let sidebarProjectGroupingOverrides: [String: ServerProjectGroupingMode]? + public let sidebarAutoSettleOnMerge: Bool + public let sidebarAutoSettleAfterDays: Double? + + public init( + defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, + newWorktreesStartFromOrigin: Bool = true, + sidebarProjectGroupingMode: ServerProjectGroupingMode? = nil, + sidebarProjectGroupingOverrides: [String: ServerProjectGroupingMode]? = nil, + sidebarAutoSettleOnMerge: Bool = true, + sidebarAutoSettleAfterDays: Double? = 3 + ) { + self.defaultThreadEnvMode = defaultThreadEnvMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.sidebarProjectGroupingMode = sidebarProjectGroupingMode + self.sidebarProjectGroupingOverrides = sidebarProjectGroupingOverrides + self.sidebarAutoSettleOnMerge = sidebarAutoSettleOnMerge + self.sidebarAutoSettleAfterDays = sidebarAutoSettleAfterDays + } + + private enum CodingKeys: String, CodingKey { + case defaultThreadEnvMode + case newWorktreesStartFromOrigin + case sidebarProjectGroupingMode + case sidebarProjectGroupingOverrides + case sidebarAutoSettleOnMerge + case sidebarAutoSettleAfterDays + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultThreadEnvMode = try container.decodeIfPresent( + ServerThreadEnvironmentMode.self, + forKey: .defaultThreadEnvMode + ) ?? .local + newWorktreesStartFromOrigin = try container.decodeIfPresent( + Bool.self, + forKey: .newWorktreesStartFromOrigin + ) ?? true + sidebarProjectGroupingMode = try container.decodeIfPresent( + ServerProjectGroupingMode.self, + forKey: .sidebarProjectGroupingMode + ) + sidebarProjectGroupingOverrides = try container.decodeIfPresent( + [String: ServerProjectGroupingMode].self, + forKey: .sidebarProjectGroupingOverrides + ) + sidebarAutoSettleOnMerge = try container.decodeIfPresent( + Bool.self, + forKey: .sidebarAutoSettleOnMerge + ) ?? true + sidebarAutoSettleAfterDays = if container.contains(.sidebarAutoSettleAfterDays) { + try container.decodeIfPresent(Double.self, forKey: .sidebarAutoSettleAfterDays) + } else { + 3 + } + } +} + +public enum ServerSettingsChange: Equatable, Sendable { + case sidebarAutoSettleOnMerge(Bool) + case sidebarAutoSettleAfterDays(Double?) + + public var jsonValue: JSONValue { + switch self { + case let .sidebarAutoSettleOnMerge(value): + .object(["sidebarAutoSettleOnMerge": .bool(value)]) + case let .sidebarAutoSettleAfterDays(value): + .object(["sidebarAutoSettleAfterDays": value.map(JSONValue.number) ?? .null]) + } + } +} + +/// Narrow decode view of the much larger `ServerConfig` RPC result. +public struct ServerConfigSnapshot: Codable, Equatable, Sendable { + public let providers: [ServerProviderSnapshot] + public let settings: ServerSettingsSnapshot? + public let threadSnapshotPagination: Bool? + public let environment: EnvironmentDescriptor? + + public init( + providers: [ServerProviderSnapshot], + settings: ServerSettingsSnapshot? = nil, + threadSnapshotPagination: Bool? = nil, + environment: EnvironmentDescriptor? = nil + ) { + self.providers = providers + self.settings = settings + self.threadSnapshotPagination = threadSnapshotPagination + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case providers, settings, threadSnapshotPagination, environment + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + providers = try container.decode( + [LossyDecodableElement].self, + forKey: .providers + ).compactMap(\.value) + settings = try container.decodeIfPresent(ServerSettingsSnapshot.self, forKey: .settings) + threadSnapshotPagination = try container.decodeIfPresent( + Bool.self, + forKey: .threadSnapshotPagination + ) + environment = try container.decodeIfPresent(EnvironmentDescriptor.self, forKey: .environment) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(providers, forKey: .providers) + try container.encodeIfPresent(settings, forKey: .settings) + try container.encodeIfPresent( + threadSnapshotPagination, + forKey: .threadSnapshotPagination + ) + try container.encodeIfPresent(environment, forKey: .environment) + } +} + +private struct LossyDecodableElement: Decodable { + let value: Value? + + init(from decoder: any Decoder) throws { + value = try? Value(from: decoder) + } +} + +public enum ServerConfigStreamEvent: Decodable, Sendable { + case snapshot(ServerConfigSnapshot) + case providerStatuses([ServerProviderSnapshot]) + case settingsUpdated(ServerSettingsSnapshot) + case unrelated(type: String) + + private enum CodingKeys: String, CodingKey { case type, config, payload } + private struct ProviderPayload: Decodable { + let providers: [ServerProviderSnapshot] + + private enum CodingKeys: String, CodingKey { case providers } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + providers = try container.decode( + [LossyDecodableElement].self, + forKey: .providers + ).compactMap(\.value) + } + } + private struct SettingsPayload: Decodable { let settings: ServerSettingsSnapshot } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "snapshot": + self = .snapshot( + try container.decode(ServerConfigSnapshot.self, forKey: .config) + ) + case "providerStatuses": + self = .providerStatuses( + try container.decode(ProviderPayload.self, forKey: .payload).providers + ) + case "settingsUpdated": + self = .settingsUpdated( + try container.decode(SettingsPayload.self, forKey: .payload).settings + ) + default: + self = .unrelated(type: type) + } + } +} + +public struct ServerRefreshProvidersResult: Codable, Equatable, Sendable { + public let providers: [ServerProviderSnapshot] + + public init(providers: [ServerProviderSnapshot]) { + self.providers = providers + } +} diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift new file mode 100644 index 000000000000..e331b114c3e4 --- /dev/null +++ b/apps/swift-ios/Core/T3Client.swift @@ -0,0 +1,2229 @@ +import Foundation + +enum MobileClientMetadata { + static var osMajorVersion: Int { + ProcessInfo.processInfo.operatingSystemVersion.majorVersion + } + + static var deviceModel: String { + if let simulatedModel = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + !simulatedModel.isEmpty { + return simulatedModel + } + var system = utsname() + uname(&system) + let machineSize = MemoryLayout.size(ofValue: system.machine) + return withUnsafePointer(to: &system.machine) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: machineSize) { + String(cString: $0) + } + } + } +} + +public actor T3Client { + public let environment: Environment + private let api: EnvironmentAPI + private let rpc: WebSocketRPCClient + private let configSnapshotWaitTimeout: Duration + private var latestServerEnvironment: EnvironmentDescriptor? + private var serverConfigCache: ServerConfigSnapshot? + private var serverConfigGeneration: UInt64 = 0 + private var serverConfigTask: Task? + private var serverConfigWaiters: [UUID: CheckedContinuation] = [:] + private var serverConfigListeners: [UUID: AsyncThrowingStream.Continuation] = [:] + + public init( + environment: Environment, + credentialStore: any CredentialStore, + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector(), + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil, + rpcConnectionWaitTimeout: Duration = .seconds(4) + ) { + self.environment = environment + let api = EnvironmentAPI( + transport: httpTransport, + credentials: credentialStore, + managedAuthorization: managedAuthorization + ) + self.api = api + self.configSnapshotWaitTimeout = rpcConnectionWaitTimeout + self.rpc = WebSocketRPCClient( + connector: webSocketConnector, + connectionWaitTimeout: rpcConnectionWaitTimeout + ) { + let ticket = try await api.webSocketTicket(for: environment) + var components = URLComponents( + url: environment.webSocketBaseURL, + resolvingAgainstBaseURL: false + )! + if components.path.isEmpty || components.path == "/" { + components.path = "/ws" + } + var query = components.queryItems ?? [] + query.removeAll { + $0.name == "wsTicket" + || $0.name == "clientSurface" + || $0.name == "clientAppVersion" + || $0.name == "clientOs" + || $0.name == "clientOsMajorVersion" + || $0.name == "clientDeviceModel" + } + query.append(URLQueryItem(name: "wsTicket", value: ticket.ticket)) + query.append(URLQueryItem(name: "clientSurface", value: "mobile")) + query.append(URLQueryItem(name: "clientOs", value: "iOS")) + query.append(URLQueryItem( + name: "clientOsMajorVersion", + value: String(MobileClientMetadata.osMajorVersion) + )) + let deviceModel = MobileClientMetadata.deviceModel + if !deviceModel.isEmpty { + query.append(URLQueryItem(name: "clientDeviceModel", value: deviceModel)) + } + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + !appVersion.isEmpty { + query.append(URLQueryItem(name: "clientAppVersion", value: appVersion)) + } + components.queryItems = query + guard let url = components.url else { throw PairingURLError.invalidURL } + return url + } + } + + public func connect() async { + await rpc.start() + } + + public func disconnect() async { + stopServerConfigSubscription(error: RPCError.disconnected) + await rpc.stop() + } + + public func liveConnectionActive() async -> Bool { + await rpc.isConnected() + } + + public func shellSnapshot( + timeoutInterval: TimeInterval? = nil + ) async throws -> OrchestrationShellSnapshot { + try await api.shellSnapshot( + for: environment, + timeoutInterval: timeoutInterval + ) + } + + public func readModel() async throws -> OrchestrationReadModel { + try await api.readModel(for: environment) + } + + public func archivedShellSnapshot() async throws -> OrchestrationShellSnapshot { + try await rpc.request( + RPCMethod.getArchivedShellSnapshot.rawValue, + as: OrchestrationShellSnapshot.self + ) + } + + public func threadSnapshot( + id: String, + turnLimit: Int? = nil, + beforeCursor: String? = nil + ) async throws -> OrchestrationThreadDetailSnapshot { + try await api.threadSnapshot( + id: id, + environment: environment, + turnLimit: turnLimit, + beforeCursor: beforeCursor + ) + } + + public func serverConfig() async throws -> ServerConfigSnapshot { + if let serverConfigCache { return serverConfigCache } + startServerConfigSubscriptionIfNeeded() + return try await withThrowingTaskGroup(of: ServerConfigSnapshot.self) { group in + group.addTask { try await self.waitForServerConfigSnapshot() } + group.addTask { + try await Task.sleep(for: self.configSnapshotWaitTimeout) + throw RPCError.responseTimedOut + } + defer { group.cancelAll() } + return try await group.next()! + } + } + + public func updateSettings(_ change: ServerSettingsChange) async throws + -> ServerSettingsSnapshot + { + return try await rpc.request( + RPCMethod.serverUpdateSettings.rawValue, + payload: .object(["patch": change.jsonValue]), + as: ServerSettingsSnapshot.self + ) + } + + public func refreshProviders() async throws -> ServerConfigSnapshot { + let current = try await serverConfig() + let generation = serverConfigGeneration + let result: ServerRefreshProvidersResult = try await rpc.request( + RPCMethod.serverRefreshProviders.rawValue, + as: ServerRefreshProvidersResult.self + ) + guard generation == serverConfigGeneration else { throw CancellationError() } + let config = ServerConfigSnapshot( + providers: result.providers, + settings: current.settings, + threadSnapshotPagination: current.threadSnapshotPagination, + environment: current.environment + ) + cacheServerConfig(config) + serverConfigListeners.values.forEach { $0.yield(.snapshot(config)) } + return config + } + + public func prism(_ input: PrismRequest) async throws -> PrismResponse { + try await api.prism(input, environment: environment) + } + + public func usageSummary(_ input: UsageSummaryInput) async throws -> UsageSummary { + try await rpc.request( + RPCMethod.serverGetUsageSummary.rawValue, + payload: try JSONValue.encode(input), + as: UsageSummary.self + ) + } + + public func pullRequests(_ input: PullRequestListInput) async throws -> PullRequestListResult { + try await rpc.request( + RPCMethod.pullRequestsList.rawValue, + payload: try JSONValue.encode(input), + as: PullRequestListResult.self + ) + } + + public func pullRequestDetail(_ reference: PullRequestRef) async throws -> PullRequestDetail { + try await rpc.request( + RPCMethod.pullRequestsDetail.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestDetail.self + ) + } + + public func pullRequestActivity(_ reference: PullRequestRef) async throws + -> PullRequestActivity + { + try await rpc.request( + RPCMethod.pullRequestsActivity.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestActivity.self + ) + } + + public func pullRequestDiff(_ input: PullRequestDiffInput) async throws + -> PullRequestDiffResult + { + try await api.pullRequestDiff(input, environment: environment) + } + + public func runPullRequestAction( + _ reference: PullRequestRef, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod? = nil, + updateMethod: PullRequestUpdateMethod? = nil + ) async throws { + var payload = try reference.jsonObject + payload["action"] = .string(action.rawValue) + if let mergeMethod { payload["mergeMethod"] = .string(mergeMethod.rawValue) } + if let updateMethod { payload["updateMethod"] = .string(updateMethod.rawValue) } + try await rpc.request( + RPCMethod.pullRequestsRunAction.rawValue, + payload: .object(payload) + ) + } + + public func updatePullRequest( + _ reference: PullRequestRef, + title: String? = nil, + body: String? = nil + ) async throws { + var payload = try reference.jsonObject + if let title { payload["title"] = .string(title) } + if let body { payload["body"] = .string(body) } + try await rpc.request(RPCMethod.pullRequestsUpdate.rawValue, payload: .object(payload)) + } + + public func commentOnPullRequest(_ reference: PullRequestRef, body: String) async throws { + var payload = try reference.jsonObject + payload["body"] = .string(body) + try await rpc.request(RPCMethod.pullRequestsComment.rawValue, payload: .object(payload)) + } + + public func updatePullRequestComment( + _ reference: PullRequestRef, + commentID: String, + kind: PullRequestCommentKind, + body: String + ) async throws { + var payload = try reference.jsonObject + payload["commentId"] = .string(commentID) + payload["kind"] = .string(kind.rawValue) + payload["body"] = .string(body) + try await rpc.request( + RPCMethod.pullRequestsUpdateComment.rawValue, + payload: .object(payload) + ) + } + + public func submitPullRequestReview( + _ reference: PullRequestRef, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { + var payload = try reference.jsonObject + payload["verdict"] = .string(verdict.rawValue) + payload["body"] = .string(body) + payload["comments"] = try .encode(comments) + try await rpc.request( + RPCMethod.pullRequestsSubmitReview.rawValue, + payload: .object(payload) + ) + } + + public func replyToPullRequestThread( + _ reference: PullRequestRef, + threadID: String, + body: String + ) async throws { + var payload = try reference.jsonObject + payload["threadId"] = .string(threadID) + payload["body"] = .string(body) + try await rpc.request( + RPCMethod.pullRequestsReplyToThread.rawValue, + payload: .object(payload) + ) + } + + public func setPullRequestThreadResolved( + _ reference: PullRequestRef, + threadID: String, + resolved: Bool + ) async throws { + var payload = try reference.jsonObject + payload["threadId"] = .string(threadID) + payload["resolved"] = .bool(resolved) + try await rpc.request( + RPCMethod.pullRequestsSetThreadResolution.rawValue, + payload: .object(payload) + ) + } + + public func setPullRequestReaction( + _ reference: PullRequestRef, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { + var payload = try reference.jsonObject + if let subjectID { payload["subjectId"] = .string(subjectID) } + payload["content"] = .string(content.rawValue) + payload["reacted"] = .bool(reacted) + try await rpc.request( + RPCMethod.pullRequestsSetReaction.rawValue, + payload: .object(payload) + ) + } + + public func pullRequestReviewerCandidates(_ reference: PullRequestRef) async throws + -> PullRequestReviewerCandidateList + { + try await rpc.request( + RPCMethod.pullRequestsReviewerCandidates.rawValue, + payload: try JSONValue.encode(reference), + as: PullRequestReviewerCandidateList.self + ) + } + + public func requestPullRequestReviewers( + _ reference: PullRequestRef, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { + var payload = try reference.jsonObject + payload["reviewers"] = .array(reviewers.map { + .object(["id": .string($0.id), "kind": .string($0.kind)]) + }) + payload["requested"] = .bool(requested) + try await rpc.request( + RPCMethod.pullRequestsRequestReviewers.rawValue, + payload: .object(payload) + ) + } + + public func invalidatePullRequests(_ reference: PullRequestRef? = nil) async throws { + var payload: [String: JSONValue] = [:] + if let reference { payload["reference"] = try JSONValue.encode(reference) } + try await rpc.request( + RPCMethod.pullRequestsInvalidate.rawValue, + payload: .object(payload) + ) + } + + public func serverConfigEvents() async + -> AsyncThrowingStream + { + let id = UUID() + let stream = AsyncThrowingStream { continuation in + serverConfigListeners[id] = continuation + if let serverConfigCache { continuation.yield(.snapshot(serverConfigCache)) } + continuation.onTermination = { @Sendable _ in + Task { await self.removeServerConfigListener(id) } + } + } + startServerConfigSubscriptionIfNeeded() + return stream + } + + private func startServerConfigSubscriptionIfNeeded() { + guard serverConfigTask == nil else { return } + serverConfigGeneration &+= 1 + let generation = serverConfigGeneration + serverConfigTask = Task { [weak self] in + guard let self else { return } + let stream = await rpc.subscribe( + RPCMethod.subscribeServerConfig.rawValue, + as: ServerConfigStreamEvent.self + ) + do { + for try await event in stream { + guard !Task.isCancelled else { return } + await self.consumeServerConfig(event, generation: generation) + } + await self.finishServerConfigSubscription(generation: generation, error: RPCError.disconnected) + } catch { + await self.handleServerConfigSubscriptionFailure(error, generation: generation) + } + } + } + + private func consumeServerConfig(_ event: ServerConfigStreamEvent, generation: UInt64) { + guard generation == serverConfigGeneration else { return } + switch event { + case let .snapshot(config): cacheServerConfig(config) + case let .providerStatuses(providers): + if let current = serverConfigCache { + cacheServerConfig(.init( + providers: providers, + settings: current.settings, + threadSnapshotPagination: current.threadSnapshotPagination, + environment: current.environment + )) + } + case let .settingsUpdated(settings): + if let current = serverConfigCache { + cacheServerConfig(.init( + providers: current.providers, + settings: settings, + threadSnapshotPagination: current.threadSnapshotPagination, + environment: current.environment + )) + } + case .unrelated: break + } + serverConfigListeners.values.forEach { $0.yield(event) } + } + + private func cacheServerConfig(_ config: ServerConfigSnapshot) { + serverConfigCache = config + latestServerEnvironment = config.environment + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(returning: config) } + } + + private func waitForServerConfigSnapshot() async throws -> ServerConfigSnapshot { + if let serverConfigCache { return serverConfigCache } + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + serverConfigWaiters[id] = continuation + } + } onCancel: { + Task { await self.cancelServerConfigWaiter(id) } + } + } + + private func cancelServerConfigWaiter(_ id: UUID) { + serverConfigWaiters.removeValue(forKey: id)?.resume(throwing: CancellationError()) + } + + private func removeServerConfigListener(_ id: UUID) { serverConfigListeners[id] = nil } + + private func handleServerConfigSubscriptionFailure(_ error: any Error, generation: UInt64) async { + guard generation == serverConfigGeneration else { return } + guard isUnsupportedServerConfigSubscription(error) else { + finishServerConfigSubscription(generation: generation, error: error) + return + } + do { + let config: ServerConfigSnapshot = try await rpc.request( + RPCMethod.serverGetConfig.rawValue, + as: ServerConfigSnapshot.self + ) + guard generation == serverConfigGeneration else { return } + cacheServerConfig(config) + serverConfigListeners.values.forEach { $0.yield(.snapshot(config)) } + serverConfigTask = nil + } catch { + finishServerConfigSubscription(generation: generation, error: error) + } + } + + private func isUnsupportedServerConfigSubscription(_ error: any Error) -> Bool { + guard case let RPCError.remote(message) = error else { return false } + let value = message.lowercased() + guard value.contains(RPCMethod.subscribeServerConfig.rawValue.lowercased()) else { + return false + } + return value.contains("unsupported method") || value.contains("unknown rpc") + || value.contains("unknown request") || value.contains("method not found") + } + + private func finishServerConfigSubscription(generation: UInt64, error: any Error) { + guard generation == serverConfigGeneration else { return } + serverConfigTask = nil + serverConfigCache = nil + latestServerEnvironment = nil + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(throwing: error) } + let listeners = serverConfigListeners.values + serverConfigListeners.removeAll() + listeners.forEach { $0.finish(throwing: error) } + } + + private func stopServerConfigSubscription(error: any Error) { + serverConfigGeneration &+= 1 + serverConfigTask?.cancel() + serverConfigTask = nil + serverConfigCache = nil + latestServerEnvironment = nil + let waiters = serverConfigWaiters.values + serverConfigWaiters.removeAll() + waiters.forEach { $0.resume(throwing: error) } + let listeners = serverConfigListeners.values + serverConfigListeners.removeAll() + listeners.forEach { $0.finish(throwing: error) } + } + + public func clientSessions() async throws -> [AuthClientSession] { + try await api.clientSessions(for: environment) + } + + public func authSession() async throws -> AuthSessionState { + try await api.session(for: environment) + } + + @discardableResult + public func revokeClientSession(id: String) async throws -> Bool { + try await api.revokeClientSession(id: id, environment: environment).revoked + } + + @discardableResult + public func revokeOtherClientSessions() async throws -> Int { + try await api.revokeOtherClientSessions(for: environment).revokedCount + } + + /// HTTP live-sync fallback. Each iteration is an independent request, so a + /// transient network loss naturally reconnects without replaying commands. + public func pollShell( + every interval: Duration = .seconds(2) + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + var lastSequence: Int? + while !Task.isCancelled { + do { + let snapshot = try await self.shellSnapshot() + if lastSequence != snapshot.snapshotSequence { + lastSequence = snapshot.snapshotSequence + continuation.yield(snapshot) + } + } catch is CancellationError { + break + } catch { + // Keep retrying transient HTTP failures. Authentication + // failures surface from direct loads and pairing UI. + } + try? await Task.sleep(for: interval) + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + public func shellEvents( + after sequence: Int? = nil + ) async -> AsyncThrowingStream { + var payload: [String: JSONValue] = ["requestCompletionMarker": .bool(true)] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + return await rpc.subscribe( + RPCMethod.subscribeShell.rawValue, + payload: .object(payload), + as: ShellStreamItem.self + ) + } + + public func threadEvents( + threadID: String, + after sequence: Int? = nil, + turnLimit: Int? = nil + ) async -> AsyncThrowingStream { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "requestCompletionMarker": .bool(true), + ] + if let sequence { payload["afterSequence"] = .number(Double(sequence)) } + if let turnLimit { payload["turnLimit"] = .number(Double(turnLimit)) } + return await rpc.subscribe( + RPCMethod.subscribeThread.rawValue, + payload: .object(payload), + as: ThreadStreamItem.self + ) + } + + @discardableResult + public func dispatch(_ command: JSONValue) async throws -> DispatchResult { + guard await rpc.isConnected() else { + return try await api.dispatch(command, environment: environment) + } + do { + return try await dispatchOverWebSocket(command) + } catch RPCError.connectionUnavailable { + // The request provably never crossed the socket, so HTTP is a safe + // fallback without risking duplicate side effects. + return try await api.dispatch(command, environment: environment) + } + } + + @discardableResult + public func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + return try await dispatch( + try OrchestrationCommands.sendTurn( + threadID: threadID, + text: text, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + model: model, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt + ) + ) + } + + @discardableResult + public func createThread( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil + ) async throws -> DispatchResult { + try await dispatch( + try OrchestrationCommands.createThread( + threadID: threadID, + projectID: projectID, + title: title, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath + ) + ) + } + + /// Creates a thread and starts its first turn through the server-supported + /// message-first bootstrap path. + @discardableResult + public func createThreadAndSend( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + worktreePreparation: ThreadWorktreePreparation? = nil, + attachments: [UploadChatImageAttachment] = [], + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = OrchestrationCommands.now() + ) async throws -> DispatchResult { + let uploadedAttachments = try await prepareTurnAttachments(attachments) + return try await dispatchOverWebSocket( + try OrchestrationCommands.createThreadAndSend( + threadID: threadID, + projectID: projectID, + title: title, + text: text, + model: model, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + branch: branch, + worktreePath: worktreePath, + worktreePreparation: worktreePreparation, + attachments: attachments, + uploadedAttachments: uploadedAttachments, + commandID: commandID, + messageID: messageID, + createdAt: createdAt + ) + ) + } + + private func dispatchOverWebSocket(_ command: JSONValue) async throws -> DispatchResult { + try await rpc.request( + RPCMethod.dispatchCommand.rawValue, + payload: command, + as: DispatchResult.self + ) + } + + @discardableResult + public func createProject( + projectID: String = UUID().uuidString, + title: String, + workspaceRoot: String, + defaultModel: ModelSelection? = nil, + createWorkspaceRootIfMissing: Bool = false + ) async throws -> DispatchResult { + try await dispatch( + try OrchestrationCommands.createProject( + projectID: projectID, + title: title, + workspaceRoot: workspaceRoot, + defaultModel: defaultModel, + createWorkspaceRootIfMissing: createWorkspaceRootIfMissing + ) + ) + } + + @discardableResult + public func archive(threadID: String, archived: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.archive(threadID: threadID, archived: archived)) + } + + @discardableResult + public func delete(threadID: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.deleteThread(threadID: threadID)) + } + + @discardableResult + public func rename(threadID: String, title: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.rename(threadID: threadID, title: title)) + } + + @discardableResult + public func regenerateTitle(threadID: String) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.regenerateTitle(threadID: threadID)) + } + + @discardableResult + public func interrupt(threadID: String, turnID: String? = nil) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.interrupt(threadID: threadID, turnID: turnID) + ) + } + + @discardableResult + public func respondToApproval( + threadID: String, + requestID: String, + decision: String + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToApproval( + threadID: threadID, + requestID: requestID, + decision: decision + ) + ) + } + + @discardableResult + public func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue] + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.respondToUserInput( + threadID: threadID, + requestID: requestID, + answers: answers + ) + ) + } + + @discardableResult + public func settle(threadID: String, settled: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.settle(threadID: threadID, settled: settled)) + } + + @discardableResult + public func snooze(threadID: String, until: Date?) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.snooze(threadID: threadID, until: until) + ) + } + + @discardableResult + public func pin(threadID: String, pinned: Bool) async throws -> DispatchResult { + try await dispatch(OrchestrationCommands.pin(threadID: threadID, pinned: pinned)) + } + + @discardableResult + public func setRuntimeMode( + threadID: String, + mode: RuntimeMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setRuntimeMode(threadID: threadID, mode: mode) + ) + } + + @discardableResult + public func setInteractionMode( + threadID: String, + mode: InteractionMode + ) async throws -> DispatchResult { + try await dispatch( + OrchestrationCommands.setInteractionMode(threadID: threadID, mode: mode) + ) + } + + // MARK: Workspace files + + public func listProjectEntries(cwd: String) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsListEntries.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: ProjectEntriesResult.self + ) + } + + public func searchProjectEntries( + cwd: String, + query: String, + limit: Int = 100 + ) async throws -> ProjectEntriesResult { + try await rpc.request( + RPCMethod.projectsSearchEntries.rawValue, + payload: .object([ + "cwd": .string(cwd), + "query": .string(query), + "limit": .number(Double(limit)), + ]), + as: ProjectEntriesResult.self + ) + } + + public func readProjectFile( + cwd: String, + relativePath: String + ) async throws -> ProjectReadFileResult { + try await rpc.request( + RPCMethod.projectsReadFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + ]), + as: ProjectReadFileResult.self + ) + } + + public func writeProjectFile( + cwd: String, + relativePath: String, + contents: String + ) async throws -> ProjectWriteFileResult { + try await rpc.request( + RPCMethod.projectsWriteFile.rawValue, + payload: .object([ + "cwd": .string(cwd), + "relativePath": .string(relativePath), + "contents": .string(contents), + ]), + as: ProjectWriteFileResult.self + ) + } + + public func browseFilesystem( + partialPath: String, + cwd: String? = nil + ) async throws -> FilesystemBrowseResult { + var payload: [String: JSONValue] = ["partialPath": .string(partialPath)] + if let cwd { payload["cwd"] = .string(cwd) } + return try await rpc.request( + RPCMethod.filesystemBrowse.rawValue, + payload: .object(payload), + as: FilesystemBrowseResult.self + ) + } + + /// Issues a short-lived authenticated URL for a persisted attachment, + /// workspace preview, or project favicon. + public func createAssetURL(resource: AssetResource) async throws -> AssetCreateURLResult { + try await rpc.request( + RPCMethod.assetsCreateURL.rawValue, + payload: .object(["resource": resource.jsonValue]), + as: AssetCreateURLResult.self + ) + } + + public func createAttachmentUploadURL( + type: String? = nil, + name: String, + mimeType: String, + sizeBytes: Int + ) async throws -> AttachmentCreateUploadURLResult { + var payload: [String: JSONValue] = [ + "name": .string(name), + "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ] + if let type { payload["type"] = .string(type) } + return try await rpc.request( + RPCMethod.attachmentsCreateUploadURL.rawValue, + payload: .object(payload), + as: AttachmentCreateUploadURLResult.self + ) + } + + public func deleteAttachment(id: String) async throws { + try await rpc.request( + RPCMethod.attachmentsDelete.rawValue, + payload: .object(["attachmentId": .string(id)]) + ) + } + + public func uploadFeedback( + threadID: String, + reason: String? = nil + ) async throws -> ProviderUploadFeedbackResult { + var payload: [String: JSONValue] = ["threadId": .string(threadID)] + if let reason { + payload["reason"] = .string(reason) + } + return try await rpc.request( + RPCMethod.providerUploadFeedback.rawValue, + payload: .object(payload), + as: ProviderUploadFeedbackResult.self + ) + } + + private func prepareTurnAttachments( + _ attachments: [UploadChatImageAttachment] + ) async throws -> [JSONValue]? { + guard !attachments.isEmpty else { return nil } + guard attachments.count <= 8 else { throw FileAttachmentError.tooMany(maximum: 8) } + + let capabilities = latestServerEnvironment?.capabilities + ?? environment.descriptor?.capabilities + let containsFiles = attachments.contains { $0.type == "file" } + let supportsImageUploads = capabilities?.attachmentUploads == true + let fileCapability = capabilities?.fileAttachments + if containsFiles, !supportsImageUploads || fileCapability == nil { + throw FileAttachmentError.unsupported + } + + if let fileCapability { + let maximumBytes = min( + UploadChatAttachment.maximumFileBytes, + max(0, fileCapability.maxUploadBytes) + ) + for attachment in attachments where attachment.type == "file" { + guard attachment.sizeBytes <= maximumBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: attachment.sizeBytes, + maximumBytes: maximumBytes + ) + } + } + } + guard containsFiles || supportsImageUploads else { return nil } + + var prepared: [JSONValue] = [] + for attachment in attachments { + if let reference = try await prepareAttachment(attachment) { + prepared.append(attachment.uploadedJSONValue(id: reference.attachmentID)) + } else { + prepared.append(attachment.jsonValue) + } + } + return prepared + } + + /// Uploads one attachment for this environment. Older servers keep images + /// inline, so a nil result means the caller must use the image data URL. + public func prepareAttachment( + _ attachment: UploadChatAttachment + ) async throws -> UploadedAttachmentReference? { + let capabilities = latestServerEnvironment?.capabilities + ?? environment.descriptor?.capabilities + let supportsUploads = capabilities?.attachmentUploads == true + if attachment.type == "file" { + guard supportsUploads, let fileCapability = capabilities?.fileAttachments else { + throw FileAttachmentError.unsupported + } + let maximumBytes = min( + UploadChatAttachment.maximumFileBytes, + max(0, fileCapability.maxUploadBytes) + ) + guard attachment.sizeBytes <= maximumBytes else { + throw FileAttachmentError.tooLarge( + actualBytes: attachment.sizeBytes, + maximumBytes: maximumBytes + ) + } + } else if !supportsUploads { + return nil + } + + if let reference = attachment.uploadedReference, + reference.environmentID == environment.id, + !reference.attachmentID.isEmpty { + do { + _ = try await createAssetURL(resource: .attachment(id: reference.attachmentID)) + return reference + } catch where Self.isAttachmentNotFound(error) { + // The server expired the attachment. Upload the retained bytes again. + } + } + + let upload = try await createAttachmentUploadURL( + type: attachment.type == "file" ? "file" : nil, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes + ) + do { + guard let url = URL( + string: upload.relativeUrl, + relativeTo: environment.httpBaseURL + )?.absoluteURL else { + throw RPCError.protocolViolation("The attachment upload URL is invalid.") + } + switch attachment.source { + case let .imageData(data): + try await api.uploadAttachment(data, mimeType: attachment.mimeType, to: url) + case let .file(fileURL): + guard let actualBytes = try? fileURL.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ), + actualBytes.isRegularFile == true, + actualBytes.fileSize == attachment.sizeBytes else { + throw FileAttachmentError.invalidFileURL + } + try await api.uploadAttachment( + fileURL: fileURL, + byteCount: attachment.sizeBytes, + mimeType: attachment.mimeType, + to: url + ) + } + return UploadedAttachmentReference( + environmentID: environment.id, + attachmentID: upload.attachmentId + ) + } catch { + try? await deleteAttachment(id: upload.attachmentId) + throw error + } + } + + private static func isAttachmentNotFound(_ error: any Error) -> Bool { + guard case let RPCError.remote(message) = error else { return false } + let normalized = message.lowercased() + return normalized.contains("attachment") + && (normalized.contains("not found") || normalized.contains("does not exist")) + } + + public func resolvedAssetURL(resource: AssetResource) async throws -> URL { + try await resolvedAsset(resource: resource).url + } + + public func resolvedAsset(resource: AssetResource) async throws -> ResolvedAssetURL { + let result = try await createAssetURL(resource: resource) + guard let url = URL( + string: result.relativeUrl, + relativeTo: environment.httpBaseURL + )?.absoluteURL else { + throw RPCError.protocolViolation("The server returned an invalid asset URL.") + } + return ResolvedAssetURL( + url: url, + expiresAt: Date(timeIntervalSince1970: result.expiresAt / 1_000) + ) + } + + // MARK: VCS and source control + + public func refreshVCSStatus(cwd: String) async throws -> VCSStatus { + try await rpc.request( + RPCMethod.vcsRefreshStatus.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSStatus.self + ) + } + + public func vcsStatusEvents(cwd: String) async + -> AsyncThrowingStream + { + await rpc.subscribe( + RPCMethod.subscribeVCSStatus.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSStatusEvent.self + ) + } + + public func listVCSRefs( + cwd: String, + query: String? = nil, + cursor: Int? = nil, + kind: String? = nil, + refresh: Bool = false, + limit: Int = 100 + ) async throws -> VCSRefsResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "refresh": .bool(refresh), + "limit": .number(Double(limit)), + ] + if let query { payload["query"] = .string(query) } + if let cursor { payload["cursor"] = .number(Double(cursor)) } + if let kind { payload["refKind"] = .string(kind) } + return try await rpc.request( + RPCMethod.vcsListRefs.rawValue, + payload: .object(payload), + as: VCSRefsResult.self + ) + } + + public func pull(cwd: String) async throws -> VCSPullResult { + try await rpc.request( + RPCMethod.vcsPull.rawValue, + payload: .object(["cwd": .string(cwd)]), + as: VCSPullResult.self + ) + } + + public func createVCSRef( + cwd: String, + name: String, + switchToRef: Bool = true + ) async throws -> VCSCreateRefResult { + try await rpc.request( + RPCMethod.vcsCreateRef.rawValue, + payload: .object([ + "cwd": .string(cwd), + "refName": .string(name), + "switchRef": .bool(switchToRef), + ]), + as: VCSCreateRefResult.self + ) + } + + public func switchVCSRef(cwd: String, name: String) async throws -> VCSSwitchRefResult { + try await rpc.request( + RPCMethod.vcsSwitchRef.rawValue, + payload: .object([ + "cwd": .string(cwd), + "refName": .string(name), + ]), + as: VCSSwitchRefResult.self + ) + } + + public func createWorktree( + cwd: String, + refName: String, + newRefName: String? = nil, + baseRefName: String? = nil, + path: String? = nil + ) async throws -> VCSCreateWorktreeResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "refName": .string(refName), + "path": path.map(JSONValue.string) ?? .null, + ] + if let newRefName { payload["newRefName"] = .string(newRefName) } + if let baseRefName { payload["baseRefName"] = .string(baseRefName) } + return try await rpc.request( + RPCMethod.vcsCreateWorktree.rawValue, + payload: .object(payload), + as: VCSCreateWorktreeResult.self + ) + } + + public func removeWorktree(cwd: String, path: String, force: Bool = false) async throws { + try await rpc.request( + RPCMethod.vcsRemoveWorktree.rawValue, + payload: .object([ + "cwd": .string(cwd), + "path": .string(path), + "force": .bool(force), + ]) + ) + } + + public func initializeVCS(cwd: String, kind: String? = nil) async throws { + var payload: [String: JSONValue] = ["cwd": .string(cwd)] + if let kind { payload["kind"] = .string(kind) } + try await rpc.request(RPCMethod.vcsInitialize.rawValue, payload: .object(payload)) + } + + public func runGitAction( + cwd: String, + action: GitStackedAction, + commitMessage: String? = nil, + featureBranch: Bool? = nil, + filePaths: [String]? = nil, + actionID: String = UUID().uuidString + ) async throws -> AsyncThrowingStream { + var payload: [String: JSONValue] = [ + "actionId": .string(actionID), + "cwd": .string(cwd), + "action": .string(action.rawValue), + ] + if let commitMessage { payload["commitMessage"] = .string(commitMessage) } + if let featureBranch { payload["featureBranch"] = .bool(featureBranch) } + if let filePaths { payload["filePaths"] = .array(filePaths.map(JSONValue.string)) } + // A command stream must fail on disconnect instead of replaying a + // potentially successful commit or push. + return await rpc.subscribe( + RPCMethod.gitRunStackedAction.rawValue, + payload: .object(payload), + reconnect: false, + as: GitActionProgressEvent.self + ) + } + + public func lookupRepository( + provider: SourceControlProviderKind, + repository: String, + cwd: String? = nil + ) async throws -> SourceControlRepositoryInfo { + var payload: [String: JSONValue] = [ + "provider": .string(provider.rawValue), + "repository": .string(repository), + ] + if let cwd { payload["cwd"] = .string(cwd) } + return try await rpc.request( + RPCMethod.sourceControlLookup.rawValue, + payload: .object(payload), + as: SourceControlRepositoryInfo.self + ) + } + + public func discoverSourceControl() async throws -> SourceControlDiscoveryResult { + try await rpc.request( + RPCMethod.serverDiscoverSourceControl.rawValue, + payload: .object([:]), + as: SourceControlDiscoveryResult.self + ) + } + + public func cloneRepository( + provider: SourceControlProviderKind? = nil, + repository: String? = nil, + remoteURL: String? = nil, + destinationPath: String, + cloneProtocol: String? = nil + ) async throws -> SourceControlCloneResult { + var payload: [String: JSONValue] = ["destinationPath": .string(destinationPath)] + if let provider { payload["provider"] = .string(provider.rawValue) } + if let repository { payload["repository"] = .string(repository) } + if let remoteURL { payload["remoteUrl"] = .string(remoteURL) } + if let cloneProtocol { payload["protocol"] = .string(cloneProtocol) } + return try await rpc.request( + RPCMethod.sourceControlClone.rawValue, + payload: .object(payload), + as: SourceControlCloneResult.self + ) + } + + public func publishRepository( + cwd: String, + provider: SourceControlProviderKind, + repository: String, + visibility: String, + remoteName: String? = nil, + cloneProtocol: String? = nil + ) async throws -> SourceControlPublishResult { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "provider": .string(provider.rawValue), + "repository": .string(repository), + "visibility": .string(visibility), + ] + if let remoteName { payload["remoteName"] = .string(remoteName) } + if let cloneProtocol { payload["protocol"] = .string(cloneProtocol) } + return try await rpc.request( + RPCMethod.sourceControlPublish.rawValue, + payload: .object(payload), + as: SourceControlPublishResult.self + ) + } + + // MARK: Review + + public func reviewDiffPreview( + cwd: String, + baseRef: String? = nil, + ignoreWhitespace: Bool = false + ) async throws -> ReviewDiffPreview { + var payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "ignoreWhitespace": .bool(ignoreWhitespace), + ] + if let baseRef { payload["baseRef"] = .string(baseRef) } + return try await rpc.request( + RPCMethod.reviewDiffPreview.rawValue, + payload: .object(payload), + as: ReviewDiffPreview.self + ) + } + + public func reviewDiffFileContents( + cwd: String, + sourceKind: String, + changeType: String, + baseRef: String?, + headRef: String?, + oldPath: String, + newPath: String + ) async throws -> ReviewDiffFileContents { + let payload: [String: JSONValue] = [ + "cwd": .string(cwd), + "sourceKind": .string(sourceKind), + "changeType": .string(changeType), + "baseRef": baseRef.map(JSONValue.string) ?? .null, + "headRef": headRef.map(JSONValue.string) ?? .null, + "oldPath": .string(oldPath), + "newPath": .string(newPath), + ] + return try await rpc.request( + RPCMethod.reviewDiffFileContents.rawValue, + payload: .object(payload), + as: ReviewDiffFileContents.self + ) + } + + // MARK: Terminal + + public func openTerminal( + threadID: String, + terminalID: String, + cwd: String, + worktreePath: String? = nil, + columns: Int? = nil, + rows: Int? = nil, + environmentVariables: [String: String]? = nil + ) async throws -> TerminalSessionSnapshot { + let payload = try terminalPayload( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + return try await rpc.request( + RPCMethod.terminalOpen.rawValue, + payload: payload, + as: TerminalSessionSnapshot.self + ) + } + + public func attachTerminal( + threadID: String, + terminalID: String, + cwd: String? = nil, + worktreePath: String? = nil, + columns: Int? = nil, + rows: Int? = nil, + environmentVariables: [String: String]? = nil, + restartIfNotRunning: Bool = false + ) async throws -> AsyncThrowingStream { + var payload = try terminalPayloadObject( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + payload["restartIfNotRunning"] = .bool(restartIfNotRunning) + return await rpc.subscribe( + RPCMethod.terminalAttach.rawValue, + payload: .object(payload), + as: TerminalEvent.self + ) + } + + public func terminalEvents() async -> AsyncThrowingStream { + await rpc.subscribe( + RPCMethod.subscribeTerminalEvents.rawValue, + as: TerminalEvent.self + ) + } + + public func terminalMetadataEvents() async + -> AsyncThrowingStream + { + await rpc.subscribe( + RPCMethod.subscribeTerminalMetadata.rawValue, + as: TerminalMetadataEvent.self + ) + } + + public func writeTerminal( + threadID: String, + terminalID: String, + data: String + ) async throws { + try await rpc.request( + RPCMethod.terminalWrite.rawValue, + payload: .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + "data": .string(data), + ]) + ) + } + + public func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws { + try await rpc.request( + RPCMethod.terminalResize.rawValue, + payload: .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + "cols": .number(Double(columns)), + "rows": .number(Double(rows)), + ]) + ) + } + + public func clearTerminal(threadID: String, terminalID: String) async throws { + try await rpc.request( + RPCMethod.terminalClear.rawValue, + payload: terminalIdentity(threadID: threadID, terminalID: terminalID) + ) + } + + public func restartTerminal( + threadID: String, + terminalID: String, + cwd: String, + worktreePath: String? = nil, + columns: Int, + rows: Int, + environmentVariables: [String: String]? = nil + ) async throws -> TerminalSessionSnapshot { + let payload = try terminalPayload( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + return try await rpc.request( + RPCMethod.terminalRestart.rawValue, + payload: payload, + as: TerminalSessionSnapshot.self + ) + } + + public func closeTerminal( + threadID: String, + terminalID: String? = nil, + deleteHistory: Bool = false + ) async throws { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "deleteHistory": .bool(deleteHistory), + ] + if let terminalID { payload["terminalId"] = .string(terminalID) } + try await rpc.request(RPCMethod.terminalClose.rawValue, payload: .object(payload)) + } + + private func terminalIdentity(threadID: String, terminalID: String) -> JSONValue { + .object([ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + ]) + } + + private func terminalPayload( + threadID: String, + terminalID: String, + cwd: String?, + worktreePath: String?, + columns: Int?, + rows: Int?, + environmentVariables: [String: String]? + ) throws -> JSONValue { + .object( + try terminalPayloadObject( + threadID: threadID, + terminalID: terminalID, + cwd: cwd, + worktreePath: worktreePath, + columns: columns, + rows: rows, + environmentVariables: environmentVariables + ) + ) + } + + private func terminalPayloadObject( + threadID: String, + terminalID: String, + cwd: String?, + worktreePath: String?, + columns: Int?, + rows: Int?, + environmentVariables: [String: String]? + ) throws -> [String: JSONValue] { + var payload: [String: JSONValue] = [ + "threadId": .string(threadID), + "terminalId": .string(terminalID), + ] + if let cwd { payload["cwd"] = .string(cwd) } + if let worktreePath { + payload["worktreePath"] = .string(worktreePath) + } else if cwd != nil { + payload["worktreePath"] = .null + } + if let columns { payload["cols"] = .number(Double(columns)) } + if let rows { payload["rows"] = .number(Double(rows)) } + if let environmentVariables { + payload["env"] = try JSONValue.encode(environmentVariables) + } + return payload + } +} + +/// Owns persisted environment selection and constructs scoped clients without +/// introducing UI-framework state into Core. +public struct EnvironmentPersistenceError: LocalizedError, Sendable { + public let operationError: String + public let rollbackErrors: [String] + + public var errorDescription: String? { + "\(operationError) Recovery also failed: \(rollbackErrors.joined(separator: "; "))" + } +} + +public actor EnvironmentRuntime { + public let environmentStore: EnvironmentStore + public let credentialStore: any CredentialStore + public nonisolated let supportsManagedAuthorization: Bool + private let httpTransport: any HTTPTransport + private let webSocketConnector: any WebSocketConnecting + private let managedAuthorization: (any ManagedEnvironmentAuthorizing)? + private let rpcConnectionWaitTimeout: Duration + private var clients: [String: T3Client] = [:] + + public init( + environmentStore: EnvironmentStore = EnvironmentStore(), + credentialStore: any CredentialStore = KeychainCredentialStore(), + httpTransport: any HTTPTransport = URLSessionHTTPTransport(), + webSocketConnector: any WebSocketConnecting = URLSessionWebSocketConnector(), + managedAuthorization: (any ManagedEnvironmentAuthorizing)? = nil, + rpcConnectionWaitTimeout: Duration = .seconds(4) + ) { + self.environmentStore = environmentStore + self.credentialStore = credentialStore + self.httpTransport = httpTransport + self.webSocketConnector = webSocketConnector + self.managedAuthorization = managedAuthorization + self.rpcConnectionWaitTimeout = rpcConnectionWaitTimeout + supportsManagedAuthorization = managedAuthorization != nil + } + + public func environments() async throws -> [Environment] { + try await environmentStore.load() + } + + public func activeEnvironment() async throws -> Environment? { + let environments = try await environmentStore.load() + let enabled = environments.filter(\.isEnabled) + guard !enabled.isEmpty else { return nil } + let activeID = try await environmentStore.activeEnvironmentID() + return enabled.first(where: { $0.id == activeID }) ?? enabled[0] + } + + @discardableResult + public func activate(id: String) async throws -> T3Client { + let environments = try await environmentStore.load() + guard let environment = environments.first(where: { $0.id == id }) else { + throw RPCError.remote("Environment \(id) is not saved.") + } + guard environment.isEnabled else { + throw RPCError.remote("Environment \(id) is disabled.") + } + try await environmentStore.setActiveEnvironment(id: id) + return await client(for: environment) + } + + public func setEnabled(id: String, enabled: Bool) async throws { + let environments = try await environmentStore.setEnabled(id: id, enabled: enabled) + guard environments.contains(where: { $0.id == id }) else { + throw RPCError.remote("Environment \(id) is not saved.") + } + if !enabled, let client = clients[id] { + await client.disconnect() + } + } + + public func activeClient() async throws -> T3Client? { + guard let environment = try await activeEnvironment() else { return nil } + return await client(for: environment) + } + + @discardableResult + public func pair(url: String, clientLabel: String? = nil) async throws -> T3Client { + let service = PairingService( + transport: httpTransport, + environmentStore: environmentStore, + credentialStore: credentialStore + ) + let environment = try await service.pair(url: url, label: clientLabel) + try await environmentStore.setActiveEnvironment(id: environment.id) + return await client(for: environment) + } + + @discardableResult + public func pair( + host: String, + code: String, + clientLabel: String? = nil + ) async throws -> T3Client { + let service = PairingService( + transport: httpTransport, + environmentStore: environmentStore, + credentialStore: credentialStore + ) + let environment = try await service.pair(host: host, code: code, label: clientLabel) + try await environmentStore.setActiveEnvironment(id: environment.id) + return await client(for: environment) + } + + public func descriptor(at httpBaseURL: URL) async throws -> EnvironmentDescriptor { + let api = EnvironmentAPI(transport: httpTransport, credentials: credentialStore) + return try await api.descriptor(at: httpBaseURL) + } + + /// Persists a fully validated managed environment. Both the environment + /// metadata and the tagged DPoP credential must agree before either can + /// replace an existing manual connection with the same server identity. + @discardableResult + public func saveManagedEnvironment( + _ environment: Environment, + credential: EnvironmentCredential + ) async throws -> T3Client { + guard environment.kind == .managedDPoP, + environment.descriptor?.environmentId == environment.id, + credential.authorizationMethod == .dpop, + credential.managedEnvironmentID == environment.id, + credential.proofKeyThumbprint?.isEmpty == false else { + throw HTTPError.incompatibleCredential + } + + let previousEnvironment = try await environmentStore.load() + .first(where: { $0.id == environment.id }) + let previousActiveID = try await environmentStore.activeEnvironmentID() + let previousCredential = try await credentialStore.swapCredential( + credential, + for: environment.id + ) + do { + try await environmentStore.upsert(environment) + try await environmentStore.setActiveEnvironment(id: environment.id) + } catch { + let operationError = error + var rollbackErrors: [String] = [] + do { + if let previousCredential { + _ = try await credentialStore.replaceCredential( + previousCredential, + ifMatching: credential, + for: environment.id + ) + } else { + _ = try await credentialStore.removeCredential( + ifMatching: credential, + for: environment.id + ) + } + } catch { + rollbackErrors.append("credential: \(error.localizedDescription)") + } + // EnvironmentStore's individual mutations are actor-atomic. Undo + // only this record so a concurrent save for another environment + // cannot be lost while this actor is reentrant across awaits. + do { + if let previousEnvironment { + _ = try await environmentStore.upsert(previousEnvironment) + } else { + _ = try await environmentStore.remove(id: environment.id) + } + } catch { + rollbackErrors.append("environment catalog: \(error.localizedDescription)") + } + do { + let activeIDAfterFailure = try await environmentStore.activeEnvironmentID() + if activeIDAfterFailure == environment.id { + try await environmentStore.setActiveEnvironment(id: previousActiveID) + } + } catch { + rollbackErrors.append("active environment: \(error.localizedDescription)") + } + guard rollbackErrors.isEmpty else { + throw EnvironmentPersistenceError( + operationError: operationError.localizedDescription, + rollbackErrors: rollbackErrors + ) + } + throw operationError + } + return await client(for: environment) + } + + public func remove(id: String) async throws { + let previousEnvironment = try await environmentStore.load() + .first(where: { $0.id == id }) + let previousActiveID = try await environmentStore.activeEnvironmentID() + // Never leave a catalog entry pointing at a credential that was + // already destroyed when the catalog write itself fails. + try await environmentStore.remove(id: id) + do { + try await credentialStore.removeCredential(for: id) + } catch { + let operationError = error + var rollbackErrors: [String] = [] + if let previousEnvironment { + do { + _ = try await environmentStore.upsert(previousEnvironment) + } catch { + rollbackErrors.append("environment catalog: \(error.localizedDescription)") + } + } + do { + try await environmentStore.setActiveEnvironment(id: previousActiveID) + } catch { + rollbackErrors.append("active environment: \(error.localizedDescription)") + } + guard rollbackErrors.isEmpty else { + throw EnvironmentPersistenceError( + operationError: operationError.localizedDescription, + rollbackErrors: rollbackErrors + ) + } + throw operationError + } + if let client = clients.removeValue(forKey: id) { + await client.disconnect() + } + } + + /// Revokes local access before best-effort catalog cleanup. Account + /// sign-out uses this so a failed file write cannot leave a managed DPoP + /// credential usable. + public func revokeCredential(id: String) async throws { + try await credentialStore.removeCredential(for: id) + if let client = clients.removeValue(forKey: id) { + await client.disconnect() + } + } + + /// Returns the cached client for a saved environment without changing the + /// environment used for new projects and threads. + public func client(for environment: Environment) async -> T3Client { + if let existing = clients[environment.id] { + if existing.environment == environment { + return existing + } + // Publish the replacement before disconnecting the stale client. + // Actor methods are reentrant across that await; removing first + // allowed a concurrent caller to construct a second replacement. + let replacement = T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + clients[environment.id] = replacement + Task { await existing.disconnect() } + return replacement + } + let client = T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + clients[environment.id] = client + return client + } + + /// Creates an uncached client for bounded one-shot WebSocket RPCs. Passive + /// environment probes must not stop or mutate the shared client if that + /// environment becomes active while the probe is in flight. + public func ephemeralClient(for environment: Environment) -> T3Client { + T3Client( + environment: environment, + credentialStore: credentialStore, + httpTransport: httpTransport, + webSocketConnector: webSocketConnector, + managedAuthorization: managedAuthorization, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + } +} + +public enum RPCMethod: String, Sendable { + case serverProbe = "server.probe" + case serverGetConfig = "server.getConfig" + case serverRefreshProviders = "server.refreshProviders" + case serverUpdateSettings = "server.updateSettings" + case serverGetUsageSummary = "server.getUsageSummary" + case pullRequestsList = "pullRequests.list" + case pullRequestsDetail = "pullRequests.detail" + case pullRequestsActivity = "pullRequests.activity" + case pullRequestsRunAction = "pullRequests.runAction" + case pullRequestsUpdate = "pullRequests.update" + case pullRequestsComment = "pullRequests.comment" + case pullRequestsUpdateComment = "pullRequests.updateComment" + case pullRequestsSubmitReview = "pullRequests.submitReview" + case pullRequestsReplyToThread = "pullRequests.replyToThread" + case pullRequestsSetThreadResolution = "pullRequests.setThreadResolution" + case pullRequestsSetReaction = "pullRequests.setReaction" + case pullRequestsInvalidate = "pullRequests.invalidate" + case pullRequestsReviewerCandidates = "pullRequests.reviewerCandidates" + case pullRequestsRequestReviewers = "pullRequests.requestReviewers" + case dispatchCommand = "orchestration.dispatchCommand" + case getArchivedShellSnapshot = "orchestration.getArchivedShellSnapshot" + case subscribeShell = "orchestration.subscribeShell" + case subscribeThread = "orchestration.subscribeThread" + case projectsListEntries = "projects.listEntries" + case projectsSearchEntries = "projects.searchEntries" + case projectsReadFile = "projects.readFile" + case projectsWriteFile = "projects.writeFile" + case filesystemBrowse = "filesystem.browse" + case assetsCreateURL = "assets.createUrl" + case attachmentsCreateUploadURL = "attachments.createUploadUrl" + case attachmentsDelete = "attachments.delete" + case providerUploadFeedback = "provider.uploadFeedback" + case subscribeServerConfig + case serverDiscoverSourceControl = "server.discoverSourceControl" + case subscribeVCSStatus = "subscribeVcsStatus" + case vcsPull = "vcs.pull" + case vcsRefreshStatus = "vcs.refreshStatus" + case vcsListRefs = "vcs.listRefs" + case vcsCreateRef = "vcs.createRef" + case vcsSwitchRef = "vcs.switchRef" + case vcsCreateWorktree = "vcs.createWorktree" + case vcsRemoveWorktree = "vcs.removeWorktree" + case vcsInitialize = "vcs.init" + case gitRunStackedAction = "git.runStackedAction" + case sourceControlLookup = "sourceControl.lookupRepository" + case sourceControlClone = "sourceControl.cloneRepository" + case sourceControlPublish = "sourceControl.publishRepository" + case reviewDiffPreview = "review.getDiffPreview" + case reviewDiffFileContents = "review.getDiffFileContents" + case terminalOpen = "terminal.open" + case terminalAttach = "terminal.attach" + case terminalWrite = "terminal.write" + case terminalResize = "terminal.resize" + case terminalClear = "terminal.clear" + case terminalRestart = "terminal.restart" + case terminalClose = "terminal.close" + case subscribeTerminalEvents + case subscribeTerminalMetadata +} + +public enum OrchestrationCommands { + public static func createThread( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + .object([ + "type": .string("thread.create"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "projectId": .string(projectID), + "title": .string(title), + "modelSelection": try .encode(model), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "branch": branch.map(JSONValue.string) ?? .null, + "worktreePath": worktreePath.map(JSONValue.string) ?? .null, + "createdAt": .string(createdAt), + ]) + } + + public static func createProject( + projectID: String = UUID().uuidString, + title: String, + workspaceRoot: String, + defaultModel: ModelSelection? = nil, + createWorkspaceRootIfMissing: Bool = false, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("project.create"), + "commandId": .string(commandID), + "projectId": .string(projectID), + "title": .string(title), + "workspaceRoot": .string(workspaceRoot), + "createWorkspaceRootIfMissing": .bool(createWorkspaceRootIfMissing), + "createdAt": .string(createdAt), + ] + if let defaultModel { + value["defaultModelSelection"] = try JSONValue.encode(defaultModel) + } else { + value["defaultModelSelection"] = .null + } + return .object(value) + } + + public static func sendTurn( + threadID: String, + text: String, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + model: ModelSelection? = nil, + attachments: [UploadChatImageAttachment] = [], + uploadedAttachments: [JSONValue]? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var command: [String: JSONValue] = [ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ]), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "createdAt": .string(createdAt), + ] + if let model { + command["modelSelection"] = try .encode(model) + } + return .object(command) + } + + public static func createThreadAndSend( + threadID: String = UUID().uuidString, + projectID: String, + title: String, + text: String, + model: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: InteractionMode = .default, + branch: String? = nil, + worktreePath: String? = nil, + worktreePreparation: ThreadWorktreePreparation? = nil, + attachments: [UploadChatImageAttachment] = [], + uploadedAttachments: [JSONValue]? = nil, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: String = now() + ) throws -> JSONValue { + var create: [String: JSONValue] = [ + "projectId": .string(projectID), + "title": .string(title), + "modelSelection": try .encode(model), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "branch": branch.map(JSONValue.string) ?? .null, + "worktreePath": worktreePath.map(JSONValue.string) ?? .null, + "createdAt": .string(createdAt), + ] + create["createdAt"] = .string(createdAt) + var bootstrap: [String: JSONValue] = ["createThread": .object(create)] + if let worktreePreparation { + var prepareWorktree: [String: JSONValue] = [ + "projectCwd": .string(worktreePreparation.projectCwd), + "baseBranch": .string(worktreePreparation.baseBranch), + "branch": .string(worktreePreparation.branch), + ] + if worktreePreparation.startFromOrigin { + prepareWorktree["startFromOrigin"] = .bool(true) + } + bootstrap["prepareWorktree"] = .object(prepareWorktree) + bootstrap["runSetupScript"] = .bool(true) + } + return .object([ + "type": .string("thread.turn.start"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "message": .object([ + "messageId": .string(messageID), + "role": .string("user"), + "text": .string(text), + "attachments": .array(uploadedAttachments ?? attachments.map(\.jsonValue)), + ]), + "modelSelection": try .encode(model), + "titleSeed": .string(title), + "runtimeMode": .string(runtimeMode.rawValue), + "interactionMode": .string(interactionMode.rawValue), + "bootstrap": .object(bootstrap), + "createdAt": .string(createdAt), + ]) + } + + public static func archive( + threadID: String, + archived: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic( + type: archived ? "thread.archive" : "thread.unarchive", + threadID: threadID, + commandID: commandID + ) + } + + public static func deleteThread( + threadID: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic(type: "thread.delete", threadID: threadID, commandID: commandID) + } + + public static func rename( + threadID: String, + title: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + .object([ + "type": .string("thread.meta.update"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "title": .string(title), + ]) + } + + public static func regenerateTitle( + threadID: String, + commandID: String = UUID().uuidString + ) -> JSONValue { + .object([ + "type": .string("thread.meta.update"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "regenerateTitle": .bool(true), + ]) + } + + public static func interrupt( + threadID: String, + turnID: String?, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + var value: [String: JSONValue] = [ + "type": .string("thread.turn.interrupt"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "createdAt": .string(createdAt), + ] + if let turnID { value["turnId"] = .string(turnID) } + return .object(value) + } + + public static func respondToApproval( + threadID: String, + requestID: String, + decision: String, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.approval.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "decision": .string(decision), + "createdAt": .string(createdAt), + ]) + } + + public static func respondToUserInput( + threadID: String, + requestID: String, + answers: [String: JSONValue], + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.user-input.respond"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "requestId": .string(requestID), + "answers": .object(answers), + "createdAt": .string(createdAt), + ]) + } + + public static func settle( + threadID: String, + settled: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + var value = basic( + type: settled ? "thread.settle" : "thread.unsettle", + threadID: threadID, + commandID: commandID + ) + if !settled, case var .object(object) = value { + object["reason"] = .string("user") + value = .object(object) + } + return value + } + + public static func snooze( + threadID: String, + until: Date?, + commandID: String = UUID().uuidString + ) -> JSONValue { + if let until { + return .object([ + "type": .string("thread.snooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "snoozedUntil": .string(iso8601.format(until)), + ]) + } + return .object([ + "type": .string("thread.unsnooze"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "reason": .string("user"), + ]) + } + + public static func pin( + threadID: String, + pinned: Bool, + commandID: String = UUID().uuidString + ) -> JSONValue { + basic( + type: pinned ? "thread.pin" : "thread.unpin", + threadID: threadID, + commandID: commandID + ) + } + + public static func setRuntimeMode( + threadID: String, + mode: RuntimeMode, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.runtime-mode.set"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "runtimeMode": .string(mode.rawValue), + "createdAt": .string(createdAt), + ]) + } + + public static func setInteractionMode( + threadID: String, + mode: InteractionMode, + commandID: String = UUID().uuidString, + createdAt: String = now() + ) -> JSONValue { + .object([ + "type": .string("thread.interaction-mode.set"), + "commandId": .string(commandID), + "threadId": .string(threadID), + "interactionMode": .string(mode.rawValue), + "createdAt": .string(createdAt), + ]) + } + + private static func basic(type: String, threadID: String, commandID: String) -> JSONValue { + .object([ + "type": .string(type), + "commandId": .string(commandID), + "threadId": .string(threadID), + ]) + } + + public static func now() -> String { + iso8601.format(Date()) + } + + /// Commands are built on several actors, so their shared formatter must be Sendable. + private static let iso8601 = Date.ISO8601FormatStyle() +} diff --git a/apps/swift-ios/Core/UsageWireModels.swift b/apps/swift-ios/Core/UsageWireModels.swift new file mode 100644 index 000000000000..cc803af5cf83 --- /dev/null +++ b/apps/swift-ios/Core/UsageWireModels.swift @@ -0,0 +1,157 @@ +import Foundation + +public let usageContractVersion = 5 +public let minimumCompatibleUsageContractVersion = 3 + +/// Versions 3 through 5 contain every field this client needs. Keep them working +/// while servers update independently across a user's environments. +public func isCompatibleUsageContractVersion(_ version: Int) -> Bool { + (minimumCompatibleUsageContractVersion ... usageContractVersion).contains(version) +} + +public enum UsageProviderKind: String, Codable, CaseIterable, Sendable { + case codex + case claude + case grok + + public var displayName: String { + switch self { + case .codex: "Codex" + case .claude: "Claude Code" + case .grok: "Grok Build" + } + } +} + +public enum UsageCostSource: String, Codable, Sendable { + case providerReported + case modelPriced + case unpriced +} + +public enum UsageResolution: String, Codable, Equatable, Sendable { + case day + case hour +} + +public struct UsageSummaryInput: Codable, Equatable, Sendable { + public let sinceDay: String + public let untilDay: String + public let timeZone: String + public let resolution: UsageResolution? + public let sinceTime: String? + public let untilTime: String? + + public init( + sinceDay: String, + untilDay: String, + timeZone: String, + resolution: UsageResolution? = nil, + sinceTime: String? = nil, + untilTime: String? = nil + ) { + self.sinceDay = sinceDay + self.untilDay = untilDay + self.timeZone = timeZone + self.resolution = resolution + self.sinceTime = sinceTime + self.untilTime = untilTime + } +} + +public struct UsageTokenTotals: Codable, Equatable, Sendable { + public let uncachedInputTokens: Int + public let cachedInputTokens: Int + public let cacheCreationTokens: Int + public let outputTokens: Int + public let reasoningTokens: Int +} + +public struct UsageBucket: Codable, Equatable, Sendable { + public let day: String + public let hourStart: String? + public let provider: UsageProviderKind + public let model: String + public let totals: UsageTokenTotals + public let costUsd: Double + public let cacheSavingsUsd: Double + public let costSource: UsageCostSource + public let records: Int + public let unpricedRecords: Int + public let sessions: Int + + public init( + day: String, + hourStart: String? = nil, + provider: UsageProviderKind, + model: String, + totals: UsageTokenTotals, + costUsd: Double, + cacheSavingsUsd: Double, + costSource: UsageCostSource, + records: Int, + unpricedRecords: Int, + sessions: Int + ) { + self.day = day + self.hourStart = hourStart + self.provider = provider + self.model = model + self.totals = totals + self.costUsd = costUsd + self.cacheSavingsUsd = cacheSavingsUsd + self.costSource = costSource + self.records = records + self.unpricedRecords = unpricedRecords + self.sessions = sessions + } +} + +public struct UsageSourceFingerprint: Codable, Equatable, Hashable, Sendable { + public let hostId: String + public let provider: UsageProviderKind + public let resolvedHomePath: String + public let volumeId: String +} + +public enum UsageSourceStatus: String, Codable, Sendable { + case ok + case missing + case partial + case failed +} + +public struct UsageSource: Codable, Equatable, Sendable { + public let fingerprint: UsageSourceFingerprint + public let status: UsageSourceStatus + public let scannedFiles: Int + public let skippedFiles: Int + public let malformedRecords: Int + public let distinctSessions: Int + public let message: String? +} + +public enum UsagePricingStatus: String, Codable, Sendable { + case fresh + case cached + case unavailable +} + +public struct UsagePricing: Codable, Equatable, Sendable { + public let status: UsagePricingStatus + public let source: String + public let fetchedAt: String? + public let knownModels: Int +} + +public struct UsageSummary: Codable, Equatable, Sendable { + public let contractVersion: Int + public let readAt: String + public let timeZone: String + public let sinceDay: String + public let untilDay: String + public let buckets: [UsageBucket] + public let sources: [UsageSource] + public let pricing: UsagePricing + public let scanDurationMs: Int +} diff --git a/apps/swift-ios/Core/WebSocketRPC.swift b/apps/swift-ios/Core/WebSocketRPC.swift new file mode 100644 index 000000000000..a8329af32457 --- /dev/null +++ b/apps/swift-ios/Core/WebSocketRPC.swift @@ -0,0 +1,858 @@ +import Foundation +import OSLog + +public protocol WebSocketConnection: Sendable { + func send(_ data: Data) async throws + func receive() async throws -> Data + func close() async +} + +public protocol WebSocketConnecting: Sendable { + func connect(to url: URL) async throws -> any WebSocketConnection +} + +public enum WebSocketHandshakeRequest { + public static let perMessageDeflateOffer = "permessage-deflate; client_max_window_bits" + + /// URLSessionWebSocketTask accepts a URLRequest for its opening handshake. + /// It does not expose the 101 response headers or negotiated extensions, + /// so callers can know that compression was offered, not prove that a + /// particular connection accepted it. + public static func make( + url: URL, + offersPerMessageDeflate: Bool = true + ) -> URLRequest { + var request = URLRequest(url: url) + if offersPerMessageDeflate { + request.setValue( + perMessageDeflateOffer, + forHTTPHeaderField: "Sec-WebSocket-Extensions" + ) + } + return request + } +} + +public struct URLSessionWebSocketConnector: WebSocketConnecting { + private let session: URLSession + private let offersPerMessageDeflate: Bool + + public init( + session: URLSession = .shared, + offersPerMessageDeflate: Bool = true + ) { + self.session = session + self.offersPerMessageDeflate = offersPerMessageDeflate + } + + public func connect(to url: URL) async throws -> any WebSocketConnection { + let request = WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: offersPerMessageDeflate + ) + let connection = URLSessionWebSocketConnection(session: session, request: request) + await connection.open() + return connection + } +} + +private actor URLSessionWebSocketConnection: WebSocketConnection { + private let task: URLSessionWebSocketTask + + init(session: URLSession, request: URLRequest) { + task = session.webSocketTask(with: request) + } + + func open() { + task.resume() + } + + func send(_ data: Data) async throws { + try await task.send(.data(data)) + } + + func receive() async throws -> Data { + switch try await task.receive() { + case let .data(data): + return data + case let .string(string): + guard let data = string.data(using: .utf8) else { + throw RPCError.protocolViolation("WebSocket text was not UTF-8.") + } + return data + @unknown default: + throw RPCError.protocolViolation("Unknown WebSocket message.") + } + } + + func close() { + task.cancel(with: .goingAway, reason: nil) + } +} + +public enum RPCError: LocalizedError, Sendable { + case connectionUnavailable + case disconnected + case responseTimedOut + case remote(String) + case protocolViolation(String) + + public var errorDescription: String? { + switch self { + case .connectionUnavailable: + "The live command connection is unavailable." + case .disconnected: "The environment disconnected." + case .responseTimedOut: "The environment did not answer the command in time." + case let .remote(message): message + case let .protocolViolation(message): message + } + } +} + +private struct RPCRequestEnvelope: Encodable, Sendable { + let _tag = "Request" + let id: Int + let tag: String + let payload: JSONValue + let headers: [[String]] +} + +private struct RPCControlEnvelope: Encodable, Sendable { + let _tag: String + let requestId: Int? + + init(_ tag: String, requestID: Int? = nil) { + _tag = tag + requestId = requestID + } +} + +private struct RPCResponseEnvelope: Decodable, Sendable { + struct Exit: Decodable, Sendable { + struct Cause: Decodable, Sendable { + let _tag: String + let error: JSONValue? + let defect: JSONValue? + } + + let _tag: String + let value: JSONValue? + let cause: [Cause]? + } + + let _tag: String + let requestId: Int? + let values: [JSONValue]? + let exit: Exit? + let defect: JSONValue? +} + +/// Implements Effect RPC's JSON socket framing. Subscriptions survive +/// reconnects; unary calls that crossed a broken connection fail rather than +/// being replayed, because replaying a command could duplicate side effects. +public actor WebSocketRPCClient { + public typealias EndpointProvider = @Sendable () async throws -> URL + + private static let logger = Logger( + subsystem: "com.t3tools.t3code", + category: "WebSocketRPC" + ) + + private struct UnaryRequest { + let envelope: RPCRequestEnvelope + var sent: Bool + var connectionWaitTask: Task? + var sendDeadlineTask: Task? + var responseDeadlineTask: Task? + let resume: @Sendable (Result) -> Void + } + + private enum SubscriptionYieldResult: Sendable { + case enqueued + case dropped + case terminated + } + + private struct Subscription { + let tag: String + let payload: JSONValue + let reconnect: Bool + var requestID: Int? + /// The connection that assigned `requestID`. Request IDs are reissued + /// after reconnects, so an Interrupt is only valid on this connection. + var requestConnectionID: UUID? + let yield: @Sendable (JSONValue) -> SubscriptionYieldResult + let finish: @Sendable (Error?) -> Void + } + + private struct ConnectionAttempt: Sendable { + let connector: any WebSocketConnecting + let endpointProvider: EndpointProvider + } + + /// Long-lived tasks retain this box, never the client. Each actor hop + /// briefly promotes the weak reference, then releases it before the next + /// socket receive or reconnect wait. + private final class WeakOwner: @unchecked Sendable { + weak var value: WebSocketRPCClient? + + init(_ value: WebSocketRPCClient) { + self.value = value + } + + func connectionAttempt(loopID: UUID) async -> ConnectionAttempt? { + guard let value else { return nil } + return await value.connectionAttempt(loopID: loopID) + } + + func isCurrentConnectionLoop(_ loopID: UUID) async -> Bool { + guard let value else { return false } + return await value.isCurrentConnectionLoop(loopID) + } + + func installConnection( + _ connection: any WebSocketConnection, + loopID: UUID + ) async -> UUID? { + guard let value else { return nil } + return await value.installConnection(connection, loopID: loopID) + } + + func ownsConnection(loopID: UUID, connectionID: UUID) async -> Bool { + guard let value else { return false } + return await value.ownsConnection(loopID: loopID, connectionID: connectionID) + } + + func handle(_ data: Data, connectionID: UUID) async throws -> Bool { + guard let value else { return false } + return try await value.handle(data, expectedConnectionID: connectionID) + } + + func disconnected(connectionID: UUID) async -> Bool { + guard let value else { return false } + return await value.disconnected(expectedConnectionID: connectionID) + } + + func finishConnectionLoop(_ loopID: UUID) async { + guard let value else { return } + await value.finishConnectionLoop(loopID) + } + + func sendKeepalive(connectionID: UUID) async -> Bool { + guard let value else { return false } + return await value.sendKeepalive(expectedConnectionID: connectionID) + } + + func reconnectDelay(failureCount: Int, loopID: UUID) async -> Duration? { + guard let value else { return nil } + return await value.reconnectDelay(failureCount: failureCount, loopID: loopID) + } + } + + private let connector: any WebSocketConnecting + private let endpointProvider: EndpointProvider + private let connectionWaitTimeout: Duration + private let responseTimeout: Duration + private let keepaliveInterval: Duration + private let subscriptionBufferLimit: Int + private let reconnectBackoff: @Sendable (Int) -> Duration + private var connection: (any WebSocketConnection)? + private var connectionID: UUID? + private var loopTask: Task? + private var loopID: UUID? + private var keepaliveTask: Task? + private var desired = false + private var nextRequestID = 1 + private var unary: [Int: UnaryRequest] = [:] + private var subscriptions: [UUID: Subscription] = [:] + private var subscriptionByRequestID: [Int: UUID] = [:] + private var awaitingKeepaliveResponse = false + + public init( + connector: any WebSocketConnecting = URLSessionWebSocketConnector(), + connectionWaitTimeout: Duration = .seconds(4), + responseTimeout: Duration = .seconds(30), + keepaliveInterval: Duration = .seconds(5), + subscriptionBufferLimit: Int = 128, + reconnectBackoff: @escaping @Sendable (Int) -> Duration = { failureCount in + // Jitter desynchronizes reconnects across environments so a + // server restart doesn't trigger simultaneous ticket mints. + let backoff = min(5.0, 0.35 * pow(1.7, Double(failureCount - 1))) + return .seconds(backoff * Double.random(in: 0.5...1.0)) + }, + endpointProvider: @escaping EndpointProvider + ) { + self.connector = connector + self.connectionWaitTimeout = connectionWaitTimeout + self.responseTimeout = responseTimeout + self.keepaliveInterval = keepaliveInterval > .zero ? keepaliveInterval : .seconds(5) + self.subscriptionBufferLimit = max(1, subscriptionBufferLimit) + self.reconnectBackoff = reconnectBackoff + self.endpointProvider = endpointProvider + } + + deinit { + loopTask?.cancel() + keepaliveTask?.cancel() + } + + public func start() { + desired = true + guard loopTask == nil else { return } + let id = UUID() + loopID = id + let owner = WeakOwner(self) + loopTask = Task { + await Self.connectionLoop(owner: owner, id: id) + } + } + + public func isConnected() -> Bool { + connection != nil + } + + public func stop() async { + let closingConnection = connection + desired = false + loopID = nil + loopTask?.cancel() + loopTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + awaitingKeepaliveResponse = false + connection = nil + connectionID = nil + failUnary(RPCError.disconnected, includingUnsent: true) + let active = Array(subscriptions.values) + subscriptions.removeAll() + subscriptionByRequestID.removeAll() + active.forEach { $0.finish(RPCError.disconnected) } + // Publish the stopped state before suspension. A new start while the + // old socket closes owns independent state and must survive this call. + await closingConnection?.close() + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]), + as type: Result.Type + ) async throws -> Result { + let raw = try await requestRaw(tag, payload: payload) + return try raw.decode(type) + } + + public func request( + _ tag: String, + payload: JSONValue = .object([:]) + ) async throws { + _ = try await requestRaw(tag, payload: payload) + } + + public func subscribe( + _ tag: String, + payload: JSONValue = .object([:]), + reconnect: Bool = true, + as type: Value.Type + ) -> AsyncThrowingStream { + let subscriptionID = UUID() + return AsyncThrowingStream(bufferingPolicy: .bufferingOldest(subscriptionBufferLimit)) { + continuation in + subscriptions[subscriptionID] = Subscription( + tag: tag, + payload: payload, + reconnect: reconnect, + requestID: nil, + yield: { value in + do { + switch continuation.yield(try value.decode(type)) { + case .enqueued: + return .enqueued + case .dropped: + return .dropped + case .terminated: + return .terminated + @unknown default: + return .dropped + } + } catch { + continuation.finish(throwing: error) + return .terminated + } + }, + finish: { error in + if let error { + continuation.finish(throwing: error) + } else { + continuation.finish() + } + } + ) + continuation.onTermination = { @Sendable _ in + Task { await self.removeSubscription(subscriptionID) } + } + if connection != nil { + Task { await self.sendSubscription(subscriptionID) } + } + start() + } + } + + private func requestRaw(_ tag: String, payload: JSONValue) async throws -> JSONValue { + start() + let id = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: id, + tag: tag, + payload: payload, + headers: [] + ) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // If cancellation won the race before onCancel could observe + // an installed request, complete locally and never send it. + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + unary[id] = UnaryRequest( + envelope: envelope, + sent: false, + connectionWaitTask: nil, + sendDeadlineTask: nil, + responseDeadlineTask: nil, + resume: { continuation.resume(with: $0) } + ) + installUnaryDeadlines(id) + if connection != nil { + Task { await self.sendUnary(id) } + } + } + } onCancel: { + Task { await self.cancelUnary(id) } + } + } + + private static func connectionLoop(owner: WeakOwner, id loopID: UUID) async { + var retry = 0 + while await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled { + var openedID: UUID? + var openedConnection: (any WebSocketConnection)? + do { + guard let attempt = await owner.connectionAttempt(loopID: loopID) else { break } + let url = try await attempt.endpointProvider() + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + let opened = try await attempt.connector.connect(to: url) + openedConnection = opened + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { + await opened.close() + break + } + guard let id = await owner.installConnection(opened, loopID: loopID) else { + await opened.close() + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { + break + } + throw RPCError.disconnected + } + openedID = id + logger.info("WebSocket connection installed") + try await withTaskCancellationHandler { + while await owner.ownsConnection(loopID: loopID, connectionID: id), + !Task.isCancelled { + let data = try await opened.receive() + guard try await owner.handle(data, connectionID: id) else { + throw RPCError.disconnected + } + // URLSession's `resume()` does not expose a completed + // WebSocket handshake. A valid inbound frame is the + // first proof that the connection is actually usable. + retry = 0 + } + } onCancel: { + Task { await opened.close() } + } + if !(await owner.disconnected(connectionID: id)) { + await opened.close() + } + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + } catch { + logger.warning( + "WebSocket connection failed: \(String(describing: error), privacy: .private)" + ) + if let openedID { + if !(await owner.disconnected(connectionID: openedID)) { + await openedConnection?.close() + } + } else { + await openedConnection?.close() + } + guard await owner.isCurrentConnectionLoop(loopID), !Task.isCancelled else { break } + retry += 1 + guard let delay = await owner.reconnectDelay( + failureCount: retry, + loopID: loopID + ) else { break } + try? await Task.sleep(for: delay) + } + } + await owner.finishConnectionLoop(loopID) + } + + private func connectionAttempt(loopID: UUID) -> ConnectionAttempt? { + guard isCurrentConnectionLoop(loopID) else { return nil } + return ConnectionAttempt(connector: connector, endpointProvider: endpointProvider) + } + + private func reconnectDelay(failureCount: Int, loopID: UUID) -> Duration? { + guard isCurrentConnectionLoop(loopID) else { return nil } + return reconnectBackoff(failureCount) + } + + private func installConnection( + _ opened: any WebSocketConnection, + loopID: UUID + ) async -> UUID? { + guard isCurrentConnectionLoop(loopID), !Task.isCancelled else { return nil } + let id = UUID() + connection = opened + connectionID = id + await connected() + // Sending queued work during setup is actor-reentrant. A send failure + // can discard this socket before setup completes. + guard isCurrentConnectionLoop(loopID), connectionID == id else { return nil } + return id + } + + private func ownsConnection(loopID: UUID, connectionID: UUID) -> Bool { + isCurrentConnectionLoop(loopID) && self.connectionID == connectionID + } + + private func finishConnectionLoop(_ loopID: UUID) { + guard self.loopID == loopID else { return } + self.loopID = nil + loopTask = nil + } + + private func isCurrentConnectionLoop(_ id: UUID) -> Bool { + desired && loopID == id + } + + private func connected() async { + keepaliveTask?.cancel() + if let connectionID { + let owner = WeakOwner(self) + let interval = keepaliveInterval + keepaliveTask = Task { + await Self.keepaliveLoop(owner: owner, connectionID: connectionID, interval: interval) + } + } + // Snapshot the keys: the sends suspend, and reentrant completions or + // failures mutate these dictionaries mid-iteration. + for id in Array(unary.keys) { + await sendUnary(id) + } + subscriptionByRequestID.removeAll() + for id in Array(subscriptions.keys) { + await sendSubscription(id) + } + } + + @discardableResult + private func disconnected(expectedConnectionID: UUID? = nil) async -> Bool { + if let expectedConnectionID, connectionID != expectedConnectionID { + return false + } + let closingConnection = connection + keepaliveTask?.cancel() + keepaliveTask = nil + awaitingKeepaliveResponse = false + connection = nil + connectionID = nil + Self.logger.info("WebSocket connection closed") + failUnary(RPCError.disconnected, includingUnsent: false) + subscriptionByRequestID.removeAll() + let oneShotSubscriptions = subscriptions.filter { !$0.value.reconnect } + for (id, subscription) in oneShotSubscriptions { + subscriptions.removeValue(forKey: id) + subscription.finish(RPCError.disconnected) + } + for id in Array(subscriptions.keys) { + subscriptions[id]?.requestID = nil + subscriptions[id]?.requestConnectionID = nil + } + await closingConnection?.close() + return true + } + + private func handle(_ data: Data, expectedConnectionID: UUID) async throws -> Bool { + guard connectionID == expectedConnectionID else { return false } + let response = try JSONDecoder.t3.decode(RPCResponseEnvelope.self, from: data) + awaitingKeepaliveResponse = false + try await handle(response) + return connectionID == expectedConnectionID + } + + private func handle(_ response: RPCResponseEnvelope) async throws { + switch response._tag { + case "Pong": + return + case "Chunk": + guard let requestID = response.requestId, + let subscriptionID = subscriptionByRequestID[requestID], + let subscription = subscriptions[subscriptionID] + else { return } + for value in response.values ?? [] { + switch subscription.yield(value) { + case .enqueued: + continue + case .dropped: + let error = RPCError.protocolViolation( + "The live stream exceeded its buffered event limit." + ) + if !subscription.reconnect { + subscriptionByRequestID.removeValue(forKey: requestID) + subscriptions.removeValue(forKey: subscriptionID) + subscription.finish(error) + } + throw error + case .terminated: + await removeSubscription(subscriptionID) + return + } + } + try await sendControl("Ack", requestID: requestID) + case "Exit": + guard let requestID = response.requestId, let exit = response.exit else { return } + if unary[requestID] != nil { + if exit._tag == "Success" { + completeUnary(requestID, with: .success(exit.value ?? .null)) + } else { + completeUnary(requestID, with: .failure(remoteError(exit))) + } + return + } + guard let subscriptionID = subscriptionByRequestID.removeValue(forKey: requestID), + let subscription = subscriptions.removeValue(forKey: subscriptionID) + else { return } + subscription.finish(exit._tag == "Success" ? nil : remoteError(exit)) + case "Defect", "ClientProtocolError": + throw RPCError.remote( + response.defect?.stringValue ?? "The server reported an RPC protocol error." + ) + default: + throw RPCError.protocolViolation("Unknown RPC response \(response._tag).") + } + } + + private func sendUnary(_ id: Int) async { + guard let connection, + let connectionID, + var request = unary[id], + !request.sent else { return } + // Actor methods are reentrant at the send below. Record that this + // command crossed the socket boundary first so a concurrent + // disconnect fails it instead of replaying an ambiguous mutation. + request.sent = true + unary[id] = request + startUnarySendDeadline(id, connectionID: connectionID) + do { + try await connection.send(JSONEncoder.t3.encode(request.envelope)) + startUnaryResponseDeadline(id) + } catch { + // A response, disconnect, or stop may have completed the request + // while send was suspended. Only its current owner may resume it. + completeUnary(id, with: .failure(RPCError.disconnected)) + await disconnected(expectedConnectionID: connectionID) + } + } + + private func sendSubscription(_ subscriptionID: UUID) async { + guard let connection, + let connectionID, + var subscription = subscriptions[subscriptionID], + subscription.requestID == nil else { return } + let requestID = allocateRequestID() + let envelope = RPCRequestEnvelope( + id: requestID, + tag: subscription.tag, + payload: subscription.payload, + headers: [] + ) + // Install ownership before suspending in send. A very fast response can + // otherwise arrive before the request is routable, while termination + // during the send must be able to remove the exact in-flight mapping. + subscription.requestID = requestID + subscription.requestConnectionID = connectionID + subscriptions[subscriptionID] = subscription + subscriptionByRequestID[requestID] = subscriptionID + do { + try await connection.send(JSONEncoder.t3.encode(envelope)) + } catch { + // Retain the subscription for the next socket, but make the send + // failure visible to the connection loop by closing this socket. + await disconnected(expectedConnectionID: connectionID) + } + } + + private func removeSubscription(_ id: UUID) async { + guard let subscription = subscriptions.removeValue(forKey: id) else { return } + if let requestID = subscription.requestID { + subscriptionByRequestID.removeValue(forKey: requestID) + // A termination racing a reconnect must not interrupt whichever + // subscription now owns this request ID on the new connection. + if subscription.requestConnectionID == connectionID { + try? await sendControl("Interrupt", requestID: requestID) + } + } + } + + private static func keepaliveLoop( + owner: WeakOwner, + connectionID: UUID, + interval: Duration + ) async { + while !Task.isCancelled { + try? await Task.sleep(for: interval) + guard !Task.isCancelled, + await owner.sendKeepalive(connectionID: connectionID) else { return } + } + } + + private func sendKeepalive(expectedConnectionID: UUID) async -> Bool { + guard desired, connectionID == expectedConnectionID, connection != nil else { + return false + } + if awaitingKeepaliveResponse { + await disconnected(expectedConnectionID: expectedConnectionID) + return false + } + do { + awaitingKeepaliveResponse = true + try await sendControl("Ping", requestID: nil) + return connectionID == expectedConnectionID + } catch { + return false + } + } + + private func sendControl(_ tag: String, requestID: Int?) async throws { + guard let connection, let connectionID else { throw RPCError.disconnected } + do { + try await connection.send( + JSONEncoder.t3.encode(RPCControlEnvelope(tag, requestID: requestID)) + ) + } catch { + await disconnected(expectedConnectionID: connectionID) + throw RPCError.disconnected + } + } + + private func failUnary(_ error: Error, includingUnsent: Bool) { + let failedIDs = unary.compactMap { id, request in + includingUnsent || request.sent ? id : nil + } + for id in failedIDs { + completeUnary(id, with: .failure(error)) + } + } + + private func failUnaryIfUnsent(_ id: Int) { + guard let request = unary[id], !request.sent else { return } + completeUnary(id, with: .failure(RPCError.connectionUnavailable)) + } + + private func installUnaryDeadlines(_ id: Int) { + guard var request = unary[id] else { return } + let connectionWaitTimeout = connectionWaitTimeout + request.connectionWaitTask = Task { [weak self] in + do { + try await Task.sleep(for: connectionWaitTimeout) + } catch { + return + } + await self?.failUnaryIfUnsent(id) + } + unary[id] = request + } + + private func startUnaryResponseDeadline(_ id: Int) { + guard var request = unary[id], request.sent else { return } + request.connectionWaitTask?.cancel() + request.connectionWaitTask = nil + request.sendDeadlineTask?.cancel() + request.sendDeadlineTask = nil + let responseTimeout = responseTimeout + request.responseDeadlineTask = Task { [weak self] in + do { + try await Task.sleep(for: responseTimeout) + } catch { + return + } + await self?.failUnaryOnResponseDeadline(id) + } + unary[id] = request + } + + private func startUnarySendDeadline(_ id: Int, connectionID: UUID) { + guard var request = unary[id], request.sent else { return } + request.connectionWaitTask?.cancel() + request.connectionWaitTask = nil + let sendTimeout = responseTimeout + request.sendDeadlineTask = Task { [weak self] in + do { + try await Task.sleep(for: sendTimeout) + } catch { + return + } + await self?.failUnaryOnSendDeadline(id, connectionID: connectionID) + } + unary[id] = request + } + + private func failUnaryOnSendDeadline(_ id: Int, connectionID: UUID) async { + guard let request = unary[id], + request.sent, + request.responseDeadlineTask == nil else { return } + completeUnary(id, with: .failure(RPCError.responseTimedOut)) + await disconnected(expectedConnectionID: connectionID) + } + + private func failUnaryOnResponseDeadline(_ id: Int) async { + guard let request = unary[id] else { return } + let sent = request.sent + completeUnary(id, with: .failure(RPCError.responseTimedOut)) + if sent { + try? await sendControl("Interrupt", requestID: id) + } + } + + private func cancelUnary(_ id: Int) async { + guard let request = unary[id] else { return } + let sent = request.sent + completeUnary(id, with: .failure(CancellationError())) + if sent { + try? await sendControl("Interrupt", requestID: id) + } + } + + private func completeUnary(_ id: Int, with result: Result) { + guard let request = unary.removeValue(forKey: id) else { return } + request.connectionWaitTask?.cancel() + request.sendDeadlineTask?.cancel() + request.responseDeadlineTask?.cancel() + request.resume(result) + } + + private func remoteError(_ exit: RPCResponseEnvelope.Exit) -> RPCError { + let value = exit.cause?.first?.error + let message = value?["message"]?.stringValue + ?? value?["detail"]?.stringValue + ?? "The environment rejected the RPC request." + return .remote(message) + } + + private func allocateRequestID() -> Int { + defer { nextRequestID += 1 } + return nextRequestID + } +} diff --git a/apps/swift-ios/Core/WorkspaceModels.swift b/apps/swift-ios/Core/WorkspaceModels.swift new file mode 100644 index 000000000000..1d4eaeb5d3bf --- /dev/null +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -0,0 +1,576 @@ +import Foundation + +// MARK: - Project files + +public enum ProjectEntryKind: String, Codable, Sendable { + case file + case directory +} + +public struct ProjectEntry: Codable, Equatable, Sendable { + public let path: String + public let kind: ProjectEntryKind +} + +public struct ProjectEntriesResult: Codable, Equatable, Sendable { + public let entries: [ProjectEntry] + public let truncated: Bool +} + +public struct ProjectReadFileResult: Codable, Equatable, Sendable { + public let relativePath: String + public let contents: String + public let byteLength: Int + public let truncated: Bool +} + +public struct ProjectWriteFileResult: Codable, Equatable, Sendable { + public let relativePath: String +} + +public struct ThreadWorktreePreparation: Equatable, Sendable { + public let projectCwd: String + public let baseBranch: String + public let branch: String + public let startFromOrigin: Bool + + public init( + projectCwd: String, + baseBranch: String, + branch: String, + startFromOrigin: Bool + ) { + self.projectCwd = projectCwd + self.baseBranch = baseBranch + self.branch = branch + self.startFromOrigin = startFromOrigin + } +} + +public struct FilesystemBrowseEntry: Codable, Equatable, Sendable { + public let name: String + public let fullPath: String +} + +public struct FilesystemBrowseResult: Codable, Equatable, Sendable { + public let parentPath: String + public let entries: [FilesystemBrowseEntry] +} + +// MARK: - Source control and VCS + +public enum SourceControlProviderKind: String, Codable, CaseIterable, Sendable { + case github + case gitlab + case azureDevOps = "azure-devops" + case bitbucket + case unknown +} + +public struct SourceControlProviderInfo: Codable, Equatable, Sendable { + public let kind: SourceControlProviderKind + public let name: String + public let baseUrl: String +} + +public struct SourceControlRepositoryInfo: Codable, Equatable, Sendable { + public let provider: SourceControlProviderKind + public let nameWithOwner: String + public let url: String + public let sshUrl: String +} + +public struct SourceControlCloneResult: Codable, Equatable, Sendable { + public let cwd: String + public let remoteUrl: String + public let repository: SourceControlRepositoryInfo? +} + +public struct SourceControlPublishResult: Codable, Equatable, Sendable { + public let repository: SourceControlRepositoryInfo + public let remoteName: String + public let remoteUrl: String + public let branch: String + public let upstreamBranch: String? + public let status: String +} + +public enum SourceControlDiscoveryStatus: String, Codable, Sendable { + case available + case missing +} + +public enum SourceControlProviderAuthStatus: String, Codable, Sendable { + case authenticated + case unauthenticated + case unknown +} + +public struct SourceControlProviderAuth: Decodable, Equatable, Sendable { + public let status: SourceControlProviderAuthStatus + public let account: String? + public let host: String? + public let detail: String? + + private enum CodingKeys: String, CodingKey { + case status + case account + case host + case detail + } + + public init( + status: SourceControlProviderAuthStatus, + account: String? = nil, + host: String? = nil, + detail: String? = nil + ) { + self.status = status + self.account = account + self.host = host + self.detail = detail + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + status = try container.decode(SourceControlProviderAuthStatus.self, forKey: .status) + account = try container.decodeEffectOptionalString(forKey: .account) + host = try container.decodeEffectOptionalString(forKey: .host) + detail = try container.decodeEffectOptionalString(forKey: .detail) + } +} + +public struct SourceControlVCSDiscoveryItem: Decodable, Equatable, Sendable { + public let kind: String + public let label: String + public let executable: String? + public let implemented: Bool + public let status: SourceControlDiscoveryStatus + public let version: String? + public let installHint: String + public let detail: String? + + private enum CodingKeys: String, CodingKey { + case kind + case label + case executable + case implemented + case status + case version + case installHint + case detail + } + + public init( + kind: String, + label: String, + executable: String? = nil, + implemented: Bool, + status: SourceControlDiscoveryStatus, + version: String? = nil, + installHint: String, + detail: String? = nil + ) { + self.kind = kind + self.label = label + self.executable = executable + self.implemented = implemented + self.status = status + self.version = version + self.installHint = installHint + self.detail = detail + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + label = try container.decode(String.self, forKey: .label) + executable = try container.decodeIfPresent(String.self, forKey: .executable) + implemented = try container.decode(Bool.self, forKey: .implemented) + status = try container.decode(SourceControlDiscoveryStatus.self, forKey: .status) + version = try container.decodeEffectOptionalString(forKey: .version) + installHint = try container.decode(String.self, forKey: .installHint) + detail = try container.decodeEffectOptionalString(forKey: .detail) + } +} + +public struct SourceControlProviderDiscoveryItem: Decodable, Equatable, Sendable { + public let kind: SourceControlProviderKind + public let label: String + public let executable: String? + public let status: SourceControlDiscoveryStatus + public let version: String? + public let installHint: String + public let detail: String? + public let auth: SourceControlProviderAuth + + private enum CodingKeys: String, CodingKey { + case kind + case label + case executable + case status + case version + case installHint + case detail + case auth + } + + public init( + kind: SourceControlProviderKind, + label: String, + executable: String? = nil, + status: SourceControlDiscoveryStatus, + version: String? = nil, + installHint: String, + detail: String? = nil, + auth: SourceControlProviderAuth + ) { + self.kind = kind + self.label = label + self.executable = executable + self.status = status + self.version = version + self.installHint = installHint + self.detail = detail + self.auth = auth + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(SourceControlProviderKind.self, forKey: .kind) + label = try container.decode(String.self, forKey: .label) + executable = try container.decodeIfPresent(String.self, forKey: .executable) + status = try container.decode(SourceControlDiscoveryStatus.self, forKey: .status) + version = try container.decodeEffectOptionalString(forKey: .version) + installHint = try container.decode(String.self, forKey: .installHint) + detail = try container.decodeEffectOptionalString(forKey: .detail) + auth = try container.decode(SourceControlProviderAuth.self, forKey: .auth) + } +} + +public struct SourceControlDiscoveryResult: Decodable, Equatable, Sendable { + public let versionControlSystems: [SourceControlVCSDiscoveryItem] + public let sourceControlProviders: [SourceControlProviderDiscoveryItem] + + public init( + versionControlSystems: [SourceControlVCSDiscoveryItem], + sourceControlProviders: [SourceControlProviderDiscoveryItem] + ) { + self.versionControlSystems = versionControlSystems + self.sourceControlProviders = sourceControlProviders + } +} + +private struct EffectOptionalString: Decodable { + let value: String? + + private enum CodingKeys: String, CodingKey { + case _tag + case value + } + + init(from decoder: any Decoder) throws { + let singleValue = try decoder.singleValueContainer() + if singleValue.decodeNil() { + value = nil + return + } + if let direct = try? singleValue.decode(String.self) { + value = direct + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: ._tag) { + case "Some": + value = try container.decode(String.self, forKey: .value) + case "None": + value = nil + case let tag: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown Effect Option tag \(tag)" + ) + } + } +} + +private extension KeyedDecodingContainer { + func decodeEffectOptionalString(forKey key: Key) throws -> String? { + guard contains(key), try !decodeNil(forKey: key) else { return nil } + return try decode(EffectOptionalString.self, forKey: key).value + } +} + +public struct VCSWorkingTreeFile: Codable, Equatable, Sendable { + public let path: String + public let insertions: Int + public let deletions: Int +} + +public struct VCSWorkingTree: Codable, Equatable, Sendable { + public let files: [VCSWorkingTreeFile] + public let insertions: Int + public let deletions: Int +} + +public struct VCSChangeRequest: Codable, Equatable, Sendable { + public let number: Int + public let title: String + public let url: String + public let baseRef: String + public let headRef: String + public let state: String + public var updatedAt: String? = nil +} + +public struct VCSLocalStatus: Codable, Equatable, Sendable { + public let isRepo: Bool + public let sourceControlProvider: SourceControlProviderInfo? + public let hasPrimaryRemote: Bool + public let isDefaultRef: Bool + public let refName: String? + public let hasWorkingTreeChanges: Bool + public let workingTree: VCSWorkingTree +} + +public struct VCSRemoteStatus: Codable, Equatable, Sendable { + public let hasUpstream: Bool + public let aheadCount: Int + public let behindCount: Int + public let aheadOfDefaultCount: Int? + public let pr: VCSChangeRequest? +} + +public struct VCSStatus: Codable, Equatable, Sendable { + public let isRepo: Bool + public let sourceControlProvider: SourceControlProviderInfo? + public let hasPrimaryRemote: Bool + public let isDefaultRef: Bool + public let refName: String? + public let hasWorkingTreeChanges: Bool + public let workingTree: VCSWorkingTree + public let hasUpstream: Bool + public let aheadCount: Int + public let behindCount: Int + public let aheadOfDefaultCount: Int? + public let pr: VCSChangeRequest? +} + +public enum VCSStatusEvent: Decodable, Sendable { + case snapshot(local: VCSLocalStatus, remote: VCSRemoteStatus?) + case localUpdated(VCSLocalStatus) + case remoteUpdated(VCSRemoteStatus?) + + private enum CodingKeys: String, CodingKey { case _tag, local, remote } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let tag = try container.decode(String.self, forKey: ._tag) + switch tag { + case "snapshot": + self = .snapshot( + local: try container.decode(VCSLocalStatus.self, forKey: .local), + remote: try container.decodeIfPresent(VCSRemoteStatus.self, forKey: .remote) + ) + case "localUpdated": + self = .localUpdated(try container.decode(VCSLocalStatus.self, forKey: .local)) + case "remoteUpdated": + self = .remoteUpdated( + try container.decodeIfPresent(VCSRemoteStatus.self, forKey: .remote) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: ._tag, + in: container, + debugDescription: "Unknown VCS status event \(tag)" + ) + } + } +} + +public struct VCSRef: Codable, Equatable, Sendable { + public let name: String + public let isRemote: Bool? + public let remoteName: String? + public let current: Bool + public let isDefault: Bool + public let worktreePath: String? +} + +public struct VCSRefsResult: Codable, Equatable, Sendable { + public let refs: [VCSRef] + public let isRepo: Bool + public let hasPrimaryRemote: Bool + public let nextCursor: Int? + public let totalCount: Int +} + +public struct VCSPullResult: Codable, Equatable, Sendable { + public let status: String + public let refName: String + public let upstreamRef: String? +} + +public struct VCSCreateRefResult: Codable, Equatable, Sendable { + public let refName: String +} + +public struct VCSSwitchRefResult: Codable, Equatable, Sendable { + public let refName: String? +} + +public struct VCSWorktree: Codable, Equatable, Sendable { + public let path: String + public let refName: String +} + +public struct VCSCreateWorktreeResult: Codable, Equatable, Sendable { + public let worktree: VCSWorktree +} + +public enum GitStackedAction: String, Codable, CaseIterable, Sendable { + case commit + case push + case createPullRequest = "create_pr" + case commitAndPush = "commit_push" + case commitPushAndPullRequest = "commit_push_pr" +} + +public struct GitActionResult: Codable, Equatable, Sendable { + public struct Branch: Codable, Equatable, Sendable { + public let status: String + public let name: String? + } + + public struct Commit: Codable, Equatable, Sendable { + public let status: String + public let commitSha: String? + public let subject: String? + } + + public struct Push: Codable, Equatable, Sendable { + public let status: String + public let branch: String? + public let upstreamBranch: String? + public let setUpstream: Bool? + } + + public struct PullRequest: Codable, Equatable, Sendable { + public let status: String + public let url: String? + public let number: Int? + public let baseBranch: String? + public let headBranch: String? + public let title: String? + } + + public let action: GitStackedAction + public let branch: Branch + public let commit: Commit + public let push: Push + public let pr: PullRequest + public let toast: JSONValue +} + +public struct GitActionProgressEvent: Codable, Equatable, Sendable { + public let actionId: String + public let cwd: String + public let action: GitStackedAction + public let kind: String + public let phases: [String]? + public let phase: String? + public let label: String? + public let hookName: String? + public let stream: String? + public let text: String? + public let exitCode: Int? + public let durationMs: Int? + public let result: GitActionResult? + public let message: String? +} + +// MARK: - Review + +public struct ReviewDiffSource: Codable, Identifiable, Equatable, Sendable { + public let id: String + public let kind: String + public let title: String + public let baseRef: String? + public let headRef: String? + public let diff: String + public let diffHash: String + public let truncated: Bool +} + +public struct ReviewDiffPreview: Codable, Equatable, Sendable { + public let cwd: String + public let generatedAt: String + public let sources: [ReviewDiffSource] +} + +public struct ReviewDiffFileContents: Codable, Equatable, Sendable { + public let oldContents: String + public let newContents: String +} + +// MARK: - Terminal + +public enum TerminalSessionStatus: String, Codable, Sendable { + case starting + case running + case exited + case error +} + +public struct TerminalSessionSnapshot: Codable, Equatable, Sendable { + public let threadId: String + public let terminalId: String + public let cwd: String + public let worktreePath: String? + public let status: TerminalSessionStatus + public let pid: Int? + public let history: String + public let exitCode: Int? + public let exitSignal: Int? + public let label: String + public let updatedAt: String + public let sequence: Int? +} + +public struct TerminalSummary: Codable, Equatable, Sendable { + public let threadId: String + public let terminalId: String + public let cwd: String + public let worktreePath: String? + public let status: TerminalSessionStatus + public let pid: Int? + public let exitCode: Int? + public let exitSignal: Int? + public let hasRunningSubprocess: Bool + public let label: String + public let updatedAt: String +} + +public struct TerminalEvent: Codable, Equatable, Sendable { + public let type: String + public let threadId: String? + public let terminalId: String? + public let sequence: Int? + public let snapshot: TerminalSessionSnapshot? + public let data: String? + public let exitCode: Int? + public let exitSignal: Int? + public let message: String? + public let hasRunningSubprocess: Bool? + public let label: String? +} + +public struct TerminalMetadataEvent: Codable, Equatable, Sendable { + public let type: String + public let terminals: [TerminalSummary]? + public let terminal: TerminalSummary? + public let threadId: String? + public let terminalId: String? +} diff --git a/apps/swift-ios/DesignSystem/ProviderIcon.swift b/apps/swift-ios/DesignSystem/ProviderIcon.swift new file mode 100644 index 000000000000..7564049a870a --- /dev/null +++ b/apps/swift-ios/DesignSystem/ProviderIcon.swift @@ -0,0 +1,85 @@ +import SwiftUI + +enum ProviderBrand: String { + case openAI = "ProviderOpenAI" + case claude = "ProviderClaude" + case cursor = "ProviderCursor" + case grok = "ProviderGrok" + case openCode = "ProviderOpenCode" + + static func resolve( + driver: String, + providerID: String, + providerName: String = "" + ) -> ProviderBrand? { + for value in [driver, providerID, providerName] { + let normalized = value + .lowercased() + .filter(\.isLetter) + switch normalized { + case "codex", "codexcli", "openai", "openaicodex": + return .openAI + case "anthropic", "anthropicclaude", "claudeagent", "claude", "claudecode": + return .claude + case "cursor", "cursoragent": + return .cursor + case "grok", "xai", "xaigrok": + return .grok + case "opencode": + return .openCode + default: + continue + } + } + return nil + } + + var usesTemplateRendering: Bool { + switch self { + case .openAI, .cursor, .grok: true + case .claude, .openCode: false + } + } +} + +/// Displays the same provider artwork used by the web and marketing surfaces. +/// Unknown provider instances retain a compact initial so custom adapters remain legible. +struct ProviderIcon: View { + let driver: String + let providerID: String + let fallbackName: String + let size: CGFloat + + var body: some View { + Group { + if let brand = ProviderBrand.resolve( + driver: driver, + providerID: providerID, + providerName: fallbackName + ) { + Image(brand.rawValue) + .resizable() + .renderingMode(brand.usesTemplateRendering ? .template : .original) + .foregroundStyle(T3Colors.textSecondary) + .scaledToFit() + } else { + Text(fallbackInitial) + .font(.system(size: max(9, size * 0.44), weight: .bold)) + .foregroundStyle(T3Colors.textPrimary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + T3Colors.surfaceRaised, + in: RoundedRectangle(cornerRadius: max(4, size * 0.22)) + ) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private var fallbackInitial: String { + fallbackName.trimmingCharacters(in: .whitespacesAndNewlines) + .first + .map { String($0).uppercased() } ?? "?" + } +} diff --git a/apps/swift-ios/DesignSystem/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift new file mode 100644 index 000000000000..1c52485650de --- /dev/null +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -0,0 +1,107 @@ +import SwiftUI +import UIKit + +enum T3Colors { + // UIKit variants let recycled collection and terminal surfaces participate + // in the same system appearance changes as SwiftUI views. + static let uiBackground = adaptive(light: rgb(0xF2F2F7), dark: rgb(0x000000)) + static let uiTextPrimary = adaptive(light: rgb(0x262626), dark: rgb(0xF5F5F5)) + static let uiTextSecondary = adaptive(light: rgb(0x525252), dark: rgb(0xA3A3A3)) + static let uiSurfaceRaised = adaptive(light: rgb(0xF5F5F5), dark: rgb(0x1C1C1C)) + static let uiAccent = adaptive(light: rgb(0x007AFF), dark: rgb(0x0A84FF)) + + static let background = Color(uiColor: uiBackground) + static let sheet = color(light: rgb(0xF2F2F7, alpha: 0.98), dark: rgb(0x000000, alpha: 0.98)) + static let surface = color(light: rgb(0xFFFFFF), dark: rgb(0x171717)) + static let surfaceRaised = Color(uiColor: uiSurfaceRaised) + static let input = color(light: rgb(0xFFFFFF), dark: rgb(0x141414)) + static let border = color(light: rgb(0x000000, alpha: 0.08), dark: rgb(0xFFFFFF, alpha: 0.06)) + static let inputBorder = color( + light: rgb(0x000000, alpha: 0.10), dark: rgb(0xFFFFFF, alpha: 0.08)) + static let separator = color( + light: rgb(0x000000, alpha: 0.04), dark: rgb(0xFFFFFF, alpha: 0.03)) + static let subtle = color(light: rgb(0x000000, alpha: 0.04), dark: rgb(0xFFFFFF, alpha: 0.04)) + static let subtleStrong = color( + light: rgb(0x000000, alpha: 0.08), dark: rgb(0xFFFFFF, alpha: 0.08)) + static let shadow = color(light: rgb(0x000000, alpha: 0.18), dark: rgb(0x000000, alpha: 0.32)) + static let ledgerSurface = surface + static let ledgerSelected = surfaceRaised + + static let textPrimary = Color(uiColor: uiTextPrimary) + static let textSecondary = Color(uiColor: uiTextSecondary) + static let textTertiary = color(light: rgb(0x737373), dark: rgb(0x8E8E93)) + static let placeholder = color(light: rgb(0xA3A3A3), dark: rgb(0x8E8E93)) + + static let primaryAction = color(light: rgb(0x262626), dark: rgb(0xF5F5F5)) + static let primaryActionForeground = color(light: rgb(0xFFFFFF), dark: rgb(0x000000)) + static let accent = Color(uiColor: uiAccent) + static let statusRunning = color(light: rgb(0x0284C7), dark: rgb(0x22D3EE)) + static let statusInput = color(light: rgb(0x4F46E5), dark: rgb(0xA5B4FC)) + static let success = color(light: rgb(0x16A34A), dark: rgb(0x30D158)) + static let warning = color(light: rgb(0xD97706), dark: rgb(0xFF9F0A)) + static let danger = color(light: rgb(0xDC2626), dark: rgb(0xFF453A)) + + static let syntaxKeyword = color(light: rgb(0x7C3AED), dark: rgb(0xC78EFF)) + static let syntaxLiteral = color(light: rgb(0x2563EB), dark: rgb(0x8CC7FF)) + static let syntaxNumber = color(light: rgb(0xB45309), dark: rgb(0xEBAA6B)) + static let syntaxProperty = color(light: rgb(0x0F766E), dark: rgb(0x6BD1C2)) + + private static func color(light: UIColor, dark: UIColor) -> Color { + Color(uiColor: adaptive(light: light, dark: dark)) + } + + private static func adaptive(light: UIColor, dark: UIColor) -> UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark ? dark : light + } + } + + private static func rgb(_ hex: UInt32, alpha: CGFloat = 1) -> UIColor { + UIColor( + red: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: alpha + ) + } +} + +/// The native client uses semantic fonts so every surface follows Dynamic Type. +/// Keep roles here instead of introducing one-off point sizes in feature views. +enum T3Typography { + static let homeTitle = Font.system(.body, design: .default, weight: .semibold) + static let homeMetadata = Font.system(.footnote, design: .default) + + static let navigationTitle = Font.system(.headline, design: .default, weight: .semibold) + static let navigationMetadata = Font.system(.footnote, design: .default) + static let status = Font.system(.footnote, design: .default, weight: .semibold) + + static let threadBody = Font.system(.body, design: .default) + static let threadHeading1 = Font.system(.title2, design: .default, weight: .bold) + static let threadHeading2 = Font.system(.title3, design: .default, weight: .bold) + static let threadHeading3 = Font.system(.headline, design: .default, weight: .bold) + static let threadHeading4 = Font.system(.body, design: .default, weight: .semibold) + static let code = Font.system(.callout, design: .monospaced) + static let tool = Font.system(.footnote, design: .monospaced) + + static let composer = Font.system(.body, design: .default) + static let control = Font.system(.callout, design: .default, weight: .medium) + static let supporting = Font.system(.footnote, design: .default) + static let supportingStrong = Font.system(.footnote, design: .default, weight: .semibold) + static let eyebrow = Font.system(.footnote, design: .default, weight: .bold) +} + +enum T3Metrics { + static let minimumTapTarget: CGFloat = 44 + static let sidebarWidth: CGFloat = 320 + static let minimumSidebarWidth: CGFloat = 280 + static let maximumSidebarWidth: CGFloat = 380 + static let readingWidth: CGFloat = 760 +} + +extension View { + func t3NavigationChrome() -> some View { + toolbarBackground(T3Colors.sheet, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } +} diff --git a/apps/swift-ios/Extensions/Share/Info.plist b/apps/swift-ios/Extensions/Share/Info.plist new file mode 100644 index 000000000000..363bb1447cee --- /dev/null +++ b/apps/swift-ios/Extensions/Share/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDisplayName + $(T3CODE_SHARE_DISPLAY_NAME) + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationDictionaryVersion + 2 + NSExtensionActivationSupportsImageWithMaxCount + 8 + NSExtensionActivationSupportsMovieWithMaxCount + 8 + NSExtensionActivationSupportsFileWithMaxCount + 8 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).T3ShareViewController + + + diff --git a/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift new file mode 100644 index 000000000000..eece28decf1e --- /dev/null +++ b/apps/swift-ios/Extensions/Share/SharePayloadLoader.swift @@ -0,0 +1,306 @@ +import Foundation +import UniformTypeIdentifiers + +struct T3LoadedSharePayload: Sendable { + var textFragments: [String] + var images: [T3PendingShareImage] + var files: [T3PendingShareFile] + var warnings: [String] +} + +enum T3SharePayloadLoader { + @MainActor + static func load(from inputItems: [Any]) async -> T3LoadedSharePayload { + var textFragments: [String] = [] + var images: [T3PendingShareImage] = [] + var files: [T3PendingShareFile] = [] + var skippedOversizedImage = false + var skippedOversizedFile = false + var skippedExcessAttachment = false + + for case let item as NSExtensionItem in inputItems { + if let attributedText = item.attributedContentText?.string { + textFragments.append(attributedText) + } + + for provider in item.attachments ?? [] { + if let imageType = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .image) == true + }) { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try await loadStagedImage( + from: provider, + typeIdentifier: imageType + ) + images.append( + T3PendingShareImage( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: provider.suggestedName, + typeIdentifier: imageType + ) + ) + } catch T3SharePayloadLoaderError.imageTooLarge { + skippedOversizedImage = true + } catch { + // An image provider is terminal even if it also vends a + // URL or text representation. Falling through would + // silently turn a rejected attachment into other input. + } + continue + } + + if let fileType = provider.registeredTypeIdentifiers.first(where: { + guard let type = UTType($0) else { return false } + return (type.conforms(to: .movie) || type.conforms(to: .data)) + && !type.conforms(to: .url) + && !type.conforms(to: .text) + }) { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try await loadStagedFile( + from: provider, + typeIdentifier: fileType, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + files.append(T3PendingShareFile( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: provider.suggestedName, + mimeType: UTType(fileType)?.preferredMIMEType ?? "application/octet-stream" + )) + } catch T3SharePayloadLoaderError.fileTooLarge { + skippedOversizedFile = true + } catch { + // A file provider is terminal. Do not turn a rejected + // attachment into its URL or text representation. + } + continue + } + + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + let urlValue = try? await loadURLItem( + from: provider, + typeIdentifier: UTType.url.identifier + ) + { + if urlValue.isFileURL { + guard images.count + files.count < T3IncomingShareStore.maximumAttachmentCount else { + skippedExcessAttachment = true + continue + } + do { + let staged = try stageFile( + from: urlValue, + maximumBytes: T3IncomingShareStore.maximumFileBytes + ) + let type = UTType(filenameExtension: urlValue.pathExtension) + files.append(T3PendingShareFile( + stagedFileURL: staged.url, + byteCount: staged.byteCount, + suggestedName: urlValue.lastPathComponent, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + )) + } catch T3SharePayloadLoaderError.fileTooLarge { + skippedOversizedFile = true + } catch {} + } else { + textFragments.append(urlValue.absoluteString) + } + continue + } + + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier), + let text = try? await loadItemString( + from: provider, + typeIdentifier: UTType.plainText.identifier + ) + { + textFragments.append(text) + } + } + } + + var warnings: [String] = [] + if skippedOversizedImage { + warnings.append("One shared image exceeded the 10 MB attachment limit.") + } + if skippedOversizedFile { + warnings.append("One shared file exceeded the 50 MB attachment limit.") + } + if skippedExcessAttachment { + warnings.append( + "Only the first \(T3IncomingShareStore.maximumAttachmentCount) shared files were attached." + ) + } + return T3LoadedSharePayload( + textFragments: textFragments, + images: images, + files: files, + warnings: warnings + ) + } + + @MainActor + private static func loadStagedImage( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> (url: URL, byteCount: Int) { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + do { + guard let url else { + throw error ?? CocoaError(.fileReadUnknown) + } + continuation.resume(returning: try stageFile( + from: url, + maximumBytes: T3IncomingShareStore.maximumImageBytes, + oversizedError: .imageTooLarge + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// The provider-owned URL expires when its callback returns. Stream it to + /// an extension-owned temporary file while enforcing the byte limit, so a + /// malicious or enormous provider never has to be materialized in memory. + @MainActor + private static func loadStagedFile( + from provider: NSItemProvider, + typeIdentifier: String, + maximumBytes: Int + ) async throws -> (url: URL, byteCount: Int) { + try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + do { + guard let url else { throw error ?? CocoaError(.fileReadUnknown) } + continuation.resume(returning: try stageFile( + from: url, + maximumBytes: maximumBytes + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func stageFile( + from sourceURL: URL, + maximumBytes: Int, + oversizedError: T3SharePayloadLoaderError = .fileTooLarge + ) throws -> (url: URL, byteCount: Int) { + let values = try sourceURL.resourceValues(forKeys: [.isRegularFileKey]) + guard sourceURL.isFileURL, values.isRegularFile == true else { + throw CocoaError(.fileReadUnsupportedScheme) + } + let fileManager = FileManager.default + let stagingDirectory = fileManager.temporaryDirectory.appending( + path: "T3CodeShareStaging", + directoryHint: .isDirectory + ) + try fileManager.createDirectory( + at: stagingDirectory, + withIntermediateDirectories: true + ) + let stagedURL = stagingDirectory.appending( + path: UUID().uuidString.lowercased(), + directoryHint: .notDirectory + ) + guard fileManager.createFile(atPath: stagedURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + + do { + let source = try FileHandle(forReadingFrom: sourceURL) + let destination = try FileHandle(forWritingTo: stagedURL) + defer { + try? source.close() + try? destination.close() + } + + var byteCount = 0 + while let chunk = try source.read(upToCount: 64 * 1_024), !chunk.isEmpty { + try Task.checkCancellation() + byteCount += chunk.count + guard byteCount <= maximumBytes else { + throw oversizedError + } + try destination.write(contentsOf: chunk) + } + guard byteCount > 0 else { throw CocoaError(.fileReadCorruptFile) } + return (stagedURL, byteCount) + } catch { + try? fileManager.removeItem(at: stagedURL) + throw error + } + } + + @MainActor + private static func loadURLItem( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier) { value, error in + if let value, let url = url(from: value) { + continuation.resume(returning: url) + } else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + } + } + } + + @MainActor + private static func loadItemString( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier) { value, error in + if let value, + let text = textString(from: value) { + continuation.resume(returning: text) + } else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + } + } + } + + private static func url(from value: NSSecureCoding) -> URL? { + if let url = value as? URL { + return url + } + if let text = value as? String, let url = URL(string: text) { + return url + } + return nil + } + + private static func textString(from value: NSSecureCoding) -> String? { + if let text = value as? String { + return text + } + if let attributedText = value as? NSAttributedString { + return attributedText.string + } + return nil + } +} + +private enum T3SharePayloadLoaderError: Error { + case imageTooLarge + case fileTooLarge +} diff --git a/apps/swift-ios/Extensions/Share/ShareViewController.swift b/apps/swift-ios/Extensions/Share/ShareViewController.swift new file mode 100644 index 000000000000..014ffdead158 --- /dev/null +++ b/apps/swift-ios/Extensions/Share/ShareViewController.swift @@ -0,0 +1,190 @@ +import SwiftUI +import UIKit + +final class T3ShareViewController: UIViewController { + private var hostingController: UIHostingController? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let content = T3ShareExtensionView( + save: { [weak self] in + let inputItems = self?.extensionContext?.inputItems ?? [] + let payload = await T3SharePayloadLoader.load(from: inputItems) + return try await Task.detached { + try T3IncomingShareStore.write( + textFragments: payload.textFragments, + images: payload.images, + files: payload.files, + warnings: payload.warnings + ) + }.value + }, + cancel: { [weak self] in + self?.extensionContext?.cancelRequest(withError: CocoaError(.userCancelled)) + }, + complete: { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } + ) + let hostingController = UIHostingController(rootView: content) + hostingController.view.backgroundColor = .systemBackground + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hostingController.didMove(toParent: self) + self.hostingController = hostingController + } +} + +struct T3ShareExtensionView: View { + enum Phase: Equatable { + case ready + case saving + case saved(imageCount: Int) + case failed(message: String) + } + + let save: () async throws -> T3IncomingShareEnvelope + let cancel: () -> Void + let complete: () -> Void + + @State private var phase = Phase.ready + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Cancel", action: cancel) + .foregroundStyle(.secondary) + .disabled(isSaving) + Spacer() + Text("T3 Code") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Color.clear.frame(width: 52, height: 1) + } + .padding(.horizontal, 18) + .padding(.vertical, 15) + + Divider() + + VStack(spacing: 14) { + Image(systemName: phaseSymbol) + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(phaseTint) + .accessibilityHidden(true) + Text(title) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + Text(message) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 28) + .padding(.vertical, 24) + + Button(action: primaryAction) { + Text(primaryTitle) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color(uiColor: .systemBackground)) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color(uiColor: .label), in: RoundedRectangle(cornerRadius: 13)) + } + .buttonStyle(.plain) + .disabled(isSaving) + .opacity(isSaving ? 0.55 : 1) + .padding(.horizontal, 18) + .padding(.bottom, 18) + } + .background(Color(uiColor: .systemBackground).ignoresSafeArea()) + } + + private var isSaving: Bool { + phase == .saving + } + + private var title: String { + switch phase { + case .ready: "Add to a new task" + case .saving: "Saving shared content" + case .saved: "Ready in T3 Code" + case .failed: "Could not add this" + } + } + + private var message: String { + switch phase { + case .ready: + "Text, links, and up to eight files will be waiting in the native composer." + case .saving: + "Keeping a durable copy so nothing gets lost." + case let .saved(imageCount): + imageCount == 0 + ? "Open T3 Code to choose a project and send it." + : "Saved \(imageCount) image\(imageCount == 1 ? "" : "s"). Open T3 Code to choose a project." + case let .failed(message): + message + } + } + + private var phaseSymbol: String { + switch phase { + case .ready: "square.and.arrow.up" + case .saving: "arrow.down.doc" + case .saved: "checkmark.circle.fill" + case .failed: "exclamationmark.triangle.fill" + } + } + + private var phaseTint: Color { + switch phase { + case .saved: Color(uiColor: .systemGreen) + case .failed: Color(uiColor: .systemRed) + default: Color(uiColor: .label) + } + } + + private var primaryTitle: String { + switch phase { + case .ready: "Add to T3 Code" + case .saving: "Saving…" + case .saved: "Done" + case .failed: "Try again" + } + } + + private func primaryAction() { + switch phase { + case .ready, .failed: + phase = .saving + Task { + do { + let envelope = try await save() + phase = .saved(imageCount: envelope.images.count + envelope.files.count) + } catch { + phase = .failed( + message: (error as? LocalizedError)?.errorDescription + ?? "The shared content could not be saved." + ) + } + } + case .saved: + complete() + case .saving: + break + } + } +} diff --git a/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements b/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements new file mode 100644 index 000000000000..87c87298c6ae --- /dev/null +++ b/apps/swift-ios/Extensions/Share/T3CodeShare.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift b/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift new file mode 100644 index 000000000000..c79d515534ba --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/AgentActivityAttributes.swift @@ -0,0 +1,119 @@ +import ActivityKit +import Foundation + +enum T3AgentActivityPhase: String, Codable, Hashable, Sendable { + case starting + case running + case waitingForApproval = "waiting_for_approval" + case waitingForInput = "waiting_for_input" + case completed + case failed + case stale + + var systemImage: String { + switch self { + case .starting: + "circle.dotted" + case .running: + "arrow.trianglehead.2.clockwise.rotate.90" + case .waitingForApproval: + "exclamationmark.circle.fill" + case .waitingForInput: + "questionmark.circle.fill" + case .completed: + "checkmark.circle.fill" + case .failed: + "xmark.octagon.fill" + case .stale: + "clock.arrow.circlepath" + } + } +} + +/// Mirrors `RelayAgentActivityAggregateRow` in packages/contracts/src/relay.ts. +struct T3RelayAgentActivityAggregateRow: Codable, Hashable, Identifiable, Sendable { + var environmentId: String + var threadId: String + var projectTitle: String + var threadTitle: String + var modelTitle: String + var phase: T3AgentActivityPhase + var status: String + var updatedAt: String + var deepLink: String + + var id: String { "\(environmentId):\(threadId)" } + + /// Generate the native query route rather than trusting a web-shaped path. + var nativeDeepLinkURL: URL? { + var components = URLComponents() + components.scheme = T3SharedContainer.urlScheme + components.host = "threads" + components.queryItems = [ + URLQueryItem(name: "environment", value: environmentId), + URLQueryItem(name: "thread", value: threadId), + ] + return components.url + } +} + +/// Mirrors `RelayAgentActivityAggregateState` in packages/contracts/src/relay.ts. +struct T3RelayAgentActivityAggregateState: Codable, Hashable, Sendable { + var title: String + var subtitle: String + var activeCount: Int + var updatedAt: String + var activities: [T3RelayAgentActivityAggregateRow] + + var attentionFirstActivities: [T3RelayAgentActivityAggregateRow] { + activities.sorted { left, right in + let leftPriority = left.phase.presentationPriority + let rightPriority = right.phase.presentationPriority + return leftPriority == rightPriority + ? left.updatedAt > right.updatedAt + : leftPriority < rightPriority + } + } +} + +/// This exact type and state envelope are part of the relay/APNs protocol. +/// The relay sends `attributes-type: LiveActivityAttributes`, empty attributes, +/// and `{ name: "AgentActivity", props: "" }` as content state. +struct LiveActivityAttributes: ActivityAttributes, Hashable { + struct ContentState: Codable, Hashable, Sendable { + var name: String + var props: String + + var aggregate: T3RelayAgentActivityAggregateState? { + guard name == LiveActivityAttributes.activityName, + let data = props.data(using: .utf8) + else { + return nil + } + return try? JSONDecoder().decode(T3RelayAgentActivityAggregateState.self, from: data) + } + + init(name: String, props: String) { + self.name = name + self.props = props + } + + init(aggregate: T3RelayAgentActivityAggregateState) throws { + name = LiveActivityAttributes.activityName + props = String(decoding: try JSONEncoder().encode(aggregate), as: UTF8.self) + } + } + + static let activityName = "AgentActivity" +} + +extension T3AgentActivityPhase { + fileprivate var presentationPriority: Int { + switch self { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } +} diff --git a/apps/swift-ios/Extensions/Shared/ShareInbox.swift b/apps/swift-ios/Extensions/Shared/ShareInbox.swift new file mode 100644 index 000000000000..75fa3d7b6031 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/ShareInbox.swift @@ -0,0 +1,349 @@ +import Foundation + +struct T3IncomingShareImage: Codable, Hashable, Identifiable, Sendable { + var id: String + var fileName: String + var typeIdentifier: String + var relativePath: String + var byteCount: Int +} + +struct T3IncomingShareFile: Codable, Hashable, Identifiable, Sendable { + var id: String + var fileName: String + var mimeType: String + var relativePath: String + var byteCount: Int +} + +struct T3IncomingShareEnvelope: Codable, Hashable, Identifiable, Sendable { + static let schemaVersion = 2 + + var schemaVersion: Int + var id: String + var createdAt: Date + var text: String + var images: [T3IncomingShareImage] + var files: [T3IncomingShareFile] + var warnings: [String] + + init( + schemaVersion: Int, + id: String, + createdAt: Date, + text: String, + images: [T3IncomingShareImage], + files: [T3IncomingShareFile] = [], + warnings: [String] + ) { + self.schemaVersion = schemaVersion + self.id = id + self.createdAt = createdAt + self.text = text + self.images = images + self.files = files + self.warnings = warnings + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, id, createdAt, text, images, files, warnings + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decode(Int.self, forKey: .schemaVersion) + id = try values.decode(String.self, forKey: .id) + createdAt = try values.decode(Date.self, forKey: .createdAt) + text = try values.decode(String.self, forKey: .text) + images = try values.decodeIfPresent([T3IncomingShareImage].self, forKey: .images) ?? [] + files = try values.decodeIfPresent([T3IncomingShareFile].self, forKey: .files) ?? [] + warnings = try values.decodeIfPresent([String].self, forKey: .warnings) ?? [] + } +} + +struct T3PendingShareImage: Sendable { + var stagedFileURL: URL + var byteCount: Int + var suggestedName: String? + var typeIdentifier: String +} + +struct T3PendingShareFile: Sendable { + var stagedFileURL: URL + var byteCount: Int + var suggestedName: String? + var mimeType: String +} + +enum T3IncomingShareStoreError: LocalizedError { + case appGroupUnavailable + case noSupportedContent + + var errorDescription: String? { + switch self { + case .appGroupUnavailable: + "T3 Code could not access its shared inbox." + case .noSupportedContent: + "This app did not provide text, a URL, or a supported file." + } + } +} + +/// A crash-safe handoff from the short-lived share extension to the host app. +/// Each share gets its own UUID directory and an atomically-written manifest. +enum T3IncomingShareStore { + static let inboxRelativePath = "Library/Application Support/T3Code/IncomingShares" + static let manifestFileName = "manifest.json" + static let maximumImageCount = 8 + static let maximumImageBytes = 10 * 1_024 * 1_024 + static let maximumAttachmentCount = 8 + static let maximumFileBytes = 50 * 1_024 * 1_024 + + static func write( + textFragments: [String], + images: [T3PendingShareImage], + files: [T3PendingShareFile] = [], + warnings initialWarnings: [String] = [], + now: Date = Date(), + id: String = UUID().uuidString.lowercased() + ) throws -> T3IncomingShareEnvelope { + guard let containerURL = T3SharedContainer.rootURL else { + throw T3IncomingShareStoreError.appGroupUnavailable + } + defer { + for url in images.map(\.stagedFileURL) + files.map(\.stagedFileURL) { + try? FileManager.default.removeItem(at: url) + } + } + + let normalizedText = deduplicatedText(textFragments) + let itemDirectory = containerURL + .appending(path: inboxRelativePath, directoryHint: .isDirectory) + .appending(path: id, directoryHint: .isDirectory) + var warnings = initialWarnings + var savedImages: [T3IncomingShareImage] = [] + var savedFiles: [T3IncomingShareFile] = [] + var validOverflowCount = 0 + + do { + try FileManager.default.createDirectory( + at: itemDirectory, + withIntermediateDirectories: true + ) + + for image in images { + let values = try? image.stagedFileURL.resourceValues(forKeys: [ + .fileSizeKey, + .isRegularFileKey, + ]) + guard values?.isRegularFile == true, + let byteCount = values?.fileSize, + byteCount > 0, + byteCount <= maximumImageBytes, + byteCount == image.byteCount else { + warnings.append("One shared image exceeded the 10 MB attachment limit.") + continue + } + guard savedImages.count + savedFiles.count < maximumAttachmentCount else { + validOverflowCount += 1 + continue + } + + let attachmentID = UUID().uuidString.lowercased() + let fileName = safeFileName( + image.suggestedName, + fallback: "shared-image-\(savedImages.count + 1).\(fileExtension(for: image.typeIdentifier))" + ) + let storedName = "\(attachmentID)-\(fileName)" + let fileURL = itemDirectory.appending(path: storedName, directoryHint: .notDirectory) + try FileManager.default.copyItem(at: image.stagedFileURL, to: fileURL) + savedImages.append( + T3IncomingShareImage( + id: attachmentID, + fileName: fileName, + typeIdentifier: image.typeIdentifier, + relativePath: "\(inboxRelativePath)/\(id)/\(storedName)", + byteCount: byteCount + ) + ) + } + + for file in files { + let values = try? file.stagedFileURL.resourceValues(forKeys: [ + .fileSizeKey, .isRegularFileKey, + ]) + guard values?.isRegularFile == true, + let byteCount = values?.fileSize, + byteCount > 0, + byteCount <= maximumFileBytes, + byteCount == file.byteCount else { + warnings.append("One shared file exceeded the 50 MB attachment limit.") + continue + } + guard savedImages.count + savedFiles.count < maximumAttachmentCount else { + validOverflowCount += 1 + continue + } + let attachmentID = UUID().uuidString.lowercased() + let fileName = safeFileName(file.suggestedName, fallback: "shared-file-\(savedFiles.count + 1)") + let storedName = "\(attachmentID)-\(fileName)" + let fileURL = itemDirectory.appending(path: storedName, directoryHint: .notDirectory) + try FileManager.default.copyItem(at: file.stagedFileURL, to: fileURL) + savedFiles.append(T3IncomingShareFile( + id: attachmentID, + fileName: fileName, + mimeType: safeMIMEType(file.mimeType), + relativePath: "\(inboxRelativePath)/\(id)/\(storedName)", + byteCount: byteCount + )) + } + + if validOverflowCount > 0 { + warnings.append("Only the first \(maximumAttachmentCount) shared files were attached.") + } + + guard !normalizedText.isEmpty || !savedImages.isEmpty || !savedFiles.isEmpty else { + throw T3IncomingShareStoreError.noSupportedContent + } + + let envelope = T3IncomingShareEnvelope( + schemaVersion: T3IncomingShareEnvelope.schemaVersion, + id: id, + createdAt: now, + text: normalizedText, + images: savedImages, + files: savedFiles, + warnings: warnings + ) + let manifestURL = itemDirectory.appending( + path: manifestFileName, + directoryHint: .notDirectory + ) + try encoder.encode(envelope).write(to: manifestURL, options: .atomic) + return envelope + } catch { + try? FileManager.default.removeItem(at: itemDirectory) + throw error + } + } + + static func loadAll() -> [T3IncomingShareEnvelope] { + guard let containerURL = T3SharedContainer.rootURL else { return [] } + let inboxURL = containerURL.appending(path: inboxRelativePath, directoryHint: .isDirectory) + guard let directories = try? FileManager.default.contentsOfDirectory( + at: inboxURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return directories.compactMap { directory in + let manifestURL = directory.appending(path: manifestFileName, directoryHint: .notDirectory) + guard let data = try? Data(contentsOf: manifestURL) else { return nil } + return try? decoder.decode(T3IncomingShareEnvelope.self, from: data) + } + .filter { $0.schemaVersion == 1 || $0.schemaVersion == T3IncomingShareEnvelope.schemaVersion } + .sorted { $0.createdAt < $1.createdAt } + } + + static func remove(id: String) throws { + guard let containerURL = T3SharedContainer.rootURL else { + throw T3IncomingShareStoreError.appGroupUnavailable + } + guard UUID(uuidString: id) != nil else { + throw T3IncomingShareStoreError.noSupportedContent + } + let inboxURL = containerURL + .appending(path: inboxRelativePath, directoryHint: .isDirectory) + .standardizedFileURL + let itemURL = inboxURL + .appending(path: id, directoryHint: .isDirectory) + .standardizedFileURL + guard itemURL.deletingLastPathComponent() == inboxURL else { + throw T3IncomingShareStoreError.noSupportedContent + } + guard FileManager.default.fileExists(atPath: itemURL.path) else { return } + try FileManager.default.removeItem(at: itemURL) + } + + static func fileURL(for image: T3IncomingShareImage) -> URL? { + fileURL(relativePath: image.relativePath) + } + + static func fileURL(for file: T3IncomingShareFile) -> URL? { + fileURL(relativePath: file.relativePath) + } + + private static func fileURL(relativePath: String) -> URL? { + guard let root = T3SharedContainer.rootURL else { return nil } + return fileURL(relativePath: relativePath, rootURL: root) + } + + static func fileURL(relativePath: String, rootURL: URL) -> URL? { + let root = rootURL.standardizedFileURL.resolvingSymlinksInPath() + let inbox = root.appending(path: inboxRelativePath, directoryHint: .isDirectory) + .standardizedFileURL.resolvingSymlinksInPath() + let url = root.appending(path: relativePath, directoryHint: .notDirectory) + .standardizedFileURL.resolvingSymlinksInPath() + guard url.path.hasPrefix(inbox.path + "/") else { return nil } + return url + } + + private static func safeMIMEType(_ proposed: String) -> String { + let value = proposed.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "!#$&^_.+-/")) + guard value.count <= 100, + value.filter({ $0 == "/" }).count == 1, + value.unicodeScalars.allSatisfy(allowed.contains) else { + return "application/octet-stream" + } + return value + } + + private static func deduplicatedText(_ fragments: [String]) -> String { + var seen: Set = [] + return fragments.compactMap { fragment in + let value = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, seen.insert(value).inserted else { return nil } + return value + }.joined(separator: "\n\n") + } + + private static func safeFileName(_ proposed: String?, fallback: String) -> String { + let candidate = proposed?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lastPathComponent = URL(fileURLWithPath: candidate).lastPathComponent + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_ ")) + let sanitized = String(lastPathComponent.unicodeScalars.filter(allowed.contains)).prefix(96) + guard !sanitized.isEmpty else { return fallback } + let value = String(sanitized) + let pathExtension = URL(fileURLWithPath: value).pathExtension + guard pathExtension.count <= 16 else { + return URL(fileURLWithPath: value).deletingPathExtension().lastPathComponent + } + return value + } + + private static func fileExtension(for typeIdentifier: String) -> String { + switch typeIdentifier.lowercased() { + case "public.jpeg", "public.jpg", "image/jpeg": "jpg" + case "public.heic", "image/heic": "heic" + case "public.webp", "image/webp": "webp" + case "com.compuserve.gif", "image/gif": "gif" + default: "png" + } + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/apps/swift-ios/Extensions/Shared/SharedContainer.swift b/apps/swift-ios/Extensions/Shared/SharedContainer.swift new file mode 100644 index 000000000000..da5a052edccd --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/SharedContainer.swift @@ -0,0 +1,29 @@ +import Foundation + +enum T3SharedContainer { + #if DEBUG + private static let defaultAppGroupID = "group.com.t3tools.t3code.swiftui.dev" + static let urlScheme = "t3code-swiftui-dev" + #else + private static let defaultAppGroupID = "group.com.t3tools.t3code.swiftui" + static let urlScheme = "t3code-swiftui" + #endif + + /// The App Group shared by the app, widgets, and share extension. Each + /// bundle's Info.plist carries `T3CodeAppGroupIdentifier` from the + /// `T3CODE_APP_GROUP_IDENTIFIER` build setting, so a local identity + /// override (Config/Local.xcconfig) reaches the extensions as well. + static let appGroupID: String = { + if let value = Bundle.main.object(forInfoDictionaryKey: "T3CodeAppGroupIdentifier") as? String, + !value.isEmpty { + return value + } + return defaultAppGroupID + }() + + static var rootURL: URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) + } +} diff --git a/apps/swift-ios/Extensions/Shared/T3Code.entitlements b/apps/swift-ios/Extensions/Shared/T3Code.entitlements new file mode 100644 index 000000000000..703abe40ace0 --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/T3Code.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + $(APS_ENVIRONMENT) + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + webcredentials:clerk.t3.codes + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift b/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift new file mode 100644 index 000000000000..22f9dd30443b --- /dev/null +++ b/apps/swift-ios/Extensions/Shared/TaskWidgetSnapshot.swift @@ -0,0 +1,47 @@ +import Foundation + +struct T3TaskWidgetSnapshot: Codable, Hashable, Sendable { + static let empty = T3TaskWidgetSnapshot(updatedAt: "", tasks: []) + + var updatedAt: String + var tasks: [T3RelayAgentActivityAggregateRow] +} + +/// The host writes one small snapshot after task-state changes; the widget only +/// performs a bounded file read when WidgetKit requests a timeline. +enum T3TaskWidgetSnapshotStore { + static let fileName = "task-widget-snapshot.json" + + static func load() -> T3TaskWidgetSnapshot { + guard let url = fileURL(), + let data = try? Data(contentsOf: url), + let snapshot = try? JSONDecoder().decode(T3TaskWidgetSnapshot.self, from: data) + else { + return .empty + } + return snapshot + } + + static func save(_ snapshot: T3TaskWidgetSnapshot) throws { + guard let url = fileURL() else { + throw CocoaError(.fileNoSuchFile) + } + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try encoder.encode(snapshot).write(to: url, options: .atomic) + } + + private static func fileURL() -> URL? { + T3SharedContainer.rootURL? + .appending(path: "Library/Application Support/T3Code", directoryHint: .isDirectory) + .appending(path: fileName, directoryHint: .notDirectory) + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() +} diff --git a/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift new file mode 100644 index 000000000000..3e531463fc6f --- /dev/null +++ b/apps/swift-ios/Extensions/Tests/ExtensionContractTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import T3Code + +final class ExtensionContractTests: XCTestCase { + func testLiveActivityDecodesTheRelayAPNSEnvelope() throws { + let props = #"{"title":"T3 Code","subtitle":"2 active agents, 1 needs attention","activeCount":2,"updatedAt":"2026-08-01T12:00:00.000Z","activities":[{"environmentId":"env-1","threadId":"thread-working","projectTitle":"t3code","threadTitle":"Build the native app","modelTitle":"GPT-5.6 Sol","phase":"running","status":"Working","updatedAt":"2026-08-01T12:00:00.000Z","deepLink":"/env-1/thread-working"},{"environmentId":"env-2","threadId":"thread-approval","projectTitle":"uploadthing","threadTitle":"Ship upload recovery","modelTitle":"Claude Opus 5","phase":"waiting_for_approval","status":"Approval","updatedAt":"2026-08-01T11:59:00.000Z","deepLink":"/env-2/thread-approval"}]}"# + let state = LiveActivityAttributes.ContentState( + name: "AgentActivity", + props: props + ) + + let aggregate = try XCTUnwrap(state.aggregate) + XCTAssertEqual(aggregate.activeCount, 2) + XCTAssertEqual(aggregate.activities.count, 2) + XCTAssertEqual(aggregate.attentionFirstActivities.first?.threadId, "thread-approval") + XCTAssertEqual( + aggregate.attentionFirstActivities.first?.nativeDeepLinkURL?.absoluteString, + "\(T3SharedContainer.urlScheme)://threads?environment=env-2&thread=thread-approval" + ) + } + + func testLocalLiveActivityStatePreservesTheExactNameAndPropsKeys() throws { + let aggregate = T3RelayAgentActivityAggregateState( + title: "T3 Code", + subtitle: "1 active agent", + activeCount: 1, + updatedAt: "2026-08-01T12:00:00.000Z", + activities: [] + ) + let state = try LiveActivityAttributes.ContentState(aggregate: aggregate) + let encoded = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(state)) as? [String: Any] + ) + + XCTAssertEqual(Set(encoded.keys), Set(["name", "props"])) + XCTAssertEqual(encoded["name"] as? String, "AgentActivity") + XCTAssertEqual(state.aggregate, aggregate) + } + + func testUnexpectedActivityNamesNeverDecodeAsAgentState() { + let state = LiveActivityAttributes.ContentState(name: "Other", props: "{}") + XCTAssertNil(state.aggregate) + } +} diff --git a/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift new file mode 100644 index 000000000000..fe077760ee91 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift @@ -0,0 +1,180 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct T3TaskLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LiveActivityAttributes.self) { context in + T3LiveActivityLockScreenView(context: context) + .activityBackgroundTint(Color(uiColor: .systemBackground)) + .activitySystemActionForegroundColor(Color(uiColor: .label)) + .widgetURL(T3ActivityPresentation(state: context.state).deepLinkURL) + } dynamicIsland: { context in + let presentation = T3ActivityPresentation(state: context.state) + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text("T3") + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(presentation.tint) + .padding(.leading, 4) + } + + DynamicIslandExpandedRegion(.trailing) { + Label(presentation.shortStatus, systemImage: presentation.phase.systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(presentation.tint) + .lineLimit(1) + .padding(.trailing, 4) + } + + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 5) { + ForEach(presentation.rows.prefix(3)) { row in + T3LiveActivityRow(row: row) + } + if presentation.rows.isEmpty { + Text(presentation.subtitle) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.bottom, 2) + } + } compactLeading: { + Text("T3") + .font(.system(size: 11, weight: .black, design: .rounded)) + .foregroundStyle(presentation.tint) + } compactTrailing: { + Image(systemName: presentation.phase.systemImage) + .foregroundStyle(presentation.tint) + } minimal: { + Image(systemName: presentation.phase.systemImage) + .foregroundStyle(presentation.tint) + } + .widgetURL(presentation.deepLinkURL) + .keylineTint(presentation.tint) + } + } +} + +private struct T3LiveActivityLockScreenView: View { + let context: ActivityViewContext + + private var presentation: T3ActivityPresentation { + T3ActivityPresentation(state: context.state) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + Text("T3 Code") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.primary) + Spacer(minLength: 8) + Label(presentation.shortStatus, systemImage: presentation.phase.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(presentation.tint) + .lineLimit(1) + } + + if presentation.rows.isEmpty { + Text(presentation.subtitle) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + ForEach(presentation.rows.prefix(4)) { row in + T3LiveActivityRow(row: row) + } + } + } + .padding(15) + } +} + +private struct T3LiveActivityRow: View { + let row: T3RelayAgentActivityAggregateRow + + var body: some View { + HStack(spacing: 7) { + Image(systemName: row.phase.systemImage) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(row.phase.tint) + .frame(width: 14) + Text(row.threadTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(row.projectTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 6) + Text(row.status) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(row.phase.tint) + .lineLimit(1) + } + } +} + +private struct T3ActivityPresentation { + let aggregate: T3RelayAgentActivityAggregateState? + + init(state: LiveActivityAttributes.ContentState) { + aggregate = state.aggregate + } + + var rows: [T3RelayAgentActivityAggregateRow] { + aggregate?.attentionFirstActivities ?? [] + } + + var phase: T3AgentActivityPhase { + rows.first?.phase ?? .stale + } + + var tint: Color { phase.tint } + + var shortStatus: String { + if let row = rows.first(where: { + $0.phase == .waitingForApproval || $0.phase == .waitingForInput + }) { + return row.phase == .waitingForApproval ? "Approval" : "Input" + } + guard let aggregate else { return "Updating" } + if aggregate.activeCount > 0 { + return "\(aggregate.activeCount) active" + } + return rows.contains(where: { $0.phase == .failed }) ? "Failed" : "Done" + } + + var subtitle: String { + aggregate?.subtitle ?? "Waiting for the latest task status." + } + + var deepLinkURL: URL? { + rows.first?.nativeDeepLinkURL + } +} + +extension T3AgentActivityPhase { + var tint: Color { + switch self { + case .starting, .running: + Color(uiColor: .systemBlue) + case .waitingForApproval: + Color(uiColor: .systemOrange) + case .waitingForInput: + Color(uiColor: .systemIndigo) + case .completed: + Color(uiColor: .systemGreen) + case .failed: + Color(uiColor: .systemRed) + case .stale: + Color.secondary + } + } +} diff --git a/apps/swift-ios/Extensions/Widgets/Info.plist b/apps/swift-ios/Extensions/Widgets/Info.plist new file mode 100644 index 000000000000..140cf57da640 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/Info.plist @@ -0,0 +1,15 @@ + + + + + CFBundleDisplayName + $(T3CODE_WIDGET_DISPLAY_NAME) + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift new file mode 100644 index 000000000000..c1ced6e9ca7c --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift @@ -0,0 +1,224 @@ +import SwiftUI +import WidgetKit + +private struct T3TaskWidgetEntry: TimelineEntry { + var date: Date + var snapshot: T3TaskWidgetSnapshot +} + +private struct T3TaskWidgetProvider: TimelineProvider { + func placeholder(in _: Context) -> T3TaskWidgetEntry { + T3TaskWidgetEntry(date: Date(), snapshot: .preview) + } + + func getSnapshot(in context: Context, completion: @escaping (T3TaskWidgetEntry) -> Void) { + let snapshot = context.isPreview ? T3TaskWidgetSnapshot.preview : T3TaskWidgetSnapshotStore.load() + completion(T3TaskWidgetEntry(date: Date(), snapshot: snapshot)) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let entry = T3TaskWidgetEntry(date: now, snapshot: T3TaskWidgetSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .after(now.addingTimeInterval(15 * 60)))) + } +} + +struct T3RecentTasksWidget: Widget { + private let kind = "T3RecentTasksWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: T3TaskWidgetProvider()) { entry in + T3TaskWidgetView(entry: entry) + .containerBackground(Color(uiColor: .systemBackground), for: .widget) + } + .configurationDisplayName("T3 Code Tasks") + .description("See active and recent T3 Code tasks at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) + } +} + +private struct T3TaskWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: T3TaskWidgetEntry + + var body: some View { + switch family { + case .systemMedium: + mediumView + case .accessoryRectangular: + accessoryView + default: + smallView + } + } + + private var orderedTasks: [T3RelayAgentActivityAggregateRow] { + entry.snapshot.tasks.sorted { left, right in + let leftPriority = left.phase.widgetPriority + let rightPriority = right.phase.widgetPriority + return leftPriority == rightPriority + ? left.updatedAt > right.updatedAt + : leftPriority < rightPriority + } + } + + private var smallView: some View { + VStack(alignment: .leading, spacing: 8) { + header + Spacer(minLength: 0) + if let task = orderedTasks.first { + Link(destination: task.nativeDeepLinkURL ?? T3WidgetURLs.newTask) { + VStack(alignment: .leading, spacing: 5) { + Label(task.status, systemImage: task.phase.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .lineLimit(1) + Text(task.threadTitle) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(2) + Text(task.projectTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } else { + emptyState + } + } + .widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask) + } + + private var mediumView: some View { + VStack(alignment: .leading, spacing: 8) { + header + if orderedTasks.isEmpty { + Spacer(minLength: 0) + emptyState + Spacer(minLength: 0) + } else { + ForEach(Array(orderedTasks.prefix(3).enumerated()), id: \.element.id) { index, task in + Link(destination: task.nativeDeepLinkURL ?? T3WidgetURLs.newTask) { + HStack(spacing: 8) { + Image(systemName: task.phase.systemImage) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .frame(width: 15) + VStack(alignment: .leading, spacing: 1) { + Text(task.threadTitle) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(task.projectTitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(task.status) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(task.phase.tint) + .lineLimit(1) + } + } + if index < min(orderedTasks.count, 3) - 1 { + Divider() + } + } + } + } + } + + private var accessoryView: some View { + Group { + if let task = orderedTasks.first { + VStack(alignment: .leading, spacing: 2) { + Label(task.status, systemImage: task.phase.systemImage) + .font(.caption.weight(.semibold)) + Text(task.threadTitle) + .font(.caption2.weight(.medium)) + .lineLimit(1) + } + } else { + Label("New task", systemImage: "square.and.pencil") + .font(.caption.weight(.semibold)) + } + } + .widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask) + } + + private var header: some View { + HStack(spacing: 6) { + Text("T3") + .font(.system(size: 14, weight: .black, design: .rounded)) + .foregroundStyle(.primary) + Text("Code") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 6) + Link(destination: T3WidgetURLs.newTask) { + Image(systemName: "square.and.pencil") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + } + .accessibilityLabel("New task") + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Ready for a task") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + Text("Tap to start in T3 Code") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + } +} + +private enum T3WidgetURLs { + static let newTask = URL(string: "\(T3SharedContainer.urlScheme)://new-task")! +} + +private extension T3AgentActivityPhase { + var widgetPriority: Int { + switch self { + case .waitingForApproval, .waitingForInput: 0 + case .failed: 1 + case .starting, .running: 2 + case .completed, .stale: 3 + } + } +} + +private extension T3TaskWidgetSnapshot { + static let preview = T3TaskWidgetSnapshot( + updatedAt: "2026-08-01T12:00:00.000Z", + tasks: [ + T3RelayAgentActivityAggregateRow( + environmentId: "preview", + threadId: "one", + projectTitle: "t3code", + threadTitle: "Polish native task list", + modelTitle: "GPT-5.6 Sol", + phase: .running, + status: "Working", + updatedAt: "2026-08-01T12:00:00.000Z", + deepLink: "/preview/one" + ), + T3RelayAgentActivityAggregateRow( + environmentId: "preview", + threadId: "two", + projectTitle: "uploadthing", + threadTitle: "Review multipart recovery", + modelTitle: "Claude Opus 5", + phase: .waitingForApproval, + status: "Approval", + updatedAt: "2026-08-01T11:59:00.000Z", + deepLink: "/preview/two" + ), + ] + ) +} diff --git a/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements new file mode 100644 index 000000000000..87c87298c6ae --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(T3CODE_APP_GROUP_IDENTIFIER) + + + diff --git a/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift new file mode 100644 index 000000000000..4b4564bf9341 --- /dev/null +++ b/apps/swift-ios/Extensions/Widgets/T3CodeWidgets.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +@main +struct T3CodeWidgetBundle: WidgetBundle { + var body: some Widget { + T3TaskLiveActivity() + T3RecentTasksWidget() + } +} diff --git a/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift b/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift new file mode 100644 index 000000000000..f310e231cfa3 --- /dev/null +++ b/apps/swift-ios/Features/Chat/AppleVoiceInputAdapter.swift @@ -0,0 +1,192 @@ +import AVFoundation +import Foundation +import Speech + +@MainActor +enum FeatureVoiceInputAdapterFactory { + static func make() -> any FeatureVoiceInputAdapter { + if #available(iOS 26.0, *) { + return AppleVoiceInputAdapter() + } + return UnsupportedVoiceInputAdapter() + } +} + +@MainActor +private final class UnsupportedVoiceInputAdapter: FeatureVoiceInputAdapter { + let isSupported = false + let localeIdentifier = Locale.current.identifier + + func prepare() async throws {} + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { .denied } + func startRecording(maximumDuration: TimeInterval) throws {} + func stopRecording() async throws -> URL { throw AppleVoiceInputError.unavailable } + func transcribe(recordingURL: URL) async throws -> String { + throw AppleVoiceInputError.unavailable + } + func cancelTranscription() async {} + func cleanup() async {} +} + +private enum AppleVoiceInputError: Error { + case unavailable + case unsupportedLocale + case recordingFailed +} + +private struct AppleVoiceAudioSessionConfiguration { + let category: AVAudioSession.Category + let mode: AVAudioSession.Mode + let options: AVAudioSession.CategoryOptions +} + +@available(iOS 26.0, *) +@MainActor +private final class AppleVoiceInputAdapter: FeatureVoiceInputAdapter { + private let audioSession = AVAudioSession.sharedInstance() + private let fileManager = FileManager.default + private var transcriber: SpeechTranscriber? + private var analyzer: SpeechAnalyzer? + private var recorder: AVAudioRecorder? + private var recordingURL: URL? + private var ownedRecordingURLs = Set() + private var previousAudioSessionConfiguration: AppleVoiceAudioSessionConfiguration? + private var audioSessionWasConfigured = false + + var isSupported: Bool { SpeechTranscriber.isAvailable } + private(set) var localeIdentifier = Locale.current.identifier + + func prepare() async throws { + guard SpeechTranscriber.isAvailable else { throw AppleVoiceInputError.unavailable } + guard let locale = await SpeechTranscriber.supportedLocale( + equivalentTo: Locale.current + ) else { + throw AppleVoiceInputError.unsupportedLocale + } + + let transcriber = SpeechTranscriber(locale: locale, preset: .transcription) + if let request = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) { + try await request.downloadAndInstall() + } + self.transcriber = transcriber + localeIdentifier = locale.identifier + } + + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { + let granted = await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + return granted ? .granted : .denied + } + + func startRecording(maximumDuration: TimeInterval) throws { + let url = fileManager.temporaryDirectory + .appendingPathComponent("t3-voice-\(UUID().uuidString)") + .appendingPathExtension("m4a") + ownedRecordingURLs.insert(url) + recordingURL = url + + previousAudioSessionConfiguration = AppleVoiceAudioSessionConfiguration( + category: audioSession.category, + mode: audioSession.mode, + options: audioSession.categoryOptions + ) + do { + try audioSession.setCategory(.record, mode: .measurement) + audioSessionWasConfigured = true + try audioSession.setActive(true) + let recorder = try AVAudioRecorder(url: url, settings: [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, + ]) + recorder.prepareToRecord() + guard recorder.record(forDuration: maximumDuration) else { + throw AppleVoiceInputError.recordingFailed + } + self.recorder = recorder + } catch { + restoreAudioSession() + throw error + } + } + + func stopRecording() async throws -> URL { + guard let recordingURL else { throw AppleVoiceInputError.recordingFailed } + recorder?.stop() + recorder = nil + restoreAudioSession() + return recordingURL + } + + func transcribe(recordingURL: URL) async throws -> String { + guard let transcriber else { throw AppleVoiceInputError.unavailable } + let audioFile = try AVAudioFile(forReading: recordingURL) + let analyzer = SpeechAnalyzer(modules: [transcriber]) + self.analyzer = analyzer + + let collector = Task { () throws -> [String] in + var segments: [String] = [] + for try await result in transcriber.results where result.isFinal { + segments.append(String(result.text.characters)) + } + return segments + } + + do { + try await analyzer.start(inputAudioFile: audioFile, finishAfterFile: true) + let segments = try await collector.value + if self.analyzer === analyzer { self.analyzer = nil } + return segments.joined(separator: " ").trimmingCharacters( + in: .whitespacesAndNewlines + ) + } catch { + collector.cancel() + await analyzer.cancelAndFinishNow() + _ = try? await collector.value + if self.analyzer === analyzer { self.analyzer = nil } + throw error + } + } + + func cancelTranscription() async { + await analyzer?.cancelAndFinishNow() + } + + func cleanup() async { + recorder?.stop() + recorder = nil + restoreAudioSession() + if let analyzer { + await analyzer.cancelAndFinishNow() + self.analyzer = nil + } + for url in ownedRecordingURLs { + try? fileManager.removeItem(at: url) + } + ownedRecordingURLs.removeAll() + recordingURL = nil + transcriber = nil + } + + private func restoreAudioSession() { + guard let previousAudioSessionConfiguration else { return } + guard audioSessionWasConfigured else { + self.previousAudioSessionConfiguration = nil + return + } + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + try? audioSession.setCategory( + previousAudioSessionConfiguration.category, + mode: previousAudioSessionConfiguration.mode, + options: previousAudioSessionConfiguration.options + ) + self.previousAudioSessionConfiguration = nil + audioSessionWasConfigured = false + } +} diff --git a/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift new file mode 100644 index 000000000000..b5d6c789f6e2 --- /dev/null +++ b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift @@ -0,0 +1,326 @@ +import Foundation + +enum CodexArtifactTemplateKind: String, Equatable, Sendable { + case document, presentation, spreadsheet, site + case googleDocs = "google-docs" + case googleSlides = "google-slides" + case googleSheets = "google-sheets" + case image, email, slack + + var label: String { + switch self { + case .document: "Document template" + case .presentation: "Presentation template" + case .spreadsheet: "Spreadsheet template" + case .site: "Site template" + case .googleDocs: "Google Doc template" + case .googleSlides: "Google Slides template" + case .googleSheets: "Google Sheet template" + case .image: "Image template" + case .email: "Email template" + case .slack: "Slack template" + } + } + + func usePrompt(skillName: String) -> String { + let skill = "$\(skillName)" + return switch self { + case .document: "Create a document using this \(skill) about…" + case .presentation: "Create a presentation using the \(skill) template about…" + case .spreadsheet: "Create a spreadsheet using this \(skill) about…" + case .site: "Create a Site using this \(skill) about…" + case .googleDocs: "Create a Google Doc using this \(skill) about…" + case .googleSlides: "Create a Google Slides presentation using this \(skill) about…" + case .googleSheets: "Create a Google Sheet using this \(skill) about…" + case .image: "Create an image using this \(skill) of…" + case .email: "Draft an email using this \(skill) about…" + case .slack: "Draft a Slack message using this \(skill) about…" + } + } +} + +struct CodexArtifactTemplate: Equatable, Sendable { + let kind: CodexArtifactTemplateKind + let displayName: String + let skillDirectory: String + let skillName: String + let galleryKind: String? + + var usePrompt: String { kind.usePrompt(skillName: skillName) } + + var useURL: URL? { + var components = URLComponents() + components.scheme = "t3code" + components.host = "codex-artifact-template" + components.path = "/use" + components.queryItems = [URLQueryItem(name: "prompt", value: usePrompt)] + return components.url + } +} + +enum CodexMarkdownDirectives { + private static let artifactPrefix = "::artifact-template{" + + static func artifactTemplate(from line: String) -> CodexArtifactTemplate? { + guard line.prefix(while: { $0 == " " }).count < 4, line.first != "\t" else { + return nil + } + let source = line.trimmingCharacters(in: .whitespaces) + guard source.hasPrefix(artifactPrefix), source.hasSuffix("}"), + let attributes = attributes( + in: String(source.dropFirst(artifactPrefix.count).dropLast()) + ), + let kindValue = attributes["artifact_kind"], + let kind = CodexArtifactTemplateKind(rawValue: kindValue), + let displayName = attributes["display_name"]?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !displayName.isEmpty, + let directory = attributes["skill_directory"], isAbsolutePath(directory), + let skillName = attributes["skill_name"], + skillName.hasPrefix("artifact-template-") else { return nil } + + let gallery = attributes["gallery_kind"] + guard gallery == nil || gallery == "imagegen" || gallery == "product-design" else { + return nil + } + return CodexArtifactTemplate( + kind: kind, + displayName: displayName, + skillDirectory: directory, + skillName: skillName, + galleryKind: gallery + ) + } + + static func replacingFileCitations(in source: String) -> String { + let lines = source.components(separatedBy: "\n") + var fence: Character? + var fenceCount = 0 + return lines.map { line in + let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) + if let marker = trimmed.first, marker == "`" || marker == "~" { + let count = trimmed.prefix(while: { $0 == marker }).count + if count >= 3 { + if fence == nil { fence = marker; fenceCount = count } + else if fence == marker, count >= fenceCount { fence = nil } + return line + } + } + let leadingSpaces = line.prefix(while: { $0 == " " }).count + guard fence == nil, leadingSpaces < 4, line.first != "\t" else { + return line + } + return replacingCitationsInInlineMarkdown(line) + }.joined(separator: "\n") + } + + private static func replacingCitationsInInlineMarkdown(_ line: String) -> String { + let characters = Array(line) + var result = "" + var cursor = 0 + + while cursor < characters.count { + if characters[cursor] == "[", !isEscaped(at: cursor, in: characters), + let end = markdownLinkEnd(in: characters, from: cursor) { + result += String(characters[cursor.. String? { + let prefix = ":codex-file-citation{" + guard directive.hasPrefix(prefix), directive.hasSuffix("}"), + let values = attributes(in: String(directive.dropFirst(prefix.count).dropLast())), + let rawPath = values["path"] else { return nil } + let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + let normalizedPath = path.replacingOccurrences(of: "\\", with: "/") + .replacingOccurrences(of: #"/+$"#, with: "", options: .regularExpression) + let label = normalizedPath.split(separator: "/").last.map(String.init) + ?? (normalizedPath.isEmpty ? "File" : normalizedPath) + var destination = path + .replacingOccurrences(of: "%", with: "%25") + .replacingOccurrences(of: "#", with: "%23") + .replacingOccurrences(of: "?", with: "%3F") + .replacingOccurrences(of: "<", with: "%3C") + .replacingOccurrences(of: ">", with: "%3E") + .replacingOccurrences(of: "\r", with: "%0D") + .replacingOccurrences(of: "\n", with: "%0A") + if let line = values["line_range_start"], + let value = Int(line.trimmingCharacters(in: .whitespacesAndNewlines)), value > 0 { + destination += "#L\(value)" + } + return "[\(escapedMarkdownLabel(label))](<\(destination)>)" + } + + private static func attributes(in source: String) -> [String: String]? { + let chars = Array(source) + var values: [String: String] = [:] + var cursor = 0 + while cursor < chars.count { + while cursor < chars.count, chars[cursor].isWhitespace { cursor += 1 } + guard cursor < chars.count else { break } + let keyStart = cursor + while cursor < chars.count, chars[cursor].isLetter || chars[cursor].isNumber + || chars[cursor] == "_" { cursor += 1 } + guard cursor > keyStart else { return nil } + let key = String(chars[keyStart.. Int? { + guard let labelEnd = closingBracket(in: chars, from: start) else { return nil } + let suffix = labelEnd + 1 + guard suffix < chars.count else { return nil } + if chars[suffix] == "(" { + return closingDelimiter(")", in: chars, after: suffix) + } + if chars[suffix] == "[" { + return closingBracket(in: chars, from: suffix).map { $0 + 1 } + } + return nil + } + + private static func closingBracket(in chars: [Character], from start: Int) -> Int? { + var depth = 1 + var cursor = start + 1 + while cursor < chars.count { + if !isEscaped(at: cursor, in: chars) { + if chars[cursor] == "[" { depth += 1 } + if chars[cursor] == "]" { + depth -= 1 + if depth == 0 { return cursor } + } + } + cursor += 1 + } + return nil + } + + private static func closingDelimiter( + _ delimiter: Character, + in chars: [Character], + after start: Int + ) -> Int? { + var cursor = start + 1 + while cursor < chars.count { + if chars[cursor] == delimiter, !isEscaped(at: cursor, in: chars) { return cursor + 1 } + cursor += 1 + } + return nil + } + + private static func closingBacktickRun( + ofLength length: Int, + after start: Int, + in chars: [Character] + ) -> Int? { + var cursor = start + while cursor < chars.count { + guard chars[cursor] == "`", !isEscaped(at: cursor, in: chars) else { + cursor += 1 + continue + } + let end = chars[cursor...].prefix(while: { $0 == "`" }).count + cursor + if end - cursor == length { return end } + cursor = end + } + return nil + } + + private static func directiveEnd(in chars: [Character], from start: Int) -> Int? { + var cursor = start + ":codex-file-citation{".count + var quote: Character? + while cursor < chars.count { + let character = chars[cursor] + if let activeQuote = quote { + if character == activeQuote, !isEscaped(at: cursor, in: chars) { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "}" { + return cursor + } + cursor += 1 + } + return nil + } + + private static func isEscaped(at index: Int, in chars: [Character]) -> Bool { + guard index > 0 else { return false } + var backslashes = 0 + var cursor = index - 1 + while chars[cursor] == "\\" { + backslashes += 1 + guard cursor > 0 else { break } + cursor -= 1 + } + return backslashes.isMultiple(of: 2) == false + } + + private static func escapedMarkdownLabel(_ value: String) -> String { + let escaped = CharacterSet(charactersIn: "\\[]*_`<&") + return value.unicodeScalars.reduce(into: "") { result, scalar in + if escaped.contains(scalar) { result.append("\\") } + result.unicodeScalars.append(scalar) + } + } + + private static func isAbsolutePath(_ path: String) -> Bool { + if path.hasPrefix("/"), !path.hasPrefix("//") { return true } + if path.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil { return true } + return path.range(of: #"^(?:\\\\[^\\]+\\[^\\]+|//[^/]+/[^/]+)"#, + options: .regularExpression) != nil + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift b/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift new file mode 100644 index 000000000000..f70844ef22d6 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerCommandPopover.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct FeatureComposerCommandPopover: View { + let triggerKind: FeatureComposerTriggerKind + let items: [FeatureComposerMenuItem] + let isLoading: Bool + let errorMessage: String? + let pathSearchAvailable: Bool + let onSelect: (FeatureComposerMenuItem) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if items.isEmpty { + Text(emptyMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + Button { + onSelect(item) + } label: { + FeatureComposerCommandRow(item: item, triggerKind: triggerKind) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel(for: item)) + .accessibilityIdentifier("composer-suggestion-\(item.id)") + + if index < items.count - 1 { + Divider() + .overlay(T3Colors.separator) + .padding(.leading, 40) + } + } + } + } + .scrollIndicators(.hidden) + } + } + .frame(height: menuHeight, alignment: .top) + .background(T3Colors.surfaceRaised.opacity(0.98)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityLabel(groupLabel) + .accessibilityIdentifier("composer-command-menu") + } + + private var groupLabel: String { + switch triggerKind { + case .slashCommand: return "Commands" + case .model: return "Models" + case .skill: return "Skills" + case .path: return "Files" + } + } + + private var emptyMessage: String { + if isLoading { return "Searching files…" } + if let errorMessage, !errorMessage.isEmpty { return errorMessage } + switch triggerKind { + case .slashCommand: return "No matching commands" + case .model: return "No matching models" + case .skill: return "No matching skills" + case .path where !pathSearchAvailable: return "File search unavailable" + case .path: return "Type a file name" + } + } + + private func accessibilityLabel(for item: FeatureComposerMenuItem) -> String { + item.description.isEmpty ? item.label : "\(item.label), \(item.description)" + } + + private var menuHeight: CGFloat { + Self.height(forItemCount: items.count) + } + + /// The menu's height is deterministic so the composer can position the + /// menu fully above its own surface without measuring it. + static func height(forItemCount count: Int) -> CGFloat { + guard count > 0 else { return 48 } + let rowHeight: CGFloat = 47 + return min(CGFloat(count) * rowHeight, 188) + } +} + +private struct FeatureComposerCommandRow: View { + let item: FeatureComposerMenuItem + let triggerKind: FeatureComposerTriggerKind + + var body: some View { + HStack(spacing: 10) { + Image(systemName: iconName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 17) + + Text(displayLabel) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .layoutPriority(1) + + if !item.description.isEmpty { + Text(item.description) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Spacer(minLength: 0) + } + } + .padding(.horizontal, 14) + .frame(minHeight: 46) + .contentShape(Rectangle()) + } + + private var iconName: String { + switch item { + case .modelCommand, .model: return "cpu" + case .providerCommand: return "terminal" + case let .skill(skill): return skill.source.systemImage + case let .path(entry): return entry.kind == .directory ? "folder" : "doc" + } + } + + private var displayLabel: String { + if triggerKind == .slashCommand, case let .skill(skill) = item { + return "/skill:\(skill.displayName ?? skill.name)" + } + return item.label + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift b/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift new file mode 100644 index 000000000000..0dca4fa768f7 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerImageDrop.swift @@ -0,0 +1,104 @@ +import SwiftUI +import UniformTypeIdentifiers + +/// How many of an incoming batch of images the composer can take, separated +/// from the SwiftUI plumbing so the cap and the overflow accounting can be +/// tested without a live drag session or pasteboard. +/// +/// Paste and drop are two doors into the same room: both produce item +/// providers, both land in the attachment strip, and both must respect the +/// attachment cap while earlier images are still being prepared. +struct FeatureComposerImageIntakePlan: Equatable { + let acceptedCount: Int + let firstOrdinal: Int + let droppedCount: Int + + /// Returns nil when nothing can be accepted, either because the batch is + /// empty or the cap is already spent by attached and in-flight images. + static func forProviders( + providerCount: Int, + attachmentCount: Int, + pendingCount: Int, + maximumCount: Int = FeatureImageAttachmentLimits.maximumCount + ) -> FeatureComposerImageIntakePlan? { + guard providerCount > 0 else { return nil } + let remaining = max(0, maximumCount - attachmentCount - pendingCount) + let accepted = min(providerCount, remaining) + guard accepted > 0 else { return nil } + + return FeatureComposerImageIntakePlan( + acceptedCount: accepted, + firstOrdinal: attachmentCount + pendingCount + 1, + droppedCount: providerCount - accepted + ) + } +} + +/// Accepts images dragged onto the composer from another app. +/// +/// Registering only `.image` lets the drag session itself reject anything +/// else, so a dropped PDF never lands and there is no failure to explain +/// afterwards. +struct FeatureComposerImageDrop: ViewModifier { + let isEnabled: Bool + let shape: RoundedRectangle + let onDropImages: ([NSItemProvider]) -> Bool + + @State private var isTargeted = false + + func body(content: Content) -> some View { + content + .overlay { + if isTargeted { + shape + .fill(T3Colors.accent.opacity(0.1)) + .overlay { + shape.strokeBorder(T3Colors.accent, lineWidth: 2) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .animation(.easeOut(duration: 0.12), value: isTargeted) + .onDrop( + of: [.image], + delegate: FeatureComposerImageDropDelegate( + isEnabled: isEnabled, + isTargeted: $isTargeted, + onDropImages: onDropImages + ) + ) + } +} + +/// Tracks targeting through explicit enter and exit callbacks so the highlight +/// cannot outlive the session, and states the drop operation outright. +private struct FeatureComposerImageDropDelegate: DropDelegate { + let isEnabled: Bool + @Binding var isTargeted: Bool + let onDropImages: ([NSItemProvider]) -> Bool + + func validateDrop(info: DropInfo) -> Bool { + isEnabled && info.hasItemsConforming(to: [.image]) + } + + func dropEntered(info: DropInfo) { + isTargeted = true + } + + // A system-sourced drag (the screenshot thumbnail, Photos) is refused + // under SwiftUI's default proposal and the session dies mid-air with the + // highlight still lit. Asking for a copy explicitly is what lets it land. + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .copy) + } + + func dropExited(info: DropInfo) { + isTargeted = false + } + + func performDrop(info: DropInfo) -> Bool { + isTargeted = false + return onDropImages(info.itemProviders(for: [.image])) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift new file mode 100644 index 000000000000..2266f9fd199c --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerPowerFeatures.swift @@ -0,0 +1,436 @@ +import Foundation + +/// Provider- and project-scoped data used by the composer command menu. +/// The feature layer supplies these values because the composer should not +/// know how a particular environment fetches provider or workspace data. +struct FeatureComposerPowerFeatures { + typealias PathSearch = (_ query: String) async throws -> [FeatureComposerPathEntry] + + var slashCommands: [FeatureProviderSlashCommand] + var skills: [FeatureProviderSkill] + var pathSearchScopeID: String + var searchPaths: PathSearch? + + init( + slashCommands: [FeatureProviderSlashCommand] = [], + skills: [FeatureProviderSkill] = [], + pathSearchScopeID: String = "", + searchPaths: PathSearch? = nil + ) { + self.slashCommands = slashCommands + self.skills = skills + self.pathSearchScopeID = pathSearchScopeID + self.searchPaths = searchPaths + } + + static var disabled: FeatureComposerPowerFeatures { FeatureComposerPowerFeatures() } +} + +public struct FeatureProviderSlashCommand: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { name } + public let name: String + public let description: String? + public let inputHint: String? + + public init( + name: String, + description: String? = nil, + inputHint: String? = nil + ) { + self.name = name + self.description = description + self.inputHint = inputHint + } +} + +public struct FeatureProviderSkill: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { name } + public let name: String + public let displayName: String? + public let description: String? + public let shortDescription: String? + public let path: String + public let scope: String? + public let isEnabled: Bool + + public init( + name: String, + displayName: String? = nil, + description: String? = nil, + shortDescription: String? = nil, + path: String = "", + scope: String? = nil, + isEnabled: Bool = true + ) { + self.name = name + self.displayName = displayName + self.description = description + self.shortDescription = shortDescription + self.path = path + self.scope = scope + self.isEnabled = isEnabled + } + + var source: FeatureProviderSkillSource { + let normalizedPath = path.replacingOccurrences(of: "\\", with: "/") + if normalizedPath.contains("/.codex/plugins/") + || normalizedPath.contains("/.agents/plugins/") { + return .app + } + switch scope?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "repo", "repository": return .repository + case "project", "workspace", "local": return .project + case "user", "personal": return .personal + case "system": return .system + default: return .other + } + } +} + +enum FeatureProviderSkillSource: String, Sendable, Equatable { + case app + case repository + case project + case personal + case system + case other + + var systemImage: String { + switch self { + case .app: "square.grid.2x2" + case .repository, .project: "folder" + case .personal: "person.crop.circle" + case .system: "gearshape" + case .other: "shippingbox" + } + } +} + +struct FeatureComposerPathEntry: Identifiable, Sendable, Equatable, Hashable { + enum Kind: String, Sendable, Equatable, Hashable { + case file + case directory + } + + var id: String { path } + let path: String + let kind: Kind + + init(path: String, kind: Kind) { + self.path = path + self.kind = kind + } + + var name: String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + return normalized.split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + } + + var parentPath: String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + let parts = normalized.split(separator: "/", omittingEmptySubsequences: true) + return parts.dropLast().joined(separator: "/") + } +} + +enum FeatureComposerTriggerKind: Sendable, Equatable { + case slashCommand + case model + case skill + case path +} + +struct FeatureComposerTrigger: Sendable, Equatable { + let kind: FeatureComposerTriggerKind + let query: String + let range: Range +} + +struct FeatureCodexFeedbackCommand: Sendable, Equatable { + private static let expression = try? NSRegularExpression( + pattern: #"^/feedback(?:\s+([\s\S]*))?$"#, + options: [.caseInsensitive] + ) + + let reason: String? + + static func parse(_ text: String) -> Self? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("/feedback"), + let expression, + let match = expression.firstMatch( + in: trimmed, + range: NSRange(trimmed.startIndex..., in: trimmed) + ) else { + return nil + } + guard match.range(at: 1).location != NSNotFound, + let reasonRange = Range(match.range(at: 1), in: trimmed) else { + return Self(reason: nil) + } + let reason = trimmed[reasonRange].trimmingCharacters(in: .whitespacesAndNewlines) + return Self(reason: reason.isEmpty ? nil : reason) + } +} + +/// Mirrors the shared web/mobile trigger grammar while keeping this target +/// independent of the TypeScript runtime. +enum FeatureComposerTriggerParser { + static func detect(in text: String, cursorOffset: Int? = nil) -> FeatureComposerTrigger? { + let cursor = min(max(cursorOffset ?? text.count, 0), text.count) + let cursorIndex = text.index(text.startIndex, offsetBy: cursor) + let prefix = text[.. text.startIndex { + let previous = text.index(before: tokenStartIndex) + if text[previous].isWhitespace { break } + tokenStartIndex = previous + } + let token = String(text[tokenStartIndex.., + in text: String, + with replacement: String + ) -> String { + let lower = min(max(range.lowerBound, 0), text.count) + let upper = min(max(range.upperBound, lower), text.count) + let start = text.index(text.startIndex, offsetBy: lower) + let end = text.index(text.startIndex, offsetBy: upper) + return String(text[.. String { + let normalized = path.replacingOccurrences(of: "\\", with: "/") + let basename = normalized.split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + let label = basename + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "[", with: "\\[") + .replacingOccurrences(of: "]", with: "\\]") + return "[\(label)](\(encodeDestination(path)))" + } + + private static func encodeDestination(_ path: String) -> String { + let unescaped = Set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789;,/:@&=+$-_.!~*'" + ) + return path.utf8.map { byte -> String in + guard byte < 128, + let scalar = UnicodeScalar(Int(byte)), + unescaped.contains(Character(String(scalar))) else { + return String(format: "%%%02X", byte) + } + return String(scalar) + }.joined() + } +} + +enum FeatureComposerMenuItem: Identifiable, Sendable, Equatable { + case modelCommand + case model(selection: FeatureSelection, label: String, description: String) + case providerCommand(FeatureProviderSlashCommand) + case skill(FeatureProviderSkill) + case path(FeatureComposerPathEntry) + + var id: String { + switch self { + case .modelCommand: "command:model" + case let .model(selection, _, _): "model:\(selection.providerID):\(selection.modelID)" + case let .providerCommand(command): "command:\(command.id)" + case let .skill(skill): "skill:\(skill.id)" + case let .path(entry): "path:\(entry.path)" + } + } + + var label: String { + switch self { + case .modelCommand: "/model" + case let .model(_, label, _): label + case let .providerCommand(command): "/\(command.name)" + case let .skill(skill): skill.displayName ?? skill.name + case let .path(entry): entry.name + } + } + + var description: String { + switch self { + case .modelCommand: "Switch model" + case let .model(_, _, description): description + case let .providerCommand(command): + command.description ?? command.inputHint ?? "" + case let .skill(skill): + skill.shortDescription ?? skill.description ?? skill.scope ?? "" + case let .path(entry): entry.parentPath + } + } +} + +enum FeatureComposerMenuBuilder { + private static func normalizedName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func enabledSkills( + in skills: [FeatureProviderSkill] + ) -> [FeatureProviderSkill] { + var seenNames = Set() + return skills.filter { skill in + guard skill.isEnabled else { return false } + return seenNames.insert(normalizedName(skill.name)).inserted + } + } + + static func items( + trigger: FeatureComposerTrigger, + providers: [FeatureProvider], + currentSelection: FeatureSelection?, + threadSelection: FeatureSelection?, + powerFeatures: FeatureComposerPowerFeatures, + pathEntries: [FeatureComposerPathEntry] + ) -> [FeatureComposerMenuItem] { + switch trigger.kind { + case .slashCommand: + let query = trigger.query.lowercased() + let normalizedSkillQuery = query.hasPrefix("skill:") + ? String(query.dropFirst("skill:".count)) + : query + var items: [FeatureComposerMenuItem] = [] + if query.isEmpty || "model".contains(query) { + items.append(.modelCommand) + } + let enabledSkills = enabledSkills(in: powerFeatures.skills) + let skills = enabledSkills + .filter { skill in + guard !normalizedSkillQuery.isEmpty else { return true } + return [skill.name, skill.displayName, skill.shortDescription, skill.description] + .compactMap { $0 } + .contains { $0.localizedCaseInsensitiveContains(normalizedSkillQuery) } + } + .sorted { + ($0.displayName ?? $0.name).localizedStandardCompare($1.displayName ?? $1.name) + == .orderedAscending + } + let enabledSkillNames = Set(enabledSkills.map { normalizedName($0.name) }) + let excludedCommandNames = Set(["model", "plan", "default"].map(normalizedName)) + let commands = powerFeatures.slashCommands + .filter { !excludedCommandNames.contains(normalizedName($0.name)) } + .filter { !enabledSkillNames.contains(normalizedName($0.name)) } + .filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + items.append(contentsOf: commands.map(FeatureComposerMenuItem.providerCommand)) + items.append(contentsOf: skills.map(FeatureComposerMenuItem.skill)) + return Array(items.prefix(20)) + + case .model: + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + return providers + .filter(\.isAvailable) + .filter { provider in + threadSelection == nil || provider.id == threadSelection?.providerID + } + .flatMap { provider in + provider.models + .filter { model in + guard provider.requiresNewThreadForModelChange, + let threadSelection else { return true } + return model.id == threadSelection.modelID + } + .map { model in + ( + item: FeatureComposerMenuItem.model( + selection: FeatureSelection( + providerID: provider.id, + modelID: model.id, + options: currentSelection?.providerID == provider.id + && currentSelection?.modelID == model.id + ? currentSelection?.options ?? [] + : DailyUXModelOptions.defaults(for: model) + ), + label: model.name, + description: provider.name + ), + searchText: "\(provider.name) \(model.name) \(model.id)" + ) + } + } + .filter { query.isEmpty || $0.searchText.localizedCaseInsensitiveContains(query) } + .prefix(20) + .map(\.item) + + case .skill: + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + return enabledSkills(in: powerFeatures.skills) + .filter { skill in + guard !query.isEmpty else { return true } + return [skill.name, skill.displayName, skill.shortDescription, skill.description] + .compactMap { $0 } + .contains { $0.localizedCaseInsensitiveContains(query) } + } + .sorted { + ($0.displayName ?? $0.name).localizedStandardCompare($1.displayName ?? $1.name) + == .orderedAscending + } + .prefix(20) + .map(FeatureComposerMenuItem.skill) + + case .path: + return pathEntries + .uniquedByPath() + .prefix(20) + .map(FeatureComposerMenuItem.path) + } + } +} + +private extension Array where Element == FeatureComposerPathEntry { + func uniquedByPath() -> [Element] { + var seen = Set() + return filter { seen.insert($0.path).inserted } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift new file mode 100644 index 000000000000..b5303077c7ae --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -0,0 +1,505 @@ +import SwiftUI + +struct FeatureComposerApprovalPanel: View { + let approval: FeatureApproval + let position: Int + let total: Int + let isResponding: Bool + let onDecision: (FeatureApprovalDecision) -> Void + let onCancelTurn: () -> Void + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text("Pending approval") + .font(T3Typography.eyebrow) + .tracking(1.3) + .textCase(.uppercase) + .foregroundStyle(T3Colors.warning) + + Spacer() + + if total > 1 { + Text("\(position)/\(total)") + .font(T3Typography.supportingStrong.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + + Text(approval.appName ?? approval.title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 5) { + Text(detailLabel) + .font(T3Typography.supportingStrong) + .tracking(0.7) + .textCase(.uppercase) + .foregroundStyle(T3Colors.textTertiary) + + Text(approval.detail) + .font( + approval.kind == .command + ? T3Typography.code + : T3Typography.threadBody + ) + .foregroundStyle(T3Colors.textPrimary.opacity(0.92)) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(T3Colors.border, lineWidth: 1) + } + .padding(.top, 9) + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .background(T3Colors.subtle) + + Divider().overlay(T3Colors.separator) + + VStack(spacing: 9) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 7) { + ForEach(positiveOptions) { option in + approvalButton( + option.label, + background: option.decision == .allowOnce ? T3Colors.accent : .clear, + border: option.decision == .allowOnce ? .clear : T3Colors.border, + foreground: option.decision == .allowOnce ? .white : T3Colors.textPrimary, + action: { onDecision(option.decision) } + ) + } + } + + HStack(spacing: 26) { + ForEach(negativeOptions) { option in + Button(option.label, role: .destructive) { + onDecision(option.decision) + } + .foregroundStyle(T3Colors.danger) + } + + Button("Cancel turn", action: onCancelTurn) + .foregroundStyle(T3Colors.textTertiary) + } + .font(T3Typography.supportingStrong) + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 11) + } + .disabled(isResponding) + .opacity(isResponding ? 0.56 : 1) + .accessibilityElement(children: .contain) + } + + private func approvalButton( + _ title: String, + background: Color, + border: Color = .clear, + foreground: Color = .white, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: T3Metrics.minimumTapTarget) + .background(background, in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private var detailLabel: String { + switch approval.kind { + case .command: "Command" + case .fileRead: "File access" + case .fileChange: "File change" + case .mcpElicitation: "App access" + case .patch: "Patch" + case .other: "Details" + } + } + + private var options: [FeatureApprovalOption] { + approval.options ?? [ + FeatureApprovalOption(decision: .allowOnce, label: "Approve once"), + FeatureApprovalOption(decision: .allowForSession, label: "Allow session"), + FeatureApprovalOption(decision: .deny, label: "Decline"), + ] + } + + private var positiveOptions: [FeatureApprovalOption] { + options.filter { $0.decision != .deny && $0.decision != .cancel } + } + + private var negativeOptions: [FeatureApprovalOption] { + options.filter { $0.decision == .deny || $0.decision == .cancel } + } +} + +struct FeatureComposerUserInputPanel: View { + let input: FeatureUserInput + let isResponding: Bool + let onSubmit: ([String: FeatureInputAnswer]) -> Void + + @State private var answers: [String: FeatureInputAnswer] = [:] + @State private var questionIndex = 0 + + var body: some View { + Group { + if let question = activeQuestion { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(question.header) + .font(T3Typography.eyebrow) + .tracking(1.3) + .textCase(.uppercase) + .foregroundStyle(T3Colors.accent) + + Spacer() + + if input.questions.count > 1 { + Text("\(questionIndex + 1)/\(input.questions.count)") + .font(T3Typography.supportingStrong.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + + Text(question.question) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 5) + + if question.allowsMultiple { + Text("Select one or more options") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .padding(.top, 4) + } + } + .padding(.horizontal, 15) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.subtle) + + Divider().overlay(T3Colors.separator) + + ScrollView { + VStack(spacing: 6) { + ForEach( + Array(question.options.enumerated()), + id: \.element.label + ) { index, option in + optionButton(option, number: index + 1, question: question) + } + } + .padding(.horizontal, 10) + .padding(.top, 10) + } + .frame(maxHeight: 320) + .scrollIndicators(.hidden) + + HStack(spacing: 8) { + Image(systemName: "pencil") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + + TextField( + "Write custom answer", + text: answerBinding(for: question), + axis: .vertical + ) + .font(T3Typography.composer) + .lineLimit(1...4) + .submitLabel(.return) + } + .padding(.horizontal, 12) + .frame(minHeight: T3Metrics.minimumTapTarget) + .background( + T3Colors.input, + in: RoundedRectangle(cornerRadius: 11) + ) + .overlay { + RoundedRectangle(cornerRadius: 11) + .stroke(T3Colors.inputBorder, lineWidth: 1) + } + .padding(.horizontal, 10) + .padding(.top, 7) + + HStack(spacing: 8) { + if questionIndex > 0 { + Button("Back") { + questionIndex -= 1 + } + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame( + minWidth: T3Metrics.minimumTapTarget, + minHeight: T3Metrics.minimumTapTarget + ) + } + + Spacer() + + Button(action: advanceOrSubmit) { + Text(isLastQuestion ? "Submit" : "Next question") + .font(T3Typography.control.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .frame(height: T3Metrics.minimumTapTarget) + .background(T3Colors.accent, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(!canAdvance) + .opacity(canAdvance ? 1 : 0.3) + } + .padding(.horizontal, 10) + .padding(.top, 9) + .padding(.bottom, 11) + } + .disabled(isResponding) + .opacity(isResponding ? 0.56 : 1) + } + } + .onChange(of: input.id) { + answers = [:] + questionIndex = 0 + } + .onChange(of: questionIDs) { previousIDs, currentIDs in + questionIndex = FeatureComposerQuestionReconciliation.index( + current: questionIndex, + previousQuestionIDs: previousIDs, + currentQuestionIDs: currentIDs + ) + answers = FeatureComposerQuestionReconciliation.answers( + answers, + currentQuestionIDs: currentIDs + ) + } + } + + private var activeQuestion: FeatureInputQuestion? { + guard input.questions.indices.contains(questionIndex) else { return nil } + return input.questions[questionIndex] + } + + private var questionIDs: [String] { + input.questions.map(\.id) + } + + private var isLastQuestion: Bool { + questionIndex >= input.questions.count - 1 + } + + private var canAdvance: Bool { + guard let activeQuestion else { return false } + return normalizedAnswer(for: activeQuestion.id) != nil + } + + private var normalizedAnswers: [String: FeatureInputAnswer]? { + var result: [String: FeatureInputAnswer] = [:] + for question in input.questions { + guard let answer = normalizedAnswer(for: question.id) else { return nil } + result[question.id] = answer + } + return result + } + + private func optionButton( + _ option: FeatureInputOption, + number: Int, + question: FeatureInputQuestion + ) -> some View { + let isSelected = isOptionSelected(option.label, for: question) + + return Button { + select(option.label, for: question) + } label: { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(option.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + if !option.detail.isEmpty, option.detail != option.label { + Text(option.detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: 8) + + if isSelected { + Image(systemName: "checkmark") + .font(T3Typography.supporting.weight(.bold)) + .foregroundStyle(T3Colors.accent) + } else if number <= 9 { + Text("\(number)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 20, height: 20) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(T3Colors.border, lineWidth: 1) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .background( + isSelected ? T3Colors.accent.opacity(0.12) : T3Colors.subtle, + in: RoundedRectangle(cornerRadius: 10) + ) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke( + isSelected ? T3Colors.accent.opacity(0.46) : Color.clear, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + + private func answerBinding(for question: FeatureInputQuestion) -> Binding { + Binding( + get: { + FeatureComposerCustomAnswer.text( + in: answers[question.id], + for: question + ) + }, + set: { + answers[question.id] = FeatureComposerCustomAnswer.replacingText( + in: answers[question.id], + with: $0, + for: question + ) + } + ) + } + + private func select(_ label: String, for question: FeatureInputQuestion) { + answers[question.id] = (answers[question.id] ?? .selections([])) + .togglingOption(label, allowsMultiple: question.allowsMultiple) + if question.allowsMultiple { + return + } + guard !isLastQuestion else { return } + let selectedQuestionID = question.id + Task { @MainActor in + await Task.yield() + guard activeQuestion?.id == selectedQuestionID, + !isLastQuestion else { + return + } + questionIndex += 1 + } + } + + private func advanceOrSubmit() { + guard canAdvance else { return } + if !isLastQuestion { + questionIndex += 1 + } else if let normalizedAnswers { + onSubmit(normalizedAnswers) + } else if let unanswered = input.questions.firstIndex(where: { + normalizedAnswer(for: $0.id) == nil + }) { + questionIndex = unanswered + } + } + + private func normalizedAnswer(for questionID: String) -> FeatureInputAnswer? { + answers[questionID]?.normalized + } + + private func isOptionSelected(_ label: String, for question: FeatureInputQuestion) -> Bool { + switch answers[question.id] { + case let .text(value): + return !question.allowsMultiple && value == label + case let .selections(values): + return values.contains(label) + case nil: + return false + } + } +} + +enum FeatureComposerCustomAnswer { + static func text( + in answer: FeatureInputAnswer?, + for question: FeatureInputQuestion + ) -> String { + let optionLabels = Set(question.options.map(\.label)) + switch answer { + case let .text(value): + return optionLabels.contains(value) ? "" : value + case let .selections(values): + return values.first(where: { !optionLabels.contains($0) }) ?? "" + case nil: + return "" + } + } + + static func replacingText( + in answer: FeatureInputAnswer?, + with text: String, + for question: FeatureInputQuestion + ) -> FeatureInputAnswer { + guard question.allowsMultiple else { return .text(text) } + let optionLabels = Set(question.options.map(\.label)) + let selectedOptions: [String] + if case let .selections(values) = answer { + selectedOptions = values.filter(optionLabels.contains) + } else { + selectedOptions = [] + } + return .selections(text.isEmpty ? selectedOptions : selectedOptions + [text]) + } +} + +enum FeatureComposerQuestionReconciliation { + static func index( + current: Int, + previousQuestionIDs: [String], + currentQuestionIDs: [String] + ) -> Int { + guard !currentQuestionIDs.isEmpty else { return 0 } + if previousQuestionIDs.indices.contains(current), + let retained = currentQuestionIDs.firstIndex( + of: previousQuestionIDs[current] + ) { + return retained + } + return min(max(0, current), currentQuestionIDs.count - 1) + } + + static func answers( + _ answers: [String: FeatureInputAnswer], + currentQuestionIDs: [String] + ) -> [String: FeatureInputAnswer] { + let liveIDs = Set(currentQuestionIDs) + return answers.filter { liveIDs.contains($0.key) } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift new file mode 100644 index 000000000000..c7f43814b668 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerTextInput.swift @@ -0,0 +1,472 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers +import Observation + +/// The composer's text entry is a UIKit text view because SwiftUI's text +/// inputs expose no paste hook on iOS: the long-press Paste menu can never +/// offer an image. Bridging `UITextView` buys the native paste menu, image +/// paste, and internal scrolling once the draft outgrows its viewport cap. +struct FeatureComposerTextInput: UIViewRepresentable { + @Binding var text: String + // A plain binding, not `FocusState`: SwiftUI ignores writes to a + // `FocusState` that no `.focused()` view registers with, and a + // representable cannot register. The UIKit responder state is the source + // of truth and this binding mirrors it for the hosts. + @Binding var focused: Bool + let placeholder: String + let acceptsImages: Bool + let isReadOnly: Bool + let selectionRequest: FeatureComposerTextSelectionRequest? + let onSelectionChange: (NSRange) -> Void + let onPasteImages: ([NSItemProvider]) -> Void + let onDismissKeyboard: (() -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> FeatureComposerUITextView { + let textView = FeatureComposerUITextView() + textView.delegate = context.coordinator + textView.acceptsImages = acceptsImages + textView.isEditable = !isReadOnly + textView.onPasteImages = onPasteImages + textView.onDismissKeyboard = onDismissKeyboard + if onDismissKeyboard != nil { + textView.installDismissPanRecognizer() + } + textView.backgroundColor = .clear + textView.textColor = T3Colors.uiTextPrimary + textView.tintColor = T3Colors.uiAccent + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.adjustsFontForContentSizeCategory = true + textView.smartQuotesType = .no + textView.smartDashesType = .no + // Outer padding belongs to SwiftUI. The bottom inset keeps the final + // insertion point above the composer controls. + textView.configureComposerViewport() + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = true + // Deliberately not `keyboardDismissMode = .interactive`: the capped + // input sits directly above the keyboard, so scrolling up through a + // long draft drags into the keyboard's frame and yanks it around. + // Dismissal belongs to the pan recognizer below, which only fires + // for a drag that begins with the draft at its top. + textView.accessibilityIdentifier = "message-composer" + updateAccessibility(textView) + return textView + } + + func updateUIView(_ textView: FeatureComposerUITextView, context: Context) { + context.coordinator.parent = self + textView.acceptsImages = acceptsImages + textView.onPasteImages = onPasteImages + textView.onDismissKeyboard = onDismissKeyboard + textView.isEditable = !isReadOnly + + let shouldApplySelection = selectionRequest.map { + context.coordinator.lastAppliedSelectionRequestID != $0.id + } ?? false + context.coordinator.isApplyingProgrammaticUpdate = true + defer { + context.coordinator.isApplyingProgrammaticUpdate = false + onSelectionChange(textView.selectedRange) + } + if textView.text != text { + let previousText = textView.text ?? "" + let selectedRange = textView.selectedRange + textView.text = text + if !shouldApplySelection { + let location = FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: previousText, + newText: text, + selectedLocation: selectedRange.location + ) + let length = previousText.isEmpty + ? 0 + : min(selectedRange.length, text.utf16.count - location) + textView.selectedRange = NSRange(location: location, length: length) + textView.scrollSelectionIntoView() + } + } + if shouldApplySelection, let selectionRequest { + let location = min(selectionRequest.location, textView.text.utf16.count) + textView.selectedRange = NSRange(location: location, length: 0) + textView.scrollSelectionIntoView() + context.coordinator.lastAppliedSelectionRequestID = selectionRequest.id + } + updateAccessibility(textView) + + if context.coordinator.lastAppliedFocus != focused { + context.coordinator.lastAppliedFocus = focused + if focused, !textView.isFirstResponder { + textView.becomeFirstResponderWhenAttached() + } else if !focused { + textView.cancelPendingFirstResponder() + if textView.isFirstResponder { + textView.resignFirstResponder() + } + } + } + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: FeatureComposerUITextView, + context: Context + ) -> CGSize? { + guard let width = proposal.width, width.isFinite else { return nil } + let fittingSize = uiView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + return CGSize( + width: width, + height: FeatureComposerTextInputSizing.height( + fittingHeight: fittingSize.height, + lineHeight: uiView.font?.lineHeight ?? 22, + availableHeight: proposal.height + ) + ) + } + + private func updateAccessibility(_ textView: FeatureComposerUITextView) { + textView.accessibilityLabel = "Message agent" + textView.accessibilityHint = acceptsImages + ? "Enter a message or paste images" + : "Enter a message" + textView.accessibilityValue = text.isEmpty ? placeholder : text + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: FeatureComposerTextInput + var lastAppliedFocus: Bool? + var lastAppliedSelectionRequestID: UUID? + var isApplyingProgrammaticUpdate = false + + init(_ parent: FeatureComposerTextInput) { + self.parent = parent + } + + func textViewDidChange(_ textView: UITextView) { + guard !isApplyingProgrammaticUpdate else { return } + guard parent.text != textView.text else { return } + parent.text = textView.text + (textView as? FeatureComposerUITextView)?.scrollSelectionIntoView() + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingProgrammaticUpdate else { return } + parent.onSelectionChange(textView.selectedRange) + } + + func textViewDidBeginEditing(_ textView: UITextView) { + lastAppliedFocus = true + if !parent.focused { + parent.focused = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + lastAppliedFocus = false + if parent.focused { + parent.focused = false + } + } + } +} + +/// Advertises image support to the paste menu and routes image pastes out to +/// the attachment pipeline. Text-only pastes fall through to UIKit untouched. +final class FeatureComposerUITextView: UITextView { + private static let bottomEditingInset: CGFloat = 10 + private var lastLaidOutBoundsSize = CGSize.zero + + func configureComposerViewport() { + clipsToBounds = true + textContainerInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: Self.bottomEditingInset, + right: 0 + ) + } + + func scrollSelectionIntoView() { + guard bounds.width > 0, bounds.height > 0 else { return } + scrollRangeToVisible(selectedRange) + guard let selection = selectedTextRange else { return } + + let caret = caretRect(for: selection.end) + let visibleBottom = contentOffset.y + bounds.height - Self.bottomEditingInset + guard caret.maxY > visibleBottom else { return } + + let maximumOffset = max( + -adjustedContentInset.top, + contentSize.height + adjustedContentInset.bottom - bounds.height + ) + let requestedOffset = caret.maxY + Self.bottomEditingInset - bounds.height + let pixelScale = traitCollection.displayScale > 0 ? traitCollection.displayScale : 1 + let alignedOffset = ceil(requestedOffset * pixelScale) / pixelScale + setContentOffset( + CGPoint(x: contentOffset.x, y: min(maximumOffset, alignedOffset)), + animated: false + ) + } + + var acceptsImages = false { + didSet { + guard oldValue != acceptsImages else { return } + pasteConfiguration = acceptsImages + ? UIPasteConfiguration( + acceptableTypeIdentifiers: [ + UTType.image.identifier, + UTType.text.identifier, + ] + ) + : nil + } + } + var onPasteImages: (([NSItemProvider]) -> Void)? + var onDismissKeyboard: (() -> Void)? + private var wantsFirstResponderOnAttach = false + + /// Programmatic focus can arrive before the view joins a window (a host + /// refocusing right as the composer expands); retry once attached. The + /// pending request is cancelled if focus clears again before the view + /// attaches, so a stale request can never raise the keyboard. + func becomeFirstResponderWhenAttached() { + if window != nil { + becomeFirstResponder() + } else { + wantsFirstResponderOnAttach = true + } + } + + func cancelPendingFirstResponder() { + wantsFirstResponderOnAttach = false + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil, wantsFirstResponderOnAttach { + wantsFirstResponderOnAttach = false + becomeFirstResponder() + } + } + + // A SwiftUI drag gesture on the composer never sees drags that start + // inside this view: the text interaction's own recognizers claim them at + // the UIKit level. This observing pan reproduces the host's + // drag-to-dismiss there: it recognizes alongside everything, cancels + // nothing, and dismisses a scrollable draft only when the drag begins at + // its top. + private let dismissPanDelegate = FeatureComposerDismissPanDelegate() + + func installDismissPanRecognizer() { + let pan = UIPanGestureRecognizer(target: self, action: #selector(handleDismissPan)) + pan.cancelsTouchesInView = false + pan.delegate = dismissPanDelegate + addGestureRecognizer(pan) + } + + private var dismissPanBeganAtTop = false + + @objc private func handleDismissPan(_ recognizer: UIPanGestureRecognizer) { + // A fast flick can jump straight from .began to .ended without a + // .changed in between, so the end state is evaluated too. The at-top + // check is latched at .began: a drag that merely reaches the top + // mid-scroll only rubber-bands, instead of yanking the keyboard away + // the moment the offset crosses zero. + switch recognizer.state { + case .began: + dismissPanBeganAtTop = contentOffset.y <= 0 + return + case .changed, .ended: break + default: return + } + guard isFirstResponder else { return } + let translation = recognizer.translation(in: self) + guard FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: translation.x, + translationY: translation.y, + isScrollable: contentOverflows, + isAtTop: dismissPanBeganAtTop + ) else { return } + onDismissKeyboard?() + } + + // Scrolling stays enabled at every size: toggling `isScrollEnabled` off + // stops UITextView from maintaining `contentSize` on some OS versions, + // which left long drafts unscrollable on device. Overflow is computed + // fresh wherever it matters instead. A stale offset from a mid-resize + // selection change is still reset; with nothing to scroll, any offset + // clips the first line under the padding. + var contentOverflows: Bool { + contentSize.height > bounds.height + 0.5 + } + + override func layoutSubviews() { + let viewportChanged = lastLaidOutBoundsSize != bounds.size + lastLaidOutBoundsSize = bounds.size + super.layoutSubviews() + if !contentOverflows, contentOffset.y != 0 { + contentOffset.y = 0 + } else if viewportChanged, isFirstResponder { + // `sizeThatFits` receives a proposal. The final UIKit viewport can + // still differ after the footer and attachments take their space. + // Recheck the caret against these actual bounds once per resize. + scrollSelectionIntoView() + } + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)), + acceptsImages, + FeatureComposerPasteboardPolicy.containsImage(in: UIPasteboard.general) { + return true + } + return super.canPerformAction(action, withSender: sender) + } + + // Drops are the other client of the paste configuration: while editing, + // UIKit offers the text view any drag it says it can paste. Declining + // image drags leaves them to the composer surface, so one target owns + // the session and the highlight; when the text view wins instead, the + // image vanishes into UITextView's text-only default and the surface's + // highlight never hears that the session ended. + override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { + let holdsImage = itemProviders.contains { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + return holdsImage ? false : super.canPaste(itemProviders) + } + + // When the pasteboard holds images, only the images attach. Any text + // riding along (a copied web image usually brings its URL) is dropped on + // purpose: Slack and X do the same, and inserting a stray URL next to an + // attached screenshot reads as a bug. + override func paste(_ sender: Any?) { + guard acceptsImages else { + super.paste(sender) + return + } + let imageProviders = UIPasteboard.general.itemProviders.filter { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + guard !imageProviders.isEmpty else { + super.paste(sender) + return + } + onPasteImages?(imageProviders) + } +} + +/// A standalone delegate (rather than the text view itself, whose scroll-view +/// superclass already takes part in gesture delegation) so the observing pan +/// reliably recognizes alongside the text interaction's own recognizers +/// instead of being cancelled by them. +private final class FeatureComposerDismissPanDelegate: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } +} + +/// Mirrors the thread view's composer drag-to-dismiss thresholds: a clearly +/// vertical downward drag. While the draft is scrollable, scrolling back +/// through it never drops the keyboard mid-read, but a drag that *begins* +/// with the draft at its top only rubber-bands, which is unambiguous +/// dismissal intent (and the composer's only escape hatch once it and the +/// keyboard cover the transcript). `isAtTop` is the position at drag start, +/// so a scroll that reaches the top never dismisses mid-gesture. +enum FeatureComposerDragDismissPolicy { + static func shouldDismiss( + translationX: CGFloat, + translationY: CGFloat, + isScrollable: Bool, + isAtTop: Bool + ) -> Bool { + (!isScrollable || isAtTop) + && translationY > 8 + && translationY > abs(translationX) + } +} + +enum FeatureComposerPasteboardPolicy { + /// `UIPasteboard.hasImages` misses formats that merely conform to image + /// (HEIC screenshots among them), so detection goes through UTType + /// conformance instead. + static func containsImage(in pasteboard: UIPasteboard) -> Bool { + pasteboard.itemProviders.contains { + $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) + } + } +} + +/// A one-shot caret placement, applied by the text input exactly once per +/// request `id`. Command completion issues one so the caret lands after the +/// inserted text instead of wherever UIKit leaves it after a programmatic +/// text replacement. +struct FeatureComposerTextSelectionRequest: Equatable { + let id = UUID() + let location: Int +} + +/// Selection changes come from `updateUIView` and UIKit delegate callbacks. +/// Keeping this value outside Observation avoids synchronous SwiftUI state +/// writes while the representable is updating. +@MainActor +@Observable +final class FeatureComposerTextObservation { + @ObservationIgnored var selection = NSRange(location: 0, length: 0) +} + +enum FeatureComposerTextSelectionPolicy { + /// UTF-16 caret location after `range` (character indices, as produced by + /// the trigger parser) is replaced with `replacement`. + static func cursorLocation( + afterReplacing range: Range, + in text: String, + with replacement: String + ) -> Int { + let lower = min(max(range.lowerBound, 0), text.count) + let lowerIndex = text.index(text.startIndex, offsetBy: lower) + return text[.. Int { + previousText.isEmpty ? newText.utf16.count : min(selectedLocation, newText.utf16.count) + } +} + +/// The editor grows with its content, then scrolls when it reaches the line +/// cap or the space above the composer controls. A finite SwiftUI proposal is +/// a hard bound. Returning a larger minimum makes the parent clip the editor +/// under its fixed footer. +enum FeatureComposerTextInputSizing { + static let maximumLines: CGFloat = 12 + + static func height( + fittingHeight: CGFloat, + lineHeight: CGFloat, + availableHeight: CGFloat? = nil + ) -> CGFloat { + let maximumHeight = max(0, lineHeight * maximumLines) + let contentHeight = max(0, fittingHeight) + guard let availableHeight, availableHeight.isFinite else { + return min(contentHeight, maximumHeight) + } + return min(contentHeight, maximumHeight, max(0, availableHeight)) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift new file mode 100644 index 000000000000..030268b20d17 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -0,0 +1,981 @@ +import AVFoundation +import SwiftUI +import UIKit + +struct FeatureModelRefreshError: LocalizedError { + var errorDescription: String? { "Couldn’t refresh models." } +} + +struct FeatureComposerView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @State private var isManuallyExpanded = false + @State private var isAttachmentFlowActive = false + @State private var isModelPickerPresented = false + @State private var restoresFocusAfterModelPickerDismissal = false + @State private var attachmentPreparation = FeatureAttachmentPreparationState() + @State private var pathEntries: [FeatureComposerPathEntry] = [] + @State private var isPathSearchLoading = false + @State private var pathSearchError: String? + @State private var textSelectionRequest: FeatureComposerTextSelectionRequest? + @State private var imageIntakeErrorMessage: String? + @State private var textRevision: UInt64 = 0 + @State private var textObservation = FeatureComposerTextObservation() + @State private var voiceInputController = FeatureVoiceInputController() + @Binding private var text: String + @Binding private var selection: FeatureSelection? + @Binding private var attachments: [FeatureDraftAttachment] + + private let providers: [FeatureProvider] + private let draftOwnerID: String + private let environmentID: String? + private let draftStorageKey: String? + private let environmentIsConnected: Bool + private let attachmentUploads: FeatureAttachmentUploadCoordinator + private let attachmentPreferences: FeatureEnvironmentPreferences + private let onRefreshModels: (() async throws -> Void)? + private let threadSelection: FeatureSelection? + private let materializesDefaultSelection: Bool + private let isSending: Bool + private let isWorking: Bool + @Binding private var focused: Bool + private let contextUsage: Double? + private let forceExpanded: Bool + private let pendingApprovals: [FeatureApproval] + private let pendingUserInputs: [FeatureUserInput] + private let isResolvingRequest: Bool + private let powerFeatures: FeatureComposerPowerFeatures + private let onSend: () -> Void + private let onStop: () -> Void + private let onDismissKeyboard: (() -> Void)? + private let onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? + private let onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? + + init( + text: Binding, + selection: Binding, + attachments: Binding<[FeatureDraftAttachment]>, + draftOwnerID: String, + environmentID: String?, + draftStorageKey: String?, + environmentIsConnected: Bool, + attachmentUploads: FeatureAttachmentUploadCoordinator, + attachmentPreferences: FeatureEnvironmentPreferences, + providers: [FeatureProvider], + threadSelection: FeatureSelection?, + materializesDefaultSelection: Bool = true, + isSending: Bool, + isWorking: Bool, + focused: Binding, + onSend: @escaping () -> Void, + onStop: @escaping () -> Void, + contextUsage: Double? = nil, + forceExpanded: Bool = false, + pendingApprovals: [FeatureApproval] = [], + pendingUserInputs: [FeatureUserInput] = [], + isResolvingRequest: Bool = false, + powerFeatures: FeatureComposerPowerFeatures = .disabled, + onDismissKeyboard: (() -> Void)? = nil, + onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, + onUserInputSubmit: ((String, [String: FeatureInputAnswer]) -> Void)? = nil, + onRefreshModels: (() async throws -> Void)? = nil + ) { + _text = text + _selection = selection + _attachments = attachments + self.draftOwnerID = draftOwnerID + self.environmentID = environmentID + self.draftStorageKey = draftStorageKey + self.environmentIsConnected = environmentIsConnected + self.attachmentUploads = attachmentUploads + self.attachmentPreferences = attachmentPreferences + self.onRefreshModels = onRefreshModels + self.providers = providers + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.isSending = isSending + self.isWorking = isWorking + _focused = focused + self.onSend = onSend + self.onStop = onStop + self.contextUsage = contextUsage + self.forceExpanded = forceExpanded + self.pendingApprovals = pendingApprovals + self.pendingUserInputs = pendingUserInputs + self.isResolvingRequest = isResolvingRequest + self.powerFeatures = powerFeatures + self.onDismissKeyboard = onDismissKeyboard + self.onApprovalDecision = onApprovalDecision + self.onUserInputSubmit = onUserInputSubmit + } + + var body: some View { + composerSurface + .overlay(alignment: .top) { + if showsCommandMenu, let trigger = composerTrigger { + // Offset by the menu's deterministic height so it sits + // fully above the composer and the active `$`/`@`/`/` + // token stays readable while typing. An alignment-guide + // override here never actually moved the menu, which + // left it covering the text entry. + FeatureComposerCommandPopover( + triggerKind: trigger.kind, + items: commandMenuItems, + isLoading: isPathSearchLoading, + errorMessage: pathSearchError, + pathSearchAvailable: powerFeatures.searchPaths != nil, + onSelect: selectCommandItem + ) + .offset( + y: -(FeatureComposerCommandPopover.height( + forItemCount: commandMenuItems.count + ) + 12) + ) + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + .padding(.bottom, 10) + .background { + LinearGradient( + colors: [ + .clear, + T3Colors.background.opacity(0.94), + T3Colors.background, + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } + .onChange(of: focused) { + if FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: focused, + textIsEmpty: textIsEmpty, + attachmentsAreEmpty: attachments.isEmpty, + isAttachmentFlowActive: isAttachmentFlowActive || isModelPickerPresented, + isPreparingAttachments: attachmentPreparation.isPreparing + ) { + isManuallyExpanded = false + } + } + .task(id: pathSearchRequest) { + await updatePathSearch() + } + .onAppear { + synchronizeVoiceDraft(ownerChanged: false) + } + .onDisappear { + voiceInputController.cancel() + } + .onChange(of: text) { + textRevision &+= 1 + synchronizeVoiceDraft(ownerChanged: false) + } + .onChange(of: draftOwnerID) { + synchronizeVoiceDraft(ownerChanged: true) + } + .onChange(of: voiceInputController.pendingCommit?.id) { + applyPendingVoiceCommit() + } + .onChange(of: scenePhase) { _, phase in + if phase == .background { + voiceInputController.appMovedToBackground() + } + } + .onReceive(NotificationCenter.default.publisher( + for: AVAudioSession.interruptionNotification + )) { notification in + guard let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] + as? UInt, + AVAudioSession.InterruptionType(rawValue: rawType) == .began else { return } + voiceInputController.recordingWasInterrupted() + } + .alert( + "Couldn’t add image", + isPresented: Binding( + get: { imageIntakeErrorMessage != nil }, + set: { if !$0 { imageIntakeErrorMessage = nil } } + ) + ) { + Button("OK") { imageIntakeErrorMessage = nil } + } message: { + Text(imageIntakeErrorMessage ?? "") + } + } + + private var composerSurface: some View { + VStack(spacing: 0) { + if let approval = pendingApprovals.first, let onApprovalDecision { + FeatureComposerApprovalPanel( + approval: approval, + position: 1, + total: pendingApprovals.count, + isResponding: isResolvingRequest, + onDecision: { decision in + onApprovalDecision(approval.id, decision) + }, + onCancelTurn: onStop + ) + } else if let input = pendingUserInputs.first, let onUserInputSubmit { + FeatureComposerUserInputPanel( + input: input, + isResponding: isResolvingRequest, + onSubmit: { answers in + onUserInputSubmit(input.id, answers) + } + ) + } else if isExpanded { + expandedComposer + } else { + collapsedComposer + } + } + .background(T3Colors.input.opacity(0.98), in: composerShape) + .overlay { + composerShape + .stroke(T3Colors.inputBorder, lineWidth: 1) + } + .clipShape(composerShape) + .modifier( + FeatureComposerImageDrop( + isEnabled: imagesAllowed && !voiceInputController.isBusy, + shape: composerShape, + onDropImages: attachDroppedImages + ) + ) + } + + private var collapsedComposer: some View { + HStack(spacing: 4) { + Button { + isManuallyExpanded = true + Task { @MainActor in + await Task.yield() + focused = true + } + } label: { + Text(composerPlaceholder) + .font(T3Typography.composer) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Message agent") + .accessibilityHint("Opens the message editor") + + submitButton + .padding(.trailing, 7) + + if voiceInputController.isSupported { + voiceInputButton + .padding(.trailing, 3) + } + } + .padding(.leading, 14) + .padding(.vertical, 7) + } + + private var expandedComposer: some View { + VStack(spacing: 0) { + if !attachments.isEmpty { + FeatureAttachmentStrip(attachments: $attachments) + .padding(.horizontal, 12) + .padding(.top, 3) + .padding(.bottom, 8) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .overlay(T3Colors.separator) + .padding(.horizontal, 13) + } + + // Return is always editing input. Sending is deliberately + // button-only, which is UITextView's native return behavior. + ZStack(alignment: .topLeading) { + FeatureComposerTextInput( + text: $text, + focused: $focused, + placeholder: composerPlaceholder, + acceptsImages: imagesAllowed, + isReadOnly: voiceInputController.isBusy, + selectionRequest: textSelectionRequest, + onSelectionChange: handleTextSelectionChange, + onPasteImages: attachImageProviders, + onDismissKeyboard: onDismissKeyboard + ) + .padding(.horizontal, 16) + .padding(.top, 14) + + if text.isEmpty { + Text(composerPlaceholder) + .font(T3Typography.composer) + .foregroundStyle(T3Colors.textTertiary) + .padding(.horizontal, 16) + .padding(.top, 14) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .padding(.bottom, 7) + .frame(minHeight: 62, alignment: .top) + .layoutPriority(1) + .clipped() + + if let attachmentBlocker { + Label(attachmentBlocker, systemImage: "exclamationmark.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + + if attachmentPreparation.isPreparing { + Label(attachmentPreparation.statusLabel, systemImage: "hourglass") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + .accessibilityIdentifier("attachment-preparing") + } + + if let uploadStatus { + uploadStatusView(uploadStatus) + } + + if voiceInputController.phase == .error { + voiceInputError + } + + composerFooter + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + } + } + + private var composerFooter: some View { + Group { + if voiceInputController.isBusy { + voiceInputFooter + } else { + standardComposerFooter + } + } + } + + private var standardComposerFooter: some View { + HStack(spacing: 2) { + FeatureImageAttachmentPicker( + attachments: $attachments, + preparationState: $attachmentPreparation, + isFlowActive: $isAttachmentFlowActive, + draftOwnerID: draftOwnerID, + environmentID: environmentID, + imagesAllowed: imagesAllowed, + maximumFileBytes: attachmentPreferences.maxFileAttachmentBytes + ) + + ProviderModelPicker( + providers: providers, + selection: $selection, + style: .compact, + threadSelection: threadSelection, + materializesDefaultSelection: materializesDefaultSelection, + onRefresh: onRefreshModels, + onPresentationChange: handleModelPickerPresentation + ) + .frame(maxWidth: 220, alignment: .leading) + .layoutPriority(2) + + Spacer(minLength: 0) + + if voiceInputController.isSupported { + voiceInputButton + } + + if let contextUsage { + FeatureContextMeter(usage: contextUsage) + } + + submitButton + .padding(.leading, 4) + } + .padding(.horizontal, 7) + .padding(.top, 2) + .padding(.bottom, 8) + } + + private var voiceInputButton: some View { + Button(action: startVoiceInput) { + Image(systemName: "mic") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Start voice input") + .accessibilityIdentifier("voice-input-start") + } + + private var voiceInputFooter: some View { + HStack(spacing: 8) { + voiceInputStatus + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + Spacer(minLength: 0) + + Button("Cancel") { + voiceInputController.cancel() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: T3Metrics.minimumTapTarget) + + if voiceInputController.phase == .recording { + Button("Stop") { + voiceInputController.stop() + } + .font(T3Typography.supporting.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .frame(minHeight: 34) + .background(T3Colors.accent, in: Capsule()) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityLabel("Stop recording and transcribe") + } + } + .padding(.horizontal, 12) + .padding(.top, 2) + .padding(.bottom, 8) + } + + @ViewBuilder + private var voiceInputStatus: some View { + switch voiceInputController.phase { + case .preparing: + Text("Preparing") + case .recording: + TimelineView(.periodic(from: .now, by: 1)) { context in + Text("Recording \(voiceRecordingDuration(at: context.date))") + .monospacedDigit() + } + case .transcribing: + Text("Transcribing") + case .idle, .error: + EmptyView() + } + } + + private var voiceInputError: some View { + HStack(spacing: 8) { + Text(voiceInputController.errorMessage ?? "Voice input failed.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + + if let action = voiceInputController.errorAction { + Button(action == .settings ? "Settings" : "Retry") { + if action == .settings, + let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } else { + startVoiceInput() + } + } + .font(T3Typography.supporting.weight(.semibold)) + } + + Button("Dismiss") { + voiceInputController.cancel() + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(.horizontal, 15) + .padding(.bottom, 4) + } + + private var submitButton: some View { + Button(action: performPrimaryAction) { + Image(systemName: submitSymbol) + .font(.system(size: showsStop ? 11 : 14, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(showsStop ? T3Colors.danger : T3Colors.accent, in: Circle()) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(submitDisabled) + .opacity(submitDisabled ? 0.3 : 1) + .accessibilityLabel(submitAccessibilityLabel) + .accessibilityIdentifier(showsStop ? "thread-stop" : "message-send") + } + + private var composerPlaceholder: String { + isWorking ? "Queue a message…" : "Ask anything…" + } + + private var submitSymbol: String { + if isSending { return "ellipsis" } + return showsStop ? "stop.fill" : "arrow.up" + } + + private var submitAccessibilityLabel: String { + if isSending { return "Sending message" } + if showsStop { return "Stop agent" } + return isWorking ? "Queue message" : "Send message" + } + + private var composerShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 22, style: .continuous) + } + + private var isExpanded: Bool { + forceExpanded + || isManuallyExpanded + || focused + || !textIsEmpty + || !attachments.isEmpty + || attachmentPreparation.isPreparing + || voiceInputController.isBusy + || voiceInputController.phase == .error + } + + private var showsStop: Bool { + isWorking && textIsEmpty && attachments.isEmpty + } + + private var submitDisabled: Bool { + isSending || (!showsStop && !canSend) + } + + private var textIsEmpty: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canSend: Bool { + guard composerTrigger?.kind != .model else { return false } + return FeatureComposerSubmissionEligibility.canSend( + text: text, + attachmentCount: attachments.count, + imagesAllowed: imagesAllowed, + filesAllowed: attachmentPreferences.maxFileAttachmentBytes != nil, + containsImages: attachments.contains { $0.mimeType.hasPrefix("image/") }, + containsFiles: attachments.contains { !$0.mimeType.hasPrefix("image/") }, + isSending: isSending, + preparationState: attachmentPreparation + ) && !uploadsBlockSend + } + + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages( + selection: selection ?? threadSelection, + providers: providers + ) + } + + private var attachmentBlocker: String? { + if attachments.contains(where: { !$0.mimeType.hasPrefix("image/") }), + attachmentPreferences.maxFileAttachmentBytes == nil { + return "This environment does not accept file attachments" + } + if attachments.contains(where: { $0.mimeType.hasPrefix("image/") }), !imagesAllowed { + return "Choose a model that accepts images" + } + return nil + } + + private struct UploadStatus { + var pendingCount = 0 + var failures: [(UUID, String)] = [] + } + + private var applicableUploadStates: [(UUID, FeatureAttachmentUploadState?)] { + guard environmentIsConnected, let environmentID, draftStorageKey != nil else { return [] } + return attachments.compactMap { attachment in + let isImage = attachment.mimeType.hasPrefix("image/") + let uploadsHere = isImage + ? attachmentPreferences.supportsImageUploads + : attachmentPreferences.maxFileAttachmentBytes != nil + guard uploadsHere else { return nil } + return ( + attachment.id, + attachmentUploads.state( + environmentID: environmentID, + attachmentID: attachment.id + ) + ) + } + } + + private var uploadsBlockSend: Bool { + applicableUploadStates.contains { _, state in + if case .some(.ready) = state { return false } + return true + } + } + + private var uploadStatus: UploadStatus? { + var result = UploadStatus() + for (id, state) in applicableUploadStates { + switch state { + case .some(.ready): break + case let .some(.failed(message)): result.failures.append((id, message)) + case .some(.queued), .some(.uploading), .none: result.pendingCount += 1 + } + } + return result.pendingCount == 0 && result.failures.isEmpty ? nil : result + } + + private func uploadStatusView(_ status: UploadStatus) -> some View { + VStack(alignment: .leading, spacing: 4) { + if status.pendingCount > 0 { + Text("Uploading \(status.pendingCount) attachment\(status.pendingCount == 1 ? "" : "s")") + } + ForEach(status.failures, id: \.0) { failure in + HStack(spacing: 8) { + Text(failure.1).lineLimit(2) + Spacer(minLength: 0) + Button("Retry") { + guard let environmentID else { return } + attachmentUploads.retry( + environmentID: environmentID, + attachmentID: failure.0 + ) + } + } + } + } + .font(T3Typography.supporting) + .foregroundStyle(status.failures.isEmpty ? T3Colors.textSecondary : T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(.bottom, 4) + .accessibilityIdentifier("attachment-upload-status") + } + + /// Trigger detection walks the whole draft with character indices and is + /// read from several computed properties per body evaluation, so one parse + /// per keystroke is memoized instead of four. + private final class TriggerMemo { + var text: String? + var trigger: FeatureComposerTrigger? + } + + @State private var triggerMemo = TriggerMemo() + + private var composerTrigger: FeatureComposerTrigger? { + if triggerMemo.text == text { return triggerMemo.trigger } + let trigger = FeatureComposerTriggerParser.detect(in: text) + triggerMemo.text = text + triggerMemo.trigger = trigger + return trigger + } + + private var commandMenuItems: [FeatureComposerMenuItem] { + guard let composerTrigger else { return [] } + return FeatureComposerMenuBuilder.items( + trigger: composerTrigger, + providers: providers, + currentSelection: selection, + threadSelection: threadSelection, + powerFeatures: powerFeatures, + pathEntries: pathEntries + ) + } + + private var showsCommandMenu: Bool { + isExpanded + && !voiceInputController.isBusy + && pendingApprovals.isEmpty + && pendingUserInputs.isEmpty + && composerTrigger != nil + } + + private var pathSearchRequest: FeatureComposerPathSearchRequest? { + guard let trigger = composerTrigger, + trigger.kind == .path, + powerFeatures.searchPaths != nil else { + return nil + } + let query = trigger.query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return nil } + return FeatureComposerPathSearchRequest( + scopeID: powerFeatures.pathSearchScopeID, + query: query + ) + } + + @MainActor + private func updatePathSearch() async { + guard let request = pathSearchRequest, let searchPaths = powerFeatures.searchPaths else { + pathEntries = [] + isPathSearchLoading = false + pathSearchError = nil + return + } + + pathEntries = [] + pathSearchError = nil + isPathSearchLoading = true + do { + try await Task.sleep(for: .milliseconds(140)) + let result = try await searchPaths(request.query) + guard !Task.isCancelled else { return } + pathEntries = result + isPathSearchLoading = false + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + pathSearchError = "Couldn’t search files." + isPathSearchLoading = false + } + } + + private func selectCommandItem(_ item: FeatureComposerMenuItem) { + guard let trigger = composerTrigger else { return } + let replacement: String + switch item { + case .modelCommand: + replacement = "/model " + case let .model(nextSelection, _, _): + selection = nextSelection + replacement = "" + case let .providerCommand(command): + replacement = "/\(command.name) " + case let .skill(skill): + replacement = "$\(skill.name) " + case let .path(entry): + replacement = FeatureComposerFileLinkSerializer.markdownLink(for: entry.path) + " " + } + let nextCursorLocation = FeatureComposerTextSelectionPolicy.cursorLocation( + afterReplacing: trigger.range, + in: text, + with: replacement + ) + text = FeatureComposerTriggerParser.replacing( + trigger.range, + in: text, + with: replacement + ) + // Publish the text first so the representable cannot consume and clamp + // this request against the pre-replacement draft. + textSelectionRequest = FeatureComposerTextSelectionRequest( + location: nextCursorLocation + ) + pathEntries = [] + pathSearchError = nil + Task { @MainActor in + await Task.yield() + focused = true + } + } + + private func performPrimaryAction() { + if showsStop { + onStop() + } else if FeatureComposerSubmissionPolicy.allowsSend(for: .explicitButton), + canSend { + onSend() + } + } + + private func startVoiceInput() { + synchronizeVoiceDraft(ownerChanged: false) + focused = false + voiceInputController.start() + } + + private func handleTextSelectionChange(_ selection: NSRange) { + textObservation.selection = selection + voiceInputController.updateSelection(selection) + } + + private func synchronizeVoiceDraft(ownerChanged: Bool) { + let snapshot = FeatureVoiceDraftSnapshot( + ownerID: draftOwnerID, + text: text, + revision: textRevision, + selection: textObservation.selection + ) + if ownerChanged { + voiceInputController.ownerChanged(to: snapshot) + } else { + voiceInputController.updateDraft(snapshot) + } + } + + private func applyPendingVoiceCommit() { + guard let commit = voiceInputController.pendingCommit else { return } + textSelectionRequest = FeatureComposerTextSelectionRequest( + location: commit.caretLocation + ) + text = commit.text + voiceInputController.consumePendingCommit() + } + + private func voiceRecordingDuration(at date: Date) -> String { + let seconds = max(0, Int(date.timeIntervalSince( + voiceInputController.recordingStartedAt ?? date + ))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + private func handleModelPickerPresentation(_ isPresented: Bool) { + if isPresented { + restoresFocusAfterModelPickerDismissal = focused + isManuallyExpanded = true + isModelPickerPresented = true + return + } + + isModelPickerPresented = false + guard restoresFocusAfterModelPickerDismissal else { return } + restoresFocusAfterModelPickerDismissal = false + Task { @MainActor in + await Task.yield() + focused = true + } + } + + /// Attaches images arriving from the text view's paste menu or a drag + /// from another app through the same preparation pipeline the attachment + /// picker uses, so sending stays blocked until every image is processed. + private func attachImageProviders(_ providers: [NSItemProvider]) { + guard imagesAllowed, !providers.isEmpty else { return } + + guard let plan = FeatureComposerImageIntakePlan.forProviders( + providerCount: providers.count, + attachmentCount: attachments.count, + pendingCount: attachmentPreparation.pendingItemCount + ) else { + imageIntakeErrorMessage = "You can attach up to eight images." + return + } + if plan.droppedCount > 0 { + imageIntakeErrorMessage = + "Some images were not attached because the eight-image limit was reached." + } + + let accepted = Array(providers.prefix(plan.acceptedCount)) + // Begin every provider request while the paste or drop callback still + // owns access to its item providers. Image processing can finish + // asynchronously after the callback returns. + let loads = accepted.map { provider in + Result { try FeatureImageItemProviderLoader.start(from: provider) } + } + let operation = attachmentPreparation.begin(itemCount: accepted.count) + Task { @MainActor in + defer { attachmentPreparation.finish(operation) } + for (offset, load) in loads.enumerated() { + do { + let data = try await load.get().data() + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment( + from: data, + ordinal: plan.firstOrdinal + offset + ) + }.value + attachments.append(attachment) + } catch { + imageIntakeErrorMessage = error.localizedDescription + } + } + } + } + + /// A drop is refused outright when images are not accepted, so the drag + /// session shows the system's "not allowed" badge instead of a dead drop. + private func attachDroppedImages(_ providers: [NSItemProvider]) -> Bool { + guard imagesAllowed, !providers.isEmpty else { return false } + attachImageProviders(providers) + return true + } +} + +enum FeatureComposerCollapsePolicy { + static func shouldCollapse( + isFocused: Bool, + textIsEmpty: Bool, + attachmentsAreEmpty: Bool, + isAttachmentFlowActive: Bool, + isPreparingAttachments: Bool + ) -> Bool { + !isFocused + && textIsEmpty + && attachmentsAreEmpty + && !isAttachmentFlowActive + && !isPreparingAttachments + } +} + +private struct FeatureComposerPathSearchRequest: Hashable { + let scopeID: String + let query: String +} + +enum FeatureComposerSubmissionEligibility { + static func canSend( + text: String, + attachmentCount: Int, + imagesAllowed: Bool, + filesAllowed: Bool = false, + containsImages: Bool = true, + containsFiles: Bool = false, + isSending: Bool, + preparationState: FeatureAttachmentPreparationState + ) -> Bool { + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasAttachments = attachmentCount > 0 + return !isSending + && !preparationState.isPreparing + && (hasText || hasAttachments) + && (!hasAttachments || !containsImages || imagesAllowed) + && (!hasAttachments || !containsFiles || filesAllowed) + } +} + +enum FeatureComposerSubmissionIntent: Equatable { + case explicitButton + case returnKey +} + +enum FeatureComposerSubmissionPolicy { + static func allowsSend(for intent: FeatureComposerSubmissionIntent) -> Bool { + intent == .explicitButton + } +} + +private struct FeatureContextMeter: View { + let usage: Double + + var body: some View { + ZStack { + Circle() + .stroke(T3Colors.border, lineWidth: 2) + Circle() + .trim(from: 0, to: clampedUsage) + .stroke( + T3Colors.textSecondary, + style: StrokeStyle(lineWidth: 2, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + } + .frame(width: 18, height: 18) + .frame(width: 30, height: T3Metrics.minimumTapTarget) + .accessibilityElement() + .accessibilityLabel("Context used") + .accessibilityValue("\(Int((clampedUsage * 100).rounded())) percent") + } + + private var clampedUsage: Double { + min(max(usage, 0), 1) + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift b/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift new file mode 100644 index 000000000000..22599e65e273 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureVoiceInputController.swift @@ -0,0 +1,417 @@ +import Foundation +import Observation + +enum FeatureVoiceInputPhase: Equatable { + case idle + case preparing + case recording + case transcribing + case error +} + +enum FeatureVoiceInputErrorAction: Equatable { + case retry + case settings +} + +struct FeatureVoiceDraftSnapshot: Equatable { + let ownerID: String + let text: String + let revision: UInt64 + let selection: NSRange +} + +struct FeatureVoiceTranscriptCommit: Equatable, Identifiable { + let id = UUID() + let text: String + let caretLocation: Int +} + +enum FeatureVoiceTranscriptCommitResult: Equatable { + case commit(FeatureVoiceTranscriptCommit) + case empty + case stale +} + +enum FeatureVoiceTranscriptResolver { + static func resolve( + captured: FeatureVoiceDraftSnapshot, + current: FeatureVoiceDraftSnapshot?, + transcript: String, + localeIdentifier: String + ) -> FeatureVoiceTranscriptCommitResult { + guard let current, + current.ownerID == captured.ownerID, + current.text == captured.text, + current.revision == captured.revision, + captured.selection.location >= 0, + captured.selection.length >= 0, + captured.selection.location <= captured.text.utf16.count, + captured.selection.length <= captured.text.utf16.count + - captured.selection.location, + Range(captured.selection, in: captured.text) != nil else { + return .stale + } + + let replacement = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !replacement.isEmpty else { return .empty } + + var insertion = replacement + let normalizedLocale = localeIdentifier + .replacingOccurrences(of: "_", with: "-") + .lowercased() + if captured.selection.length == 0, + normalizedLocale == "en" || normalizedLocale.hasPrefix("en-") { + let text = captured.text as NSString + let location = captured.selection.location + let left = location > 0 ? text.character(at: location - 1) : nil + let right = location < text.length ? text.character(at: location) : nil + let leftNeedsSpace = left.map(Self.leftBoundaryCharacters.contains) == true + && (right == nil || right.map(Self.isWhitespace) == true) + let rightNeedsSpace = right.map(Self.rightBoundaryCharacters.contains) == true + && (left == nil || left.map(Self.isWhitespace) == true) + insertion = "\(leftNeedsSpace ? " " : "")\(replacement)\(rightNeedsSpace ? " " : "")" + } + + let nextText = (captured.text as NSString).replacingCharacters( + in: captured.selection, + with: insertion + ) + return .commit(FeatureVoiceTranscriptCommit( + text: nextText, + caretLocation: captured.selection.location + insertion.utf16.count + )) + } + + private static let leftBoundaryCharacters = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.!?,:;)]}'\"" + .utf16 + ) + private static let rightBoundaryCharacters = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789([{'\"" + .utf16 + ) + + private static func isWhitespace(_ codeUnit: unichar) -> Bool { + guard let scalar = UnicodeScalar(codeUnit) else { return false } + return CharacterSet.whitespacesAndNewlines.contains(scalar) + } +} + +enum FeatureVoiceMicrophonePermission: Equatable { + case granted + case denied +} + +@MainActor +protocol FeatureVoiceInputAdapter: AnyObject { + var isSupported: Bool { get } + var localeIdentifier: String { get } + + func prepare() async throws + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission + func startRecording(maximumDuration: TimeInterval) throws + func stopRecording() async throws -> URL + func transcribe(recordingURL: URL) async throws -> String + func cancelTranscription() async + func cleanup() async +} + +@MainActor +private enum FeatureVoiceInputOperationGate { + static var owner: UUID? + + static func acquire(_ candidate: UUID) -> Bool { + guard owner == nil else { return false } + owner = candidate + return true + } + + static func release(_ candidate: UUID) { + if owner == candidate { owner = nil } + } +} + +@MainActor +@Observable +final class FeatureVoiceInputController { + static let maximumRecordingDuration: TimeInterval = 5 * 60 + + private(set) var phase: FeatureVoiceInputPhase = .idle + private(set) var errorMessage: String? + private(set) var errorAction: FeatureVoiceInputErrorAction? + private(set) var recordingStartedAt: Date? + private(set) var pendingCommit: FeatureVoiceTranscriptCommit? + + @ObservationIgnored private let adapter: any FeatureVoiceInputAdapter + @ObservationIgnored private var currentDraft: FeatureVoiceDraftSnapshot? + @ObservationIgnored private var capturedDraft: FeatureVoiceDraftSnapshot? + @ObservationIgnored private var operationID: UUID? + @ObservationIgnored private var operationTask: Task? + @ObservationIgnored private var recordingLimitTask: Task? + + init(adapter: any FeatureVoiceInputAdapter = FeatureVoiceInputAdapterFactory.make()) { + self.adapter = adapter + } + + var isSupported: Bool { adapter.isSupported } + + var isBusy: Bool { + phase == .preparing || phase == .recording || phase == .transcribing + } + + func updateDraft(_ snapshot: FeatureVoiceDraftSnapshot) { + currentDraft = snapshot + } + + func updateSelection(_ selection: NSRange) { + guard let currentDraft else { return } + self.currentDraft = FeatureVoiceDraftSnapshot( + ownerID: currentDraft.ownerID, + text: currentDraft.text, + revision: currentDraft.revision, + selection: selection + ) + } + + func start() { + guard phase == .idle || phase == .error else { return } + guard adapter.isSupported else { + setError("Voice transcription requires a supported device with iOS 26 or later.", nil) + return + } + guard let currentDraft else { + setError("This draft is no longer available.", .retry) + return + } + + let id = UUID() + guard FeatureVoiceInputOperationGate.acquire(id) else { + setError("Another voice recording is still finishing.", .retry) + return + } + + operationID = id + capturedDraft = currentDraft + pendingCommit = nil + setPhase(.preparing) + operationTask = Task { [weak self] in + await self?.prepareAndStartRecording(id: id) + } + } + + func stop() { + guard phase == .recording, let id = operationID else { return } + recordingLimitTask?.cancel() + recordingLimitTask = nil + recordingStartedAt = nil + setPhase(.transcribing) + operationTask = Task { [weak self] in + await self?.stopAndTranscribe(id: id) + } + } + + func cancel() { + switch phase { + case .idle: + return + case .error: + clearError() + case .preparing: + invalidateOperation() + setPhase(.idle) + case .recording: + guard let id = operationID else { + setPhase(.idle) + return + } + invalidateOperation() + setPhase(.idle) + operationTask = Task { [weak self] in + await self?.cleanupAndRelease(id: id) + } + case .transcribing: + invalidateOperation() + setPhase(.idle) + Task { [weak self] in + await self?.adapter.cancelTranscription() + } + } + } + + func ownerChanged(to snapshot: FeatureVoiceDraftSnapshot) { + if currentDraft?.ownerID != snapshot.ownerID { + pendingCommit = nil + if phase != .idle { cancel() } + } + currentDraft = snapshot + } + + func appMovedToBackground() { + if isBusy { cancel() } + } + + func recordingWasInterrupted() { + guard phase == .recording, let id = operationID else { return } + invalidateOperation() + setError("Voice recording was interrupted.", .retry) + operationTask = Task { [weak self] in + await self?.cleanupAndRelease(id: id) + } + } + + func consumePendingCommit() { + pendingCommit = nil + } + + func waitForCurrentOperation() async { + await operationTask?.value + } + + private func prepareAndStartRecording(id: UUID) async { + do { + // Locale resolution and asset installation happen before the app + // asks for microphone access. A permission prompt must not hide a + // long asset download. + try await adapter.prepare() + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + + guard await adapter.requestMicrophonePermission() == .granted else { + await cleanupAndRelease(id: id) + if operationID == id { + setError("Microphone access is required for voice input.", .settings) + operationID = nil + } + return + } + guard isCurrent(id), draftContentMatches(capturedDraft, currentDraft) else { + await cleanupAndRelease(id: id) + if operationID == id { + setError("This draft is no longer available.", .retry) + operationID = nil + } + return + } + + try adapter.startRecording(maximumDuration: Self.maximumRecordingDuration) + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + recordingStartedAt = .now + setPhase(.recording) + scheduleRecordingLimit(for: id) + } catch { + await cleanupAndRelease(id: id) + if operationID == id { + operationID = nil + setError("Could not prepare voice input.", .retry) + } + } + } + + private func stopAndTranscribe(id: UUID) async { + do { + let recordingURL = try await adapter.stopRecording() + guard isCurrent(id), let capturedDraft else { + await cleanupAndRelease(id: id) + return + } + + let transcript = try await adapter.transcribe(recordingURL: recordingURL) + guard isCurrent(id) else { + await cleanupAndRelease(id: id) + return + } + + let result = FeatureVoiceTranscriptResolver.resolve( + captured: capturedDraft, + current: currentDraft, + transcript: transcript, + localeIdentifier: adapter.localeIdentifier + ) + await cleanupAndRelease(id: id) + guard operationID == id else { return } + operationID = nil + self.capturedDraft = nil + switch result { + case let .commit(commit): + pendingCommit = commit + setPhase(.idle) + case .empty: + setError("No speech was detected.", .retry) + case .stale: + setError( + "The draft changed while voice input was running. The transcript was not added.", + .retry + ) + } + } catch { + await cleanupAndRelease(id: id) + if operationID == id { + operationID = nil + setError("Could not transcribe this recording.", .retry) + } + } + } + + private func scheduleRecordingLimit(for id: UUID) { + recordingLimitTask?.cancel() + recordingLimitTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.maximumRecordingDuration)) + guard !Task.isCancelled, let self, self.isCurrent(id) else { return } + self.stop() + } + } + + private func invalidateOperation() { + operationID = nil + capturedDraft = nil + recordingStartedAt = nil + recordingLimitTask?.cancel() + recordingLimitTask = nil + } + + private func cleanupAndRelease(id: UUID) async { + await adapter.cleanup() + FeatureVoiceInputOperationGate.release(id) + } + + private func isCurrent(_ id: UUID) -> Bool { + operationID == id + } + + private func draftContentMatches( + _ captured: FeatureVoiceDraftSnapshot?, + _ current: FeatureVoiceDraftSnapshot? + ) -> Bool { + guard let captured, let current else { return false } + return captured.ownerID == current.ownerID + && captured.text == current.text + && captured.revision == current.revision + } + + private func setPhase(_ phase: FeatureVoiceInputPhase) { + self.phase = phase + if phase != .error { + errorMessage = nil + errorAction = nil + } + } + + private func setError(_ message: String, _ action: FeatureVoiceInputErrorAction?) { + phase = .error + errorMessage = message + errorAction = action + recordingStartedAt = nil + } + + private func clearError() { + errorMessage = nil + errorAction = nil + setPhase(.idle) + } +} diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift new file mode 100644 index 000000000000..66d405f643f5 --- /dev/null +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -0,0 +1,798 @@ +import ImageIO +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers +import UIKit + +enum FeatureImageAttachmentLimits { + /// Shared by every attachment entry point (picker, camera, files, and + /// paste), so their in-flight reservations count against the same cap. + static let maximumCount = 8 +} + +struct FeatureAttachmentPreparationState: Equatable { + struct Operation: Hashable { + fileprivate let id: UUID + } + + private var pendingItemsByOperation: [Operation: Int] = [:] + + var isPreparing: Bool { + !pendingItemsByOperation.isEmpty + } + + var pendingItemCount: Int { + pendingItemsByOperation.values.reduce(0, +) + } + + var statusLabel: String { + pendingItemCount == 1 + ? "Preparing attachment…" + : "Preparing \(pendingItemCount) attachments…" + } + + @discardableResult + mutating func begin(itemCount: Int, id: UUID = UUID()) -> Operation { + let operation = Operation(id: id) + pendingItemsByOperation[operation] = max(1, itemCount) + return operation + } + + mutating func finish(_ operation: Operation) { + pendingItemsByOperation.removeValue(forKey: operation) + } +} + +struct FeatureAttachmentOperationIdentity: Equatable { + let ownerID: String + let environmentID: String? + let generation: UUID + + func matches(ownerID: String, environmentID: String?, generation: UUID) -> Bool { + self.ownerID == ownerID + && self.environmentID == environmentID + && self.generation == generation + } +} + +struct FeatureImageAttachmentPicker: View { + private enum Source { + case photoLibrary + case camera + case files + } + + @Binding var attachments: [FeatureDraftAttachment] + @Binding var preparationState: FeatureAttachmentPreparationState + @Binding var isFlowActive: Bool + let maximumCount: Int + let draftOwnerID: String + let environmentID: String? + let imagesAllowed: Bool + let maximumFileBytes: Int? + + @State private var isAttachmentSourcePresented = false + @State private var isPhotoLibraryPresented = false + @State private var pendingPhotoLibraryItems: [FeaturePhotoLibraryItem] = [] + @State private var isCameraPresented = false + @State private var isFileImporterPresented = false + @State private var sourcePresentationTask: Task? + @State private var errorMessage: String? + @State private var generation = UUID() + @State private var flowIdentity: FeatureAttachmentOperationIdentity? + + init( + attachments: Binding<[FeatureDraftAttachment]>, + preparationState: Binding, + isFlowActive: Binding, + draftOwnerID: String, + environmentID: String?, + imagesAllowed: Bool, + maximumFileBytes: Int?, + maximumCount: Int = FeatureImageAttachmentLimits.maximumCount + ) { + _attachments = attachments + _preparationState = preparationState + _isFlowActive = isFlowActive + self.maximumCount = maximumCount + self.draftOwnerID = draftOwnerID + self.environmentID = environmentID + self.imagesAllowed = imagesAllowed + self.maximumFileBytes = maximumFileBytes + } + + var body: some View { + Button { + flowIdentity = FeatureAttachmentOperationIdentity( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) + isFlowActive = true + isAttachmentSourcePresented = true + } label: { + Image(systemName: preparationState.isPreparing ? "hourglass" : "paperclip") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canAdd) + .opacity(canAdd ? 1 : 0.3) + .accessibilityLabel(attachmentAccessibilityLabel) + .accessibilityIdentifier("image-attachment-picker") + .accessibilityHint(attachmentAccessibilityHint) + .confirmationDialog("Add attachment", isPresented: $isAttachmentSourcePresented) { + Button("Photo Library") { present(.photoLibrary) } + .disabled(!imagesAllowed && maximumFileBytes == nil) + Button("Camera") { present(.camera) } + .disabled(!imagesAllowed || !UIImagePickerController.isSourceTypeAvailable(.camera)) + Button("Files") { present(.files) } + Button("Cancel", role: .cancel) { + isFlowActive = false + } + } + .fullScreenCover( + isPresented: $isPhotoLibraryPresented, + onDismiss: finishPhotoLibrarySelection + ) { + FeaturePhotoLibraryPicker( + maximumCount: max(1, remainingCount), + imagesAllowed: imagesAllowed, + videosAllowed: maximumFileBytes != nil, + onFinish: { items in + pendingPhotoLibraryItems = items + isPhotoLibraryPresented = false + } + ) + .ignoresSafeArea() + } + .fullScreenCover(isPresented: $isCameraPresented) { + FeatureCameraPicker( + onCapture: loadCapturedImage, + onCancel: { + isCameraPresented = false + isFlowActive = false + } + ) + .ignoresSafeArea() + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: maximumFileBytes == nil ? [.image] : [.item], + allowsMultipleSelection: true, + onCompletion: loadFiles + ) + .alert( + "Couldn’t add attachment", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "") + } + .onDisappear { + sourcePresentationTask?.cancel() + if !isFlowActive { + generation = UUID() + flowIdentity = nil + } + } + .onChange(of: draftOwnerID) { + generation = UUID() + flowIdentity = nil + pendingPhotoLibraryItems = [] + } + .onChange(of: environmentID) { + generation = UUID() + flowIdentity = nil + pendingPhotoLibraryItems = [] + } + } + + private var remainingCount: Int { + max(0, maximumCount - attachments.count) + } + + private var canAdd: Bool { + (imagesAllowed || maximumFileBytes != nil) + && !preparationState.isPreparing && remainingCount > 0 + } + + private var attachmentAccessibilityLabel: String { + if preparationState.isPreparing { return preparationState.statusLabel } + if remainingCount == 0 { return "Attachment limit reached" } + return "Add attachment" + } + + private var attachmentAccessibilityHint: String { + if !imagesAllowed && maximumFileBytes == nil { return "Attachments are not supported" } + if remainingCount == 0 { return "Remove an attachment before adding another" } + return maximumFileBytes == nil + ? "Choose a photo, take a photo, or browse image files" + : "Choose a photo, video, or file" + } + + private func present(_ source: Source) { + sourcePresentationTask?.cancel() + isAttachmentSourcePresented = false + sourcePresentationTask = Task { @MainActor in + // A confirmation dialog is still the active presenter while its action + // runs. Wait for its dismissal animation before presenting another + // controller or UIKit can reject (or race) the new presentation. + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled, canAdd else { + isFlowActive = false + return + } + switch source { + case .photoLibrary: + isPhotoLibraryPresented = true + case .camera: + isCameraPresented = true + case .files: + isFileImporterPresented = true + } + } + } + + private func finishPhotoLibrarySelection() { + guard let identity = flowIdentity else { + pendingPhotoLibraryItems = [] + isFlowActive = false + return + } + Task { @MainActor in + // Keep PhotosUI presentation and asset materialization in separate turns. + // Some OS versions become stuck or dismiss mid-selection when the picker + // and its selection are driven by the same SwiftUI binding transaction. + await Task.yield() + guard !isPhotoLibraryPresented, !pendingPhotoLibraryItems.isEmpty, canAdd else { + pendingPhotoLibraryItems = [] + isFlowActive = false + return + } + + let selected = Array(pendingPhotoLibraryItems.prefix(remainingCount)) + pendingPhotoLibraryItems = [] + let firstOrdinal = attachments.count + preparationState.pendingItemCount + 1 + let operation = preparationState.begin(itemCount: selected.count) + + defer { + preparationState.finish(operation) + isFlowActive = false + } + + for (offset, item) in selected.enumerated() { + do { + let attachment = try await item.loadAttachment( + ordinal: firstOrdinal + offset, + maximumFileBytes: maximumFileBytes + ) + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { + discardOwnedFile(for: attachment) + return + } + if attachment.mimeType.hasPrefix("image/"), !imagesAllowed { + throw FeatureAttachmentIntakeError.imagesUnsupported + } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + } + } + } + } + + private func loadCapturedImage(_ image: UIImage) { + isCameraPresented = false + guard canAdd else { + isFlowActive = false + return + } + guard let identity = flowIdentity else { + isFlowActive = false + return + } + let operation = preparationState.begin(itemCount: 1) + + Task { + defer { + preparationState.finish(operation) + isFlowActive = false + } + do { + let ordinal = attachments.count + 1 + let data = try await Task.detached(priority: .userInitiated) { + guard let data = image.jpegData(compressionQuality: 0.94) else { + throw FeatureImageAttachmentError.encodingFailed + } + return data + }.value + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { return } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func loadFiles(_ result: Result<[URL], Error>) { + switch result { + case .failure(let error): + errorMessage = error.localizedDescription + isFlowActive = false + case .success(let urls): + guard !urls.isEmpty, canAdd, let identity = flowIdentity else { + isFlowActive = false + return + } + let operation = preparationState.begin(itemCount: min(urls.count, remainingCount)) + + Task { + defer { + preparationState.finish(operation) + isFlowActive = false + } + for url in urls.prefix(remainingCount) { + do { + let attachment = try await prepareFile(url) + guard identity.matches( + ownerID: draftOwnerID, + environmentID: environmentID, + generation: generation + ) else { + discardOwnedFile(for: attachment) + return + } + attachments.append(attachment) + } catch { + errorMessage = error.localizedDescription + break + } + } + } + } + } + + private func prepareFile(_ url: URL) async throws -> FeatureDraftAttachment { + let type = UTType(filenameExtension: url.pathExtension) + if type?.conforms(to: .image) == true { + guard imagesAllowed else { throw FeatureAttachmentIntakeError.imagesUnsupported } + let ordinal = attachments.count + 1 + let data = try await Task.detached(priority: .userInitiated) { + let hasAccess = url.startAccessingSecurityScopedResource() + defer { if hasAccess { url.stopAccessingSecurityScopedResource() } } + return try Data(contentsOf: url, options: .mappedIfSafe) + }.value + return try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + } + guard let maximumFileBytes else { throw FeatureAttachmentIntakeError.filesUnsupported } + let id = UUID() + let owned = try await Task.detached(priority: .userInitiated) { + try ManagedAttachmentFileStore().copyOwnedFile( + from: url, + attachmentID: id, + originalFileName: url.lastPathComponent, + maximumBytes: maximumFileBytes + ) + }.value + return FeatureDraftAttachment( + id: id, + ownedFile: owned, + filename: url.lastPathComponent, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + ) + } + + private func appendImage(_ data: Data, ordinal: Int? = nil) async throws { + let ordinal = ordinal ?? attachments.count + 1 + let attachment = try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + attachments.append(attachment) + } + + private func discardOwnedFile(for attachment: FeatureDraftAttachment) { + guard let fileName = attachment.ownedFile?.fileName else { return } + try? ManagedAttachmentFileStore().removeOwnedFile(fileName: fileName) + } +} + +private struct FeaturePhotoLibraryItem: @unchecked Sendable { + let provider: NSItemProvider + + @MainActor + func loadAttachment( + ordinal: Int, + maximumFileBytes: Int? + ) async throws -> FeatureDraftAttachment { + if provider.registeredTypeIdentifiers.contains(where: { + UTType($0)?.conforms(to: .image) == true + }) { + let data = try await FeatureImageItemProviderLoader.data(from: provider) + return try await Task.detached(priority: .userInitiated) { + try FeatureImageProcessor.attachment(from: data, ordinal: ordinal) + }.value + } + guard let maximumFileBytes else { throw FeatureAttachmentIntakeError.filesUnsupported } + return try await FeatureFileItemProviderLoader.attachment( + from: provider, + maximumBytes: maximumFileBytes + ) + } +} + +enum FeatureFileItemProviderLoader { + @MainActor + static func attachment( + from provider: NSItemProvider, + maximumBytes: Int + ) async throws -> FeatureDraftAttachment { + guard let identifier = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .movie) == true + || UTType($0)?.conforms(to: .item) == true + }) else { throw FeatureAttachmentIntakeError.invalidFile } + let type = UTType(identifier) + let id = UUID() + let preferredExtension = type?.preferredFilenameExtension + let suggestedName = provider.suggestedName ?? "Attachment" + let suggestedURL = URL(fileURLWithPath: suggestedName) + let fileName = suggestedURL.pathExtension.isEmpty + ? preferredExtension.map { "\(suggestedName).\($0)" } ?? suggestedName + : suggestedName + + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: identifier) { url, error in + do { + guard let url else { + throw error ?? FeatureAttachmentIntakeError.invalidFile + } + // The provider deletes this URL when the callback returns. + let owned = try ManagedAttachmentFileStore().copyOwnedFile( + from: url, + attachmentID: id, + originalFileName: fileName, + maximumBytes: maximumBytes + ) + continuation.resume(returning: FeatureDraftAttachment( + id: id, + ownedFile: owned, + filename: fileName, + mimeType: type?.preferredMIMEType ?? "application/octet-stream" + )) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} + +/// Loads raw image bytes from an `NSItemProvider`, shared by the photo +/// library picker and the composer's paste path. Main-actor isolated because +/// providers arrive from main-actor UI callbacks and are not `Sendable`; the +/// provider does its own work off-thread. +enum FeatureImageItemProviderLoader { + struct Load { + fileprivate let values: AsyncThrowingStream + + @MainActor + func data() async throws -> Data { + for try await data in values { + return data + } + throw FeatureImageAttachmentError.encodingFailed + } + } + + /// Starts the provider request before returning. Drop callers use this + /// form so access begins within `performDrop`, while the provider grant is + /// active. + @MainActor + static func start(from provider: NSItemProvider) throws -> Load { + guard let typeIdentifier = provider.registeredTypeIdentifiers.first(where: { identifier in + UTType(identifier)?.conforms(to: .image) == true + }) else { + throw FeatureImageAttachmentError.invalidImage + } + + let values = AsyncThrowingStream { continuation in + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let data { + continuation.yield(data) + continuation.finish() + } else { + continuation.finish( + throwing: error ?? FeatureImageAttachmentError.encodingFailed + ) + } + } + } + return Load(values: values) + } + + @MainActor + static func data(from provider: NSItemProvider) async throws -> Data { + try await start(from: provider).data() + } +} + +private struct FeaturePhotoLibraryPicker: UIViewControllerRepresentable { + let maximumCount: Int + let imagesAllowed: Bool + let videosAllowed: Bool + let onFinish: @MainActor ([FeaturePhotoLibraryItem]) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration() + configuration.filter = if imagesAllowed && videosAllowed { + .any(of: [.images, .videos]) + } else if videosAllowed { + .videos + } else { + .images + } + configuration.selectionLimit = maximumCount + configuration.selection = .ordered + configuration.preferredAssetRepresentationMode = .compatible + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ picker: PHPickerViewController, context: Context) {} + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + private let onFinish: @MainActor ([FeaturePhotoLibraryItem]) -> Void + private var didFinish = false + + init(onFinish: @escaping @MainActor ([FeaturePhotoLibraryItem]) -> Void) { + self.onFinish = onFinish + } + + func picker(_: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + guard !didFinish else { return } + didFinish = true + + let items = results.map { FeaturePhotoLibraryItem(provider: $0.itemProvider) } + Task { @MainActor in + onFinish(items) + } + } + } +} + +struct FeatureAttachmentStrip: View { + @Binding var attachments: [FeatureDraftAttachment] + + var body: some View { + if !attachments.isEmpty { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(attachments) { attachment in + FeatureAttachmentThumbnail(attachment: attachment) { + attachments.removeAll { $0.id == attachment.id } + } + } + } + .padding(.horizontal, 1) + } + .scrollIndicators(.hidden) + .accessibilityLabel("\(attachments.count) attachments") + } + } +} + +private struct FeatureAttachmentThumbnail: View { + let attachment: FeatureDraftAttachment + let onRemove: () -> Void + @State private var image: UIImage? + + var body: some View { + ZStack(alignment: .topTrailing) { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if attachment.mimeType.hasPrefix("image/") { + Image(systemName: "photo") + .foregroundStyle(T3Colors.textSecondary) + } else { + VStack(spacing: 3) { + Image(systemName: "doc") + Text(attachment.filename) + .font(.caption2) + .lineLimit(1) + Text(ByteCountFormatter.string( + fromByteCount: Int64(attachment.byteCount), + countStyle: .file + )) + .font(.caption2) + } + .foregroundStyle(T3Colors.textSecondary) + } + } + .frame(width: 58, height: 58) + .background(T3Colors.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(.black.opacity(0.78), in: Circle()) + .frame( + width: T3Metrics.minimumTapTarget, + height: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .offset(x: 11, y: -11) + .accessibilityLabel("Remove \(attachment.filename)") + } + .padding(.top, 11) + .padding(.trailing, 11) + .task(id: attachment.id) { + guard attachment.mimeType.hasPrefix("image/") else { return } + let data = attachment.thumbnailData ?? attachment.data + image = await Task.detached(priority: .utility) { + UIImage(data: data) + }.value + } + } +} + +private struct FeatureCameraPicker: UIViewControllerRepresentable { + let onCapture: (UIImage) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCapture: onCapture, onCancel: onCancel) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let controller = UIImagePickerController() + controller.sourceType = .camera + controller.cameraCaptureMode = .photo + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ controller: UIImagePickerController, context: Context) {} + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + private let onCapture: (UIImage) -> Void + private let onCancel: () -> Void + + init(onCapture: @escaping (UIImage) -> Void, onCancel: @escaping () -> Void) { + self.onCapture = onCapture + self.onCancel = onCancel + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + guard let image = info[.originalImage] as? UIImage else { + onCancel() + return + } + onCapture(image) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + onCancel() + } + } +} + +enum FeatureImageProcessor { + private static let maximumDimension: CGFloat = 2_048 + private static let maximumEncodedBytes = 10 * 1_024 * 1_024 + + static func attachment( + from sourceData: Data, + ordinal: Int + ) throws -> FeatureDraftAttachment { + guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumDimension, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary + ) else { + throw FeatureImageAttachmentError.invalidImage + } + + let preparedImage = UIImage(cgImage: image) + guard let data = preparedImage.jpegData(compressionQuality: 0.82), + let thumbnailData = thumbnail(from: preparedImage) else { + throw FeatureImageAttachmentError.encodingFailed + } + guard data.count <= maximumEncodedBytes else { + throw FeatureImageAttachmentError.tooLarge + } + + return FeatureDraftAttachment( + data: data, + thumbnailData: thumbnailData, + filename: "Image \(ordinal).jpg", + mimeType: "image/jpeg" + ) + } + + private static func thumbnail(from image: UIImage) -> Data? { + let longestSide = max(image.size.width, image.size.height) + let scale = min(1, 160 / longestSide) + let size = CGSize( + width: max(1, image.size.width * scale), + height: max(1, image.size.height * scale) + ) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: size, format: format) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + }.jpegData(compressionQuality: 0.72) + } +} + +enum FeatureImageAttachmentError: LocalizedError { + case invalidImage + case encodingFailed + case tooLarge + + var errorDescription: String? { + switch self { + case .invalidImage: + "That photo could not be read." + case .encodingFailed: + "That photo could not be prepared." + case .tooLarge: + "Images must be smaller than 10 MB." + } + } +} + +enum FeatureAttachmentIntakeError: LocalizedError { + case invalidFile + case filesUnsupported + case imagesUnsupported + + var errorDescription: String? { + switch self { + case .invalidFile: "That file could not be read." + case .filesUnsupported: "This environment does not accept file attachments." + case .imagesUnsupported: "The selected model does not accept images." + } + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownDocument.swift b/apps/swift-ios/Features/Chat/MarkdownDocument.swift new file mode 100644 index 000000000000..4fbf384fa8c4 --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownDocument.swift @@ -0,0 +1,812 @@ +import Foundation + +/// A small block-level Markdown model used by chat messages. +/// +/// Foundation already provides a strong inline Markdown parser. This layer only +/// separates the block structures that `Text` otherwise flattens, keeping chat +/// rendering native and dependency-free. +struct MarkdownDocument: Equatable, Sendable { + let blocks: [MarkdownBlock] + + init(parsing source: String) { + var parser = MarkdownBlockParser( + source: CodexMarkdownDirectives.replacingFileCitations(in: source) + ) + blocks = parser.parse() + } + + fileprivate init(blocks: [MarkdownBlock]) { + self.blocks = blocks + } +} + +indirect enum MarkdownBlock: Equatable, Sendable { + case paragraph(String) + case image(MarkdownImage) + case heading(level: Int, text: String) + case unorderedList([MarkdownListItem]) + case orderedList(start: Int, items: [MarkdownListItem]) + case blockquote(MarkdownDocument) + case table(MarkdownTable) + case codeBlock(language: String?, code: String) + case thematicBreak + case artifactTemplate(CodexArtifactTemplate) +} + +struct MarkdownImage: Equatable, Sendable { + let source: String + let alternativeText: String +} + +enum MarkdownImageSource: Equatable, Sendable { + case direct(URL) + case workspaceFile(String) + case blocked + + static func classify(_ rawSource: String, workspaceRoot: String? = nil) -> Self { + var source = rawSource.trimmingCharacters(in: .whitespacesAndNewlines) + if source.hasPrefix("<"), source.hasSuffix(">") { + source = String(source.dropFirst().dropLast()) + } + guard !source.isEmpty, !source.hasPrefix("#"), !source.hasPrefix("?") else { + return .blocked + } + + let lowercased = source.lowercased() + if lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") + || lowercased.hasPrefix("data:") || lowercased.hasPrefix("blob:") { + return URL(string: source).map(Self.direct) ?? .blocked + } + if source.hasPrefix("//") { + return URL(string: "https:\(source)").map(Self.direct) ?? .blocked + } + if lowercased.hasPrefix("file:") { + guard let components = URLComponents(string: source), + components.scheme?.lowercased() == "file" else { + return .blocked + } + let decodedPath = components.percentEncodedPath.removingPercentEncoding + ?? components.percentEncodedPath + guard !decodedPath.isEmpty else { return .blocked } + if let host = components.host, !host.isEmpty, host.lowercased() != "localhost" { + return .workspaceFile( + "\\\\\(host)\(decodedPath.replacingOccurrences(of: "/", with: "\\"))" + ) + } + return .workspaceFile(normalizeWindowsDrivePath(decodedPath)) + } + + let pathEnd = source.firstIndex(where: { $0 == "?" || $0 == "#" }) ?? source.endIndex + let decodedPath = String(source[.. String { + guard value.count >= 4, value.first == "/", isWindowsDrivePath(String(value.dropFirst())) + else { return value } + return String(value.dropFirst()) + } + + private static func isWindowsDrivePath(_ value: String) -> Bool { + value.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil + } + + private static func hasURIScheme(_ value: String) -> Bool { + value.range(of: #"^[A-Za-z][A-Za-z0-9+.-]*:"#, options: .regularExpression) != nil + } +} + +enum MarkdownWorkspaceFileLink { + static func relativePath(for url: URL, workspaceRoot: String) -> String? { + let raw = url.absoluteString + let lowercase = raw.lowercased() + if lowercase.hasPrefix("http://") || lowercase.hasPrefix("https://") + || lowercase.hasPrefix("data:") || lowercase.hasPrefix("javascript:") { + return nil + } + + var path: String + if url.isFileURL { + // URL.path is decoded and already excludes a real query or fragment. + path = url.path + } else { + let pathEnd = raw.firstIndex(where: { $0 == "#" || $0 == "?" }) ?? raw.endIndex + let encodedPath = String(raw[..]+>|[^\s)]+)(?:\s+[\"'][^\"']*[\"'])?\s*\)"# + ) + + private let lines: [String] + private var index = 0 + + init(source: String) { + let normalized = source + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + lines = normalized.components(separatedBy: "\n") + } + + mutating func parse() -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + + while index < lines.count { + if lines[index].isMarkdownBlank { + index += 1 + continue + } + + if let fence = fenceMarker(in: lines[index]) { + blocks.append(parseCodeBlock(opening: fence)) + continue + } + + if let template = CodexMarkdownDirectives.artifactTemplate(from: lines[index]) { + blocks.append(.artifactTemplate(template)) + index += 1 + continue + } + + if let heading = atxHeading(in: lines[index]) { + blocks.append(.heading(level: heading.level, text: heading.text)) + index += 1 + continue + } + + if let table = tableOpening(at: index) { + blocks.append(parseTable(opening: table)) + continue + } + + if let level = setextHeadingLevel(after: index) { + blocks.append(.heading(level: level, text: lines[index].markdownTrimmed)) + index += 2 + continue + } + + if blockquoteContent(in: lines[index]) != nil { + blocks.append(parseBlockquote()) + continue + } + + if let marker = listMarker(in: lines[index]) { + blocks.append(parseList(opening: marker)) + continue + } + + if isThematicBreak(lines[index]) { + blocks.append(.thematicBreak) + index += 1 + continue + } + + blocks.append(contentsOf: parseParagraph()) + } + + return blocks + } + + private mutating func parseCodeBlock(opening: FenceMarker) -> MarkdownBlock { + index += 1 + var codeLines: [String] = [] + + while index < lines.count { + let line = lines[index] + if isClosingFence(line, matching: opening) { + index += 1 + break + } + codeLines.append(line) + index += 1 + } + + return .codeBlock(language: opening.language, code: codeLines.joined(separator: "\n")) + } + + private mutating func parseBlockquote() -> MarkdownBlock { + var quotedLines: [String] = [] + + while index < lines.count, let content = blockquoteContent(in: lines[index]) { + quotedLines.append(content) + index += 1 + } + + var parser = MarkdownBlockParser(source: quotedLines.joined(separator: "\n")) + return .blockquote(MarkdownDocument(blocks: parser.parse())) + } + + private mutating func parseTable(opening: TableOpening) -> MarkdownBlock { + index += 2 + var rows: [[String]] = [] + + while index < lines.count, + !lines[index].isMarkdownBlank, + let cells = tableCells(in: lines[index]) { + var normalized = Array(cells.prefix(opening.header.count)) + if normalized.count < opening.header.count { + normalized.append( + contentsOf: repeatElement( + "", + count: opening.header.count - normalized.count + ) + ) + } + rows.append(normalized) + index += 1 + } + + return .table( + MarkdownTable( + header: opening.header, + alignments: opening.alignments, + rows: rows + ) + ) + } + + private mutating func parseList(opening: ListMarker) -> MarkdownBlock { + var items: [MarkdownListItem] = [] + let ordered = opening.number != nil + + while index < lines.count, + let marker = listMarker(in: lines[index]), + marker.indent == opening.indent, + (marker.number != nil) == ordered { + index += 1 + var itemLines = [marker.content] + + while index < lines.count { + let line = lines[index] + + if line.isMarkdownBlank { + let next = nextNonblankLine(after: index) + guard let next else { + index = lines.count + break + } + + if let nextMarker = listMarker(in: lines[next]), + nextMarker.indent == opening.indent, + (nextMarker.number != nil) == ordered { + index = next + break + } + + if lines[next].markdownLeadingSpaces > opening.indent { + itemLines.append("") + index += 1 + continue + } + + index = next + break + } + + if let nextMarker = listMarker(in: line), nextMarker.indent == opening.indent { + break + } + + let leadingSpaces = line.markdownLeadingSpaces + if leadingSpaces > opening.indent { + let continuationIndent = min( + leadingSpaces, + max(opening.indent + 2, marker.contentIndent) + ) + itemLines.append(line.droppingLeadingSpaces(continuationIndent)) + index += 1 + continue + } + + if isBlockStarter(line) { + break + } + + // CommonMark permits a paragraph continuation without indentation. + itemLines.append(line) + index += 1 + } + + let task = taskState(in: itemLines.first ?? "") + if task != nil, !itemLines.isEmpty { + itemLines[0] = removingTaskMarker(from: itemLines[0]) + } + var itemParser = MarkdownBlockParser(source: itemLines.joined(separator: "\n")) + items.append(MarkdownListItem(task: task, blocks: itemParser.parse())) + + guard index < lines.count, + let nextMarker = listMarker(in: lines[index]), + nextMarker.indent == opening.indent, + (nextMarker.number != nil) == ordered else { + break + } + } + + if let start = opening.number { + return .orderedList(start: start, items: items) + } + return .unorderedList(items) + } + + private mutating func parseParagraph() -> [MarkdownBlock] { + var paragraphLines: [String] = [] + + while index < lines.count, !lines[index].isMarkdownBlank { + if !paragraphLines.isEmpty, + (isBlockStarter(lines[index]) || tableOpening(at: index) != nil) { + break + } + paragraphLines.append(lines[index].markdownTrimmedTrailing) + index += 1 + } + + return imageBlocks(in: paragraphLines.joined(separator: "\n")) + } + + private func imageBlocks(in paragraph: String) -> [MarkdownBlock] { + guard paragraph.contains("!["), let expression = Self.imageExpression else { + return [.paragraph(paragraph)] + } + let source = paragraph as NSString + let matches = expression.matches( + in: paragraph, + range: NSRange(location: 0, length: source.length) + ) + guard !matches.isEmpty else { return [.paragraph(paragraph)] } + + var blocks: [MarkdownBlock] = [] + var cursor = 0 + for match in matches { + let preceding = source.substring(with: NSRange( + location: cursor, + length: match.range.location - cursor + )).trimmingCharacters(in: .whitespacesAndNewlines) + if !preceding.isEmpty { + blocks.append(.paragraph(preceding)) + } + blocks.append(.image(MarkdownImage( + source: source.substring(with: match.range(at: 2)), + alternativeText: source.substring(with: match.range(at: 1)) + ))) + cursor = match.range.location + match.range.length + } + let trailing = source.substring(from: cursor) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !trailing.isEmpty { + blocks.append(.paragraph(trailing)) + } + return blocks + } + + private func tableOpening(at position: Int) -> TableOpening? { + guard position + 1 < lines.count, + let header = tableCells(in: lines[position]), + let delimiters = tableCells(in: lines[position + 1]), + header.count == delimiters.count, + !header.isEmpty else { + return nil + } + + let alignments = delimiters.compactMap(tableAlignment(in:)) + guard alignments.count == delimiters.count else { return nil } + return TableOpening(header: header, alignments: alignments) + } + + private func tableAlignment(in source: String) -> MarkdownTableAlignment? { + var marker = source.markdownTrimmed + let hasLeadingColon = marker.first == ":" + let hasTrailingColon = marker.last == ":" + if hasLeadingColon { marker.removeFirst() } + if hasTrailingColon, !marker.isEmpty { marker.removeLast() } + guard marker.count >= 3, marker.allSatisfy({ $0 == "-" }) else { return nil } + return switch (hasLeadingColon, hasTrailingColon) { + case (true, true): .center + case (true, false): .leading + case (false, true): .trailing + case (false, false): .natural + } + } + + /// Splits a GFM table row while preserving escapes for Foundation's inline parser. + /// Pipes inside code spans or escaped with a backslash remain cell content. + private func tableCells(in line: String) -> [String]? { + let source = line.markdownTrimmed + guard !source.isEmpty else { return nil } + + var cells = [String]() + var cell = "" + var codeFenceLength: Int? + var foundSeparator = false + + let characters = Array(source) + var cursor = 0 + while cursor < characters.count { + let character = characters[cursor] + if character == "\\", cursor + 1 < characters.count { + cell.append(character) + cell.append(characters[cursor + 1]) + cursor += 2 + continue + } + if character == "`" { + var runEnd = cursor + while runEnd < characters.count, characters[runEnd] == "`" { + runEnd += 1 + } + let runLength = runEnd - cursor + for _ in cursor.. Bool { + if CodexMarkdownDirectives.artifactTemplate(from: line) != nil { return true } + return fenceMarker(in: line) != nil + || atxHeading(in: line) != nil + || blockquoteContent(in: line) != nil + || listMarker(in: line) != nil + || isThematicBreak(line) + } + + private func nextNonblankLine(after position: Int) -> Int? { + var candidate = position + 1 + while candidate < lines.count { + if !lines[candidate].isMarkdownBlank { + return candidate + } + candidate += 1 + } + return nil + } + + private func atxHeading(in line: String) -> (level: Int, text: String)? { + let characters = Array(line) + let indent = min(line.markdownLeadingSpaces, characters.count) + guard indent <= 3, indent < characters.count, characters[indent] == "#" else { + return nil + } + + var cursor = indent + while cursor < characters.count, characters[cursor] == "#" { + cursor += 1 + } + let level = cursor - indent + guard level <= 6, + cursor == characters.count || characters[cursor].isMarkdownWhitespace else { + return nil + } + + while cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + var content = String(characters[cursor.. Int? { + guard position + 1 < lines.count, !lines[position].isMarkdownBlank else { + return nil + } + let underline = lines[position + 1].markdownTrimmed + guard !underline.isEmpty else { return nil } + if underline.allSatisfy({ $0 == "=" }) { + return 1 + } + if underline.allSatisfy({ $0 == "-" }) { + return 2 + } + return nil + } + + private func blockquoteContent(in line: String) -> String? { + let characters = Array(line) + let indent = min(line.markdownLeadingSpaces, characters.count) + guard indent <= 3, indent < characters.count, characters[indent] == ">" else { + return nil + } + var cursor = indent + 1 + if cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + return cursor < characters.count ? String(characters[cursor...]) : "" + } + + private func listMarker(in line: String) -> ListMarker? { + let characters = Array(line) + let indent = min(line.markdownLeadingSpaces, characters.count) + guard indent <= 3, indent < characters.count else { return nil } + + var cursor = indent + var number: Int? + + if ["-", "+", "*"].contains(characters[cursor]) { + cursor += 1 + } else if characters[cursor].isNumber { + let numberStart = cursor + while cursor < characters.count, + characters[cursor].isNumber, + cursor - numberStart < 9 { + cursor += 1 + } + guard cursor > numberStart, + cursor < characters.count, + characters[cursor] == "." || characters[cursor] == ")", + let parsedNumber = Int(String(characters[numberStart.. MarkdownTaskState? { + let characters = Array(line) + guard characters.count >= 3, + characters[0] == "[", + characters[2] == "]", + [" ", "x", "X"].contains(characters[1]), + characters.count == 3 || characters[3].isMarkdownWhitespace else { + return nil + } + return characters[1] == " " ? .incomplete : .complete + } + + private func removingTaskMarker(from line: String) -> String { + let characters = Array(line) + var cursor = min(3, characters.count) + while cursor < characters.count, characters[cursor].isMarkdownWhitespace { + cursor += 1 + } + return cursor < characters.count ? String(characters[cursor...]) : "" + } + + private func isThematicBreak(_ line: String) -> Bool { + let trimmed = line.markdownTrimmed + guard let marker = trimmed.first, ["-", "_", "*"].contains(marker) else { + return false + } + let visible = trimmed.filter { !$0.isMarkdownWhitespace } + return visible.count >= 3 && visible.allSatisfy { $0 == marker } + } + + private func fenceMarker(in line: String) -> FenceMarker? { + let characters = Array(line) + let indent = min(line.markdownLeadingSpaces, characters.count) + guard indent <= 3, + indent < characters.count, + characters[indent] == "`" || characters[indent] == "~" else { + return nil + } + + let character = characters[indent] + var cursor = indent + while cursor < characters.count, characters[cursor] == character { + cursor += 1 + } + let length = cursor - indent + guard length >= 3 else { return nil } + + let info = cursor < characters.count + ? String(characters[cursor...]).markdownTrimmed + : "" + guard character != "`" || !info.contains("`") else { return nil } + return FenceMarker( + character: character, + length: length, + language: info.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) + ) + } + + private func isClosingFence(_ line: String, matching opening: FenceMarker) -> Bool { + let characters = Array(line) + let indent = min(line.markdownLeadingSpaces, characters.count) + guard indent <= 3, + indent < characters.count, + characters[indent] == opening.character else { + return false + } + + var cursor = indent + while cursor < characters.count, characters[cursor] == opening.character { + cursor += 1 + } + guard cursor - indent >= opening.length else { return false } + return characters[cursor.. String { + String(dropFirst(Swift.min(count, markdownLeadingSpaces))) + } +} + +private extension Character { + var isMarkdownWhitespace: Bool { + self == " " || self == "\t" + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift new file mode 100644 index 000000000000..62e8498a023b --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -0,0 +1,1122 @@ +import SwiftUI +import UIKit + +struct MarkdownImageContext: Equatable, @unchecked Sendable { + let threadID: String + let workspaceRoot: String + let resolver: any FeatureWorkspaceAssetResolving + var sourceFilePath: String? = nil + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.threadID == rhs.threadID + && lhs.workspaceRoot == rhs.workspaceRoot + && lhs.sourceFilePath == rhs.sourceFilePath + && ObjectIdentifier(lhs.resolver) == ObjectIdentifier(rhs.resolver) + } +} + +/// Native chat Markdown with block-aware layout and Foundation inline parsing. +struct MarkdownMessageView: View { + private struct RenderRequest: Hashable { + let revision: MarkdownContentRevision + let isStreaming: Bool + } + + private let source: String + private let revision: MarkdownContentRevision + private let isStreaming: Bool + private let copyActionTitle: String + private let imageContext: MarkdownImageContext? + @State private var selectionSource: MarkdownSelectionSource + @State private var renderedDocument: MarkdownRenderedDocument? + @State private var streamingRenderer = StreamingMarkdownRenderer() + + init( + _ source: String, + isStreaming: Bool = false, + copyActionTitle: String = "Copy message", + imageContext: MarkdownImageContext? = nil + ) { + self.source = source + self.isStreaming = isStreaming + self.copyActionTitle = copyActionTitle + self.imageContext = imageContext + _selectionSource = State(initialValue: MarkdownSelectionSource(source)) + let revision = MarkdownContentRevision(source) + self.revision = revision + let initialDocument = if isStreaming { + MarkdownRenderCache.shared.cachedDocument(for: revision) + } else { + MarkdownRenderCache.shared.documentImmediately(for: revision) + } + _renderedDocument = State( + initialValue: initialDocument + ) + } + + var body: some View { + let selectionContext = selectionContext + Group { + if let displayDocument { + MarkdownBlocksView( + blocks: displayDocument.blocks, + selectionContext: selectionContext, + imageContext: imageContext + ) + } else { + // Parsing waits briefly so token-by-token streaming cancels stale revisions + // instead of scheduling work for content the user will never see. + Text(verbatim: source) + .font(T3Typography.threadBody) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityAction(named: copyActionTitle) { + UIPasteboard.general.string = source + } + .task(id: RenderRequest(revision: revision, isStreaming: isStreaming)) { + if !isStreaming { + streamingRenderer.cancel() + // Streaming -> complete usually keeps the final text; promote + // the last streamed render instead of reparsing synchronously. + if let renderedDocument, renderedDocument.revision == revision { + MarkdownRenderCache.shared.promote(renderedDocument) + return + } + renderedDocument = MarkdownRenderCache.shared.documentImmediately(for: revision) + return + } + + if let cached = MarkdownRenderCache.shared.cachedDocument(for: revision) { + renderedDocument = cached + return + } + + // Hand the revision to a renderer that outlives this task. The + // task modifier cancels on every revision, so rendering inside it + // starves as soon as parsing is slower than the publish cadence; + // the renderer instead keeps one render running and always picks + // up the newest revision when it finishes (latest wins). + streamingRenderer.submit(revision) { renderedDocument = $0 } + } + .onDisappear { + streamingRenderer.cancel() + } + } + + private var displayDocument: MarkdownRenderedDocument? { + if let renderedDocument, renderedDocument.revision == revision { + return renderedDocument + } + // While streaming, a slightly stale document is better than flashing + // back to plain text between renders. Streamed content only appends, + // so require the stale document to be a prefix of the current source: + // that accepts earlier snapshots of this message and rejects leftovers + // from a recycled cell showing a different message. + if isStreaming { + if let renderedDocument, + renderedDocument.revision.utf8Count <= revision.utf8Count, + source.utf8.starts(with: renderedDocument.revision.source.utf8) { + return renderedDocument + } + return nil + } + return MarkdownRenderCache.shared.documentImmediately(for: revision) + } + + private var selectionContext: MarkdownSelectionContext { + selectionSource.text = source + return MarkdownSelectionContext( + source: selectionSource, + copyActionTitle: copyActionTitle + ) + } +} + +/// Renders streaming revisions outside SwiftUI's task lifecycle so a render +/// in progress is never cancelled by the next revision arriving. One render +/// runs at a time; newer revisions replace the pending slot (latest wins) and +/// a 150ms throttle bounds the render cadence. +@MainActor +private final class StreamingMarkdownRenderer { + private let throttle: Duration = .milliseconds(150) + private var pending: MarkdownContentRevision? + private var deliver: ((MarkdownRenderedDocument) -> Void)? + private var renderTask: Task? + private var generation = 0 + private var lastRenderAt: Date? + + func submit( + _ revision: MarkdownContentRevision, + deliver: @escaping (MarkdownRenderedDocument) -> Void + ) { + pending = revision + self.deliver = deliver + guard renderTask == nil else { return } + generation += 1 + let generation = generation + renderTask = Task { [weak self] in + await self?.drain(generation: generation) + } + } + + func cancel() { + generation += 1 + renderTask?.cancel() + renderTask = nil + pending = nil + deliver = nil + } + + private func drain(generation: Int) async { + // A cancelled drain can unwind after a replacement was already + // started; only the current generation may clear the shared slot or + // deliver, so two drains can never race or regress the document. + defer { + if self.generation == generation { renderTask = nil } + } + while self.generation == generation, let revision = pending { + pending = nil + if let lastRenderAt { + let elapsed = Duration.seconds(-lastRenderAt.timeIntervalSinceNow) + if elapsed < throttle { + try? await Task.sleep(for: throttle - elapsed) + } + } + guard !Task.isCancelled else { return } + // Render the newest revision available after the throttle wait. + let target = pending ?? revision + pending = nil + guard let document = await MarkdownRenderCache.shared.document( + for: target, + isIntermediate: true + ) else { continue } + guard !Task.isCancelled, self.generation == generation else { return } + lastRenderAt = .now + deliver?(document) + } + } +} + +private final class MarkdownSelectionSource: @unchecked Sendable { + var text: String + + init(_ text: String) { + self.text = text + } +} + +private struct MarkdownSelectionContext: Equatable, Sendable { + let source: MarkdownSelectionSource + let copyActionTitle: String + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.source === rhs.source && lhs.copyActionTitle == rhs.copyActionTitle + } +} + +private enum MarkdownTextColor: Equatable, Sendable { + case primary + case secondary + + var uiColor: UIColor { + switch self { + case .primary: T3Colors.uiTextPrimary + case .secondary: T3Colors.uiTextSecondary + } + } +} + +private struct MarkdownBlocksView: View { + let blocks: [MarkdownRenderedBlock] + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + var spacing: CGFloat = 12 + var textColor: MarkdownTextColor = .primary + + var body: some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(blocks.indices, id: \.self) { index in + // Unchanged blocks share inline runs by reference across + // streaming revisions, so equatable comparison skips their + // body and layout entirely; only the changed tail re-renders. + MarkdownBlockView( + block: blocks[index], + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + .equatable() + } + } + } +} + +private struct MarkdownBlockView: View, Equatable { + let block: MarkdownRenderedBlock + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + let textColor: MarkdownTextColor + + @ViewBuilder + var body: some View { + switch block { + case let .paragraph(inline): + MarkdownInlineText( + inline, + selectionContext: selectionContext, + lineSpacing: 4, + textColor: textColor + ) + + case let .image(image): + MarkdownImageView(image: image, context: imageContext) + + case let .heading(level, inline): + MarkdownInlineText( + inline, + selectionContext: selectionContext, + textColor: textColor + ) + .padding(.top, level <= 2 ? 3 : 1) + + case let .unorderedList(items): + MarkdownListView( + items: items, + start: nil, + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + + case let .orderedList(start, items): + MarkdownListView( + items: items, + start: start, + selectionContext: selectionContext, + imageContext: imageContext, + textColor: textColor + ) + + case let .blockquote(blocks): + MarkdownBlocksView( + blocks: blocks, + selectionContext: selectionContext, + imageContext: imageContext, + spacing: 9, + textColor: .secondary + ) + .foregroundStyle(T3Colors.textSecondary) + .padding(.leading, 14) + .overlay(alignment: .leading) { + Rectangle() + .fill(T3Colors.textTertiary) + .frame(width: 2) + } + + case let .table(table): + MarkdownTableView( + table: table, + selectionContext: selectionContext, + textColor: textColor + ) + + case let .codeBlock(language, code, renderedCode): + MarkdownCodeBlockView( + language: language, + code: code, + renderedCode: renderedCode, + selectionContext: selectionContext + ) + + case let .artifactTemplate(template): + CodexArtifactTemplateView(template: template) + + case .thematicBreak: + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + .padding(.vertical, 2) + .accessibilityHidden(true) + } + } +} + +private struct MarkdownTableView: View { + let table: MarkdownRenderedTable + let selectionContext: MarkdownSelectionContext + let textColor: MarkdownTextColor + + private var columnWidths: [CGFloat] { table.columnWidths } + + var body: some View { + ScrollView(.horizontal) { + Grid(horizontalSpacing: 0, verticalSpacing: 0) { + tableRow(table.header, isHeader: true) + ForEach(table.rows.indices, id: \.self) { rowIndex in + tableRow(table.rows[rowIndex], isHeader: false) + } + } + // A horizontal ScrollView still proposes the viewport width to its child. + // Preserve the grid's measured column widths so it overflows and scrolls + // instead of compressing prose columns into unreadable slivers. + .fixedSize(horizontal: true, vertical: true) + .background(T3Colors.surface) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + } + .scrollIndicators(.visible) + .accessibilityElement(children: .contain) + .accessibilityLabel("Table with \(table.header.count) columns and \(table.rows.count) rows") + } + + private func tableRow( + _ cells: [MarkdownRenderedInline], + isHeader: Bool + ) -> some View { + GridRow(alignment: .top) { + ForEach(cells.indices, id: \.self) { columnIndex in + MarkdownInlineText( + cells[columnIndex], + selectionContext: selectionContext, + lineSpacing: 3, + textColor: textColor + ) + .frame( + width: columnWidths[columnIndex], + alignment: alignment(for: columnIndex) + ) + .frame( + minHeight: 44, + maxHeight: .infinity, + alignment: alignment(for: columnIndex) + ) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .overlay(alignment: .trailing) { + if columnIndex < cells.count - 1 { + Rectangle() + .fill(T3Colors.separator) + .frame(width: 1) + } + } + } + } + .background(isHeader ? T3Colors.surfaceRaised : T3Colors.surface) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + } + } + + private func alignment(for columnIndex: Int) -> Alignment { + guard table.alignments.indices.contains(columnIndex) else { return .leading } + return switch table.alignments[columnIndex] { + case .natural, .leading: .leading + case .center: .center + case .trailing: .trailing + } + } + +} + +private struct MarkdownListView: View { + let items: [MarkdownRenderedListItem] + let start: Int? + let selectionContext: MarkdownSelectionContext + let imageContext: MarkdownImageContext? + let textColor: MarkdownTextColor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(items.indices, id: \.self) { offset in + let item = items[offset] + HStack(alignment: .top, spacing: 8) { + marker(for: item, offset: offset) + .frame(width: 24, height: 24, alignment: .trailing) + MarkdownBlocksView( + blocks: item.blocks, + selectionContext: selectionContext, + imageContext: imageContext, + spacing: 7, + textColor: textColor + ) + } + .accessibilityElement(children: .contain) + } + } + } + + @ViewBuilder + private func marker(for item: MarkdownRenderedListItem, offset: Int) -> some View { + if let task = item.task { + Image(systemName: task == .complete ? "checkmark.square.fill" : "square") + .font(T3Typography.control) + .foregroundStyle( + task == .complete ? T3Colors.success : T3Colors.textSecondary + ) + .accessibilityLabel(task == .complete ? "Completed" : "Not completed") + } else if let start { + Text("\(start + offset).") + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Item \(start + offset)") + } else { + Text("•") + .font(T3Typography.threadBody.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityHidden(true) + } + } +} + +private struct MarkdownImageView: View { + let image: MarkdownImage + let context: MarkdownImageContext? + + @SwiftUI.Environment(\.openURL) private var openURL + @State private var loadedImage: UIImage? + @State private var previewURL: URL? + @State private var failed = false + + private var classifiedSource: MarkdownImageSource { + let basePath = context?.sourceFilePath.map { + let isWindows = $0.contains("\\") + let normalized = $0.replacingOccurrences(of: "\\", with: "/") + let parent = (normalized as NSString).deletingLastPathComponent + return isWindows ? parent.replacingOccurrences(of: "/", with: "\\") : parent + } ?? context?.workspaceRoot + return MarkdownImageSource.classify(image.source, workspaceRoot: basePath) + } + + var body: some View { + if classifiedSource != .blocked { + Group { + if let loadedImage { + Image(uiImage: loadedImage) + .resizable() + .scaledToFit() + .frame(maxHeight: 480) + } else { + Image(systemName: failed ? "exclamationmark.triangle" : "photo") + .font(.title2) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 140) + .background(T3Colors.surfaceRaised) + } + } + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityLabel(image.alternativeText.isEmpty ? "Image" : image.alternativeText) + .accessibilityAddTraits(.isButton) + .contentShape(Rectangle()) + .onTapGesture { + if let previewURL { openURL(previewURL) } + } + .task(id: "\(image.source):\(context?.threadID ?? ""):\(context?.workspaceRoot ?? ""):\(context?.sourceFilePath ?? "")") { + await loadImage() + } + } + } + + @MainActor + private func loadImage() async { + previewURL = nil + do { + let url: URL + switch classifiedSource { + case let .direct(directURL): + url = directURL + if directURL.scheme == "http" || directURL.scheme == "https" { + previewURL = directURL + } + case let .workspaceFile(path): + guard let context else { return } + var components = URLComponents() + components.scheme = "t3code" + components.host = "media-preview" + components.path = "/open" + components.queryItems = [ + URLQueryItem(name: "path", value: path), + URLQueryItem(name: "kind", value: "image"), + ] + previewURL = components.url + url = try await context.resolver.mediaAssetURL( + threadID: context.threadID, + path: path + ) + case .blocked: + return + } + loadedImage = try await MarkdownImageLoader.load(url) + } catch is CancellationError { + return + } catch { + failed = true + } + } +} + +private struct CodexArtifactTemplateView: View { + let template: CodexArtifactTemplate + @SwiftUI.Environment(\.openURL) private var openURL + + var body: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(template.displayName) + .font(T3Typography.threadBody.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + Text(template.kind.label) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer(minLength: 8) + Button("Use") { + if let url = template.useURL { openURL(url) } + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } +} + +@MainActor +private enum MarkdownImageLoader { + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 64 + cache.totalCostLimit = 32 * 1_024 * 1_024 + return cache + }() + + private static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + return URLSession(configuration: configuration) + }() + + static func load(_ url: URL) async throws -> UIImage { + if let cached = cache.object(forKey: url as NSURL) { + return cached + } + + let data: Data + if url.scheme?.lowercased() == "data" { + guard let comma = url.absoluteString.firstIndex(of: ","), + url.absoluteString[.. = [ + "markdown", + "md", + "plain", + "plaintext", + "text", + "text/plain", + "txt", + ] + + static func wrapsByDefault(language: String?) -> Bool { + guard let language else { return false } + return proseLanguages.contains(language.lowercased()) + } +} + +private struct MarkdownInlineText: UIViewRepresentable { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.openURL) private var openURL + + let rendered: MarkdownRenderedInline + let selectionContext: MarkdownSelectionContext + let lineSpacing: CGFloat + let textColor: MarkdownTextColor + let wrapsLines: Bool + + init( + _ rendered: MarkdownRenderedInline, + selectionContext: MarkdownSelectionContext, + lineSpacing: CGFloat = 0, + textColor: MarkdownTextColor = .primary, + wrapsLines: Bool = true + ) { + self.rendered = rendered + self.selectionContext = selectionContext + self.lineSpacing = lineSpacing + self.textColor = textColor + self.wrapsLines = wrapsLines + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.backgroundColor = .clear + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.showsHorizontalScrollIndicator = false + textView.showsVerticalScrollIndicator = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.widthTracksTextView = true + textView.textContainer.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + textView.adjustsFontForContentSizeCategory = true + textView.linkTextAttributes = [ + .foregroundColor: T3Colors.uiAccent, + .underlineStyle: 0, + ] + textView.accessibilityTraits = .staticText + textView.delegate = context.coordinator + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + let attributedText = context.coordinator.attributedText( + from: rendered, + lineSpacing: lineSpacing, + textColor: textColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines + ) + if context.coordinator.shouldApply(attributedText) { + let previousText = context.coordinator.lastAppliedText + let previousSelection = textView.selectedRange + textView.attributedText = attributedText + textView.selectedRange = MarkdownSelectionRestoration.range( + previousText: previousText, + previousRange: previousSelection, + newText: attributedText.string + ) + context.coordinator.didApply(attributedText) + } + context.coordinator.selectionContext = selectionContext + context.coordinator.onOpenURL = { url in + openURL(url) + } + textView.accessibilityCustomActions = context.coordinator.accessibilityActions( + title: selectionContext.copyActionTitle + ) + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiView: UITextView, + context: Context + ) -> CGSize? { + guard let proposedWidth = proposal.width, + proposedWidth.isFinite, + proposedWidth > 0 + else { + return wrapsLines ? nil : context.coordinator.unwrappedSize(for: uiView) + } + return context.coordinator.size( + for: uiView, + proposedWidth: proposedWidth, + wrapsLines: wrapsLines + ) + } + + final class Coordinator: NSObject, UITextViewDelegate { + private struct CacheKey: Equatable { + let lineSpacing: CGFloat + let textColor: MarkdownTextColor + let dynamicTypeSize: DynamicTypeSize + let wrapsLines: Bool + } + + private struct SizeKey: Hashable { + let proposedWidth: CGFloat + let wrapsLines: Bool + } + + var selectionContext = MarkdownSelectionContext( + source: MarkdownSelectionSource(""), + copyActionTitle: "Copy message" + ) + var onOpenURL: ((URL) -> Void)? + private var cacheKey: CacheKey? + private var cachedRendered: MarkdownRenderedInline? + private var cachedAttributedText: NSAttributedString? + private var cachedSizes: [SizeKey: CGSize] = [:] + private var lastAppliedAttributedText: NSAttributedString? + private var cachedAccessibilityTitle: String? + private var cachedAccessibilityActions: [UIAccessibilityCustomAction] = [] + + func attributedText( + from rendered: MarkdownRenderedInline, + lineSpacing: CGFloat, + textColor: MarkdownTextColor, + dynamicTypeSize: DynamicTypeSize, + wrapsLines: Bool + ) -> NSAttributedString { + let key = CacheKey( + lineSpacing: lineSpacing, + textColor: textColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines + ) + if cachedRendered === rendered, key == cacheKey, let cachedAttributedText { + return cachedAttributedText + } + let attributedText = MarkdownSelectableTextAttributes.make( + from: rendered, + lineSpacing: lineSpacing, + foregroundColor: textColor.uiColor, + dynamicTypeSize: dynamicTypeSize, + wrapsLines: wrapsLines + ) + cacheKey = key + cachedRendered = rendered + cachedAttributedText = attributedText + cachedSizes.removeAll(keepingCapacity: true) + return attributedText + } + + var lastAppliedText: String { + lastAppliedAttributedText?.string ?? "" + } + + func shouldApply(_ attributedText: NSAttributedString) -> Bool { + lastAppliedAttributedText !== attributedText + } + + func didApply(_ attributedText: NSAttributedString) { + lastAppliedAttributedText = attributedText + } + + func size( + for textView: UITextView, + proposedWidth: CGFloat, + wrapsLines: Bool + ) -> CGSize { + let key = SizeKey(proposedWidth: proposedWidth, wrapsLines: wrapsLines) + if let cached = cachedSizes[key] { + return cached + } + let bounds = textView.attributedText.boundingRect( + with: CGSize(width: proposedWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + let width = min(proposedWidth, max(1, ceil(bounds.width))) + let fittingSize = textView.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + let size = CGSize(width: width, height: max(1, ceil(fittingSize.height))) + cachedSizes[key] = size + return size + } + + func unwrappedSize(for textView: UITextView) -> CGSize { + let key = SizeKey(proposedWidth: .infinity, wrapsLines: false) + if let cached = cachedSizes[key] { + return cached + } + let longestLineLength = textView.attributedText.string + .split(separator: "\n", omittingEmptySubsequences: false) + .map(\.utf16.count) + .max() ?? 0 + var largestFontPointSize: CGFloat = 0 + textView.attributedText.enumerateAttribute( + .font, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + largestFontPointSize = max( + largestFontPointSize, + (value as? UIFont)?.pointSize ?? 0 + ) + } + let perCharacterWidth = max(16, largestFontPointSize * 1.5) + let maximumWidth = max(2_048, CGFloat(longestLineLength) * perCharacterWidth) + let bounds = textView.attributedText.boundingRect( + with: CGSize(width: maximumWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ) + let fittingSize = textView.sizeThatFits( + CGSize(width: max(1, ceil(bounds.width)), height: .greatestFiniteMagnitude) + ) + let size = CGSize( + width: max(1, ceil(bounds.width)), + height: max(1, ceil(fittingSize.height)) + ) + cachedSizes[key] = size + return size + } + + func accessibilityActions(title: String) -> [UIAccessibilityCustomAction] { + if cachedAccessibilityTitle == title { + return cachedAccessibilityActions + } + cachedAccessibilityTitle = title + cachedAccessibilityActions = [ + UIAccessibilityCustomAction( + name: title + ) { [weak self] _ in + self?.copyMessage() + return true + }, + ] + return cachedAccessibilityActions + } + + func textView( + _ textView: UITextView, + editMenuForTextIn range: NSRange, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + let copyMessage = UIAction( + title: selectionContext.copyActionTitle, + image: UIImage(systemName: "doc.on.doc") + ) { [weak self] _ in + self?.copyMessage() + } + return UIMenu(children: suggestedActions + [copyMessage]) + } + + func textView( + _ textView: UITextView, + primaryActionFor textItem: UITextItem, + defaultAction: UIAction + ) -> UIAction? { + guard case let .link(url) = textItem.content else { return defaultAction } + return UIAction { [weak self] _ in + self?.onOpenURL?(url) + } + } + + private func copyMessage() { + UIPasteboard.general.string = selectionContext.source.text + } + } +} + +enum MarkdownSelectableTextAttributes { + @MainActor + static func make( + from rendered: MarkdownRenderedInline, + lineSpacing: CGFloat, + foregroundColor: UIColor = T3Colors.uiTextPrimary, + dynamicTypeSize: DynamicTypeSize = .large, + wrapsLines: Bool = true + ) -> NSAttributedString { + let result = NSMutableAttributedString() + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpacing + paragraphStyle.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + + for run in rendered.attributedText.runs { + let intent = run.inlinePresentationIntent + var attributes: [NSAttributedString.Key: Any] = [ + .font: font( + for: rendered.style, + intent: intent, + dynamicTypeSize: dynamicTypeSize + ), + .foregroundColor: foregroundColor, + .paragraphStyle: paragraphStyle, + ] + if intent?.contains(.code) == true { + attributes[.backgroundColor] = T3Colors.uiSurfaceRaised + } + if intent?.contains(.strikethrough) == true { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + if let link = run.link { + attributes[.link] = link + } + result.append( + NSAttributedString( + string: String(rendered.attributedText[run.range].characters), + attributes: attributes + ) + ) + } + + return result + } + + @MainActor + private static func font( + for style: MarkdownInlineStyle, + intent: InlinePresentationIntent?, + dynamicTypeSize: DynamicTypeSize + ) -> UIFont { + var font = style.uiFont(dynamicTypeSize: dynamicTypeSize) + if intent?.contains(.code) == true, style != .code { + font = UIFont.monospacedSystemFont( + ofSize: font.pointSize, + weight: .regular + ) + } + + let addsBold = intent?.contains(.stronglyEmphasized) == true + let addsItalic = intent?.contains(.emphasized) == true + guard addsBold || addsItalic else { return font } + + var traits = font.fontDescriptor.symbolicTraits + if addsBold { + traits.insert(.traitBold) + } + if addsItalic { + traits.insert(.traitItalic) + } + if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) { + font = UIFont(descriptor: descriptor, size: 0) + } + return font + } +} + +enum MarkdownSelectionRestoration { + static func range( + previousText: String, + previousRange: NSRange, + newText: String + ) -> NSRange { + guard newText.utf16.starts(with: previousText.utf16), + NSMaxRange(previousRange) <= (newText as NSString).length + else { + return NSRange(location: 0, length: 0) + } + return previousRange + } +} + +enum MarkdownInlineFormatter { + static func format(_ source: String) -> AttributedString { + ( + try? AttributedString( + markdown: source, + options: AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace, + failurePolicy: .returnPartiallyParsedIfPossible + ) + ) + ) ?? AttributedString(source) + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift new file mode 100644 index 000000000000..6114c369d33d --- /dev/null +++ b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift @@ -0,0 +1,477 @@ +import Foundation +import SwiftUI +import UIKit + +/// An exact content revision with a cheap, deterministic hash for SwiftUI task identity. +/// Source equality remains the final check, so a fingerprint collision cannot return stale text. +struct MarkdownContentRevision: Hashable, Sendable { + let source: String + let fingerprint: UInt64 + let utf8Count: Int + + init(_ source: String) { + self.source = source + utf8Count = source.utf8.count + + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in source.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + fingerprint = hash + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.fingerprint == rhs.fingerprint + && lhs.utf8Count == rhs.utf8Count + && lhs.source == rhs.source + } + + func hash(into hasher: inout Hasher) { + hasher.combine(fingerprint) + hasher.combine(utf8Count) + } +} + +enum MarkdownInlineStyle: String, Hashable, Sendable { + case body + case heading1 + case heading2 + case heading3 + case heading4 + case tableHeader + case tableCell + case code + + @MainActor + func uiFont(dynamicTypeSize: DynamicTypeSize) -> UIFont { + let textStyle: UIFont.TextStyle + let weight: UIFont.Weight + switch self { + case .body, .tableCell: + textStyle = .body + weight = .regular + case .heading1: + textStyle = .title2 + weight = .bold + case .heading2: + textStyle = .title3 + weight = .bold + case .heading3: + textStyle = .headline + weight = .bold + case .heading4, .tableHeader: + textStyle = .body + weight = .semibold + case .code: + textStyle = .callout + weight = .regular + } + + let traits = UITraitCollection( + preferredContentSizeCategory: UIContentSizeCategory(dynamicTypeSize) + ) + let preferred = UIFont.preferredFont(forTextStyle: textStyle, compatibleWith: traits) + if self == .code { + return UIFont.monospacedSystemFont(ofSize: preferred.pointSize, weight: weight) + } + return UIFont.systemFont(ofSize: preferred.pointSize, weight: weight) + } + + static func heading(level: Int) -> Self { + switch level { + case 1: .heading1 + case 2: .heading2 + case 3: .heading3 + default: .heading4 + } + } +} + +/// Reference semantics let consecutive streaming revisions share unchanged inline runs. +final class MarkdownRenderedInline: @unchecked Sendable { + let attributedText: AttributedString + let style: MarkdownInlineStyle + + init(attributedText: AttributedString, style: MarkdownInlineStyle) { + self.attributedText = attributedText + self.style = style + } +} + +extension MarkdownRenderedInline: Equatable { + /// Streaming revisions share unchanged runs by reference (see the inline + /// cache), so identity comparison is both cheap and effective: unchanged + /// paragraphs compare equal without touching their attributed text. + static func == (lhs: MarkdownRenderedInline, rhs: MarkdownRenderedInline) -> Bool { + lhs === rhs + } +} + +struct MarkdownRenderedListItem: Equatable, @unchecked Sendable { + let task: MarkdownTaskState? + let blocks: [MarkdownRenderedBlock] +} + +struct MarkdownRenderedTable: Equatable, @unchecked Sendable { + let header: [MarkdownRenderedInline] + let alignments: [MarkdownTableAlignment] + let rows: [[MarkdownRenderedInline]] + /// Estimated per-column widths, computed once on the render task so the + /// table view never measures cell text on the main thread. + let columnWidths: [CGFloat] + + static func estimatedColumnWidths( + header: [MarkdownRenderedInline], + rows: [[MarkdownRenderedInline]] + ) -> [CGFloat] { + let cells = [header] + rows + return header.indices.map { columnIndex in + let longestLine = cells + .compactMap { row -> Int? in + guard row.indices.contains(columnIndex) else { return nil } + return String(row[columnIndex].attributedText.characters) + .split(separator: "\n", omittingEmptySubsequences: false) + .map(\.count) + .max() + } + .max() ?? 0 + + // Deliberately an estimate rather than text measurement. Exact + // widths would require laying every cell out twice. + return min(300, max(140, CGFloat(longestLine) * 8.25)) + } + } +} + +indirect enum MarkdownRenderedBlock: Equatable, @unchecked Sendable { + case paragraph(MarkdownRenderedInline) + case image(MarkdownImage) + case heading(level: Int, inline: MarkdownRenderedInline) + case unorderedList([MarkdownRenderedListItem]) + case orderedList(start: Int, items: [MarkdownRenderedListItem]) + case blockquote([MarkdownRenderedBlock]) + case table(MarkdownRenderedTable) + case codeBlock(language: String?, code: String, inline: MarkdownRenderedInline) + case thematicBreak + case artifactTemplate(CodexArtifactTemplate) +} + +/// Immutable render plans are safe to reuse every time SwiftUI reconstructs a message row. +final class MarkdownRenderedDocument: @unchecked Sendable { + let revision: MarkdownContentRevision + let blocks: [MarkdownRenderedBlock] + + init(revision: MarkdownContentRevision, blocks: [MarkdownRenderedBlock]) { + self.revision = revision + self.blocks = blocks + } +} + +private final class MarkdownRenderedInlineBox: NSObject { + let value: MarkdownRenderedInline + + init(_ value: MarkdownRenderedInline) { + self.value = value + } +} + +/// Bounded, process-local caches keep history navigation and SwiftUI diffing from reparsing +/// unchanged messages. Cache misses are rendered by a detached task and duplicate requests +/// for the same revision share one in-flight render. +final class MarkdownRenderCache: @unchecked Sendable { + static let shared: MarkdownRenderCache = { + let cache = MarkdownRenderCache() + // NSCache only sheds objects; in-flight renders and their waiters are + // ours to drop when the system is under pressure. + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil + ) { _ in + cache.removeAll() + } + return cache + }() + + private let documents = NSCache() + private let inlineRuns = NSCache() + private let inFlightQueue = DispatchQueue(label: "codes.t3.native.markdown-render-cache") + private struct InFlightRender { + let task: Task + var waiters: Set + } + private var inFlight: [MarkdownContentRevision: InFlightRender] = [:] + + init( + documentCountLimit: Int = 512, + documentCostLimit: Int = 12 * 1_024 * 1_024, + inlineCountLimit: Int = 2_048, + inlineCostLimit: Int = 8 * 1_024 * 1_024 + ) { + documents.countLimit = documentCountLimit + documents.totalCostLimit = documentCostLimit + inlineRuns.countLimit = inlineCountLimit + inlineRuns.totalCostLimit = inlineCostLimit + } + + /// Keys use the precomputed fingerprint instead of the full source so each + /// lookup avoids bridging and hashing the entire message body. The stored + /// document's revision equality check below makes collisions safe. + private func cacheKey(for revision: MarkdownContentRevision) -> NSString { + "\(revision.fingerprint):\(revision.utf8Count)" as NSString + } + + func cachedDocument(for revision: MarkdownContentRevision) -> MarkdownRenderedDocument? { + let document = documents.object(forKey: cacheKey(for: revision)) + return document?.revision == revision ? document : nil + } + + /// Completed transcript rows must have their final geometry on first display. + /// Prefetching normally makes this a cache hit; the synchronous fallback prevents + /// a visible plain-text-to-Markdown layout swap when UIKit misses a prefetch window. + func documentImmediately( + for revision: MarkdownContentRevision + ) -> MarkdownRenderedDocument? { + if let cached = cachedDocument(for: revision) { + return cached + } + guard let document = renderDocument(revision) else { return nil } + documents.setObject( + document, + forKey: cacheKey(for: revision), + cost: documentCost(document) + ) + return document + } + + /// Set `isIntermediate` for in-progress streaming revisions: they are + /// superseded within milliseconds, and inserting each one would churn + /// completed messages out of the bounded document cache. Unchanged inline + /// runs are still shared through the inline cache either way. + func document( + for revision: MarkdownContentRevision, + isIntermediate: Bool = false + ) async -> MarkdownRenderedDocument? { + guard !Task.isCancelled else { return nil } + if let cached = cachedDocument(for: revision) { + return cached + } + + let waiterID = UUID() + let task = inFlightTask(for: revision, waiterID: waiterID) + let document = await withTaskCancellationHandler { + let document = await task.value + releaseWaiter(for: revision, waiterID: waiterID, cancelIfLast: false) + return document + } onCancel: { [self] in + releaseWaiter(for: revision, waiterID: waiterID, cancelIfLast: true) + } + guard !Task.isCancelled, let document else { return nil } + if !isIntermediate { + documents.setObject( + document, + forKey: cacheKey(for: revision), + cost: documentCost(document) + ) + } + return document + } + + /// Adopts a document that was rendered as a streaming intermediate. Called + /// when its message completes so the final revision becomes a durable + /// cache entry without reparsing on the main thread. + func promote(_ document: MarkdownRenderedDocument) { + guard cachedDocument(for: document.revision) == nil else { return } + documents.setObject( + document, + forKey: cacheKey(for: document.revision), + cost: documentCost(document) + ) + } + + func removeAll() { + documents.removeAllObjects() + inlineRuns.removeAllObjects() + let tasks = inFlightQueue.sync { + let tasks = inFlight.values.map(\.task) + inFlight.removeAll(keepingCapacity: true) + return tasks + } + tasks.forEach { $0.cancel() } + } + + private func inFlightTask( + for revision: MarkdownContentRevision, + waiterID: UUID + ) -> Task { + inFlightQueue.sync { + if var existing = inFlight[revision] { + existing.waiters.insert(waiterID) + inFlight[revision] = existing + return existing.task + } + + let task = Task.detached(priority: .userInitiated) { [self] in + renderDocument(revision) + } + inFlight[revision] = InFlightRender(task: task, waiters: [waiterID]) + return task + } + } + + private func releaseWaiter( + for revision: MarkdownContentRevision, + waiterID: UUID, + cancelIfLast: Bool + ) { + let taskToCancel: Task? = inFlightQueue.sync { + guard var render = inFlight[revision], + render.waiters.remove(waiterID) != nil else { + return nil + } + guard render.waiters.isEmpty else { + inFlight[revision] = render + return nil + } + inFlight.removeValue(forKey: revision) + return cancelIfLast ? render.task : nil + } + taskToCancel?.cancel() + } + + private func renderDocument(_ revision: MarkdownContentRevision) -> MarkdownRenderedDocument? { + guard !Task.isCancelled else { return nil } + let document = MarkdownDocument(parsing: revision.source) + guard !Task.isCancelled, let blocks = renderBlocks(document.blocks) else { return nil } + return MarkdownRenderedDocument( + revision: revision, + blocks: blocks + ) + } + + private func renderBlocks(_ blocks: [MarkdownBlock]) -> [MarkdownRenderedBlock]? { + var renderedBlocks: [MarkdownRenderedBlock] = [] + renderedBlocks.reserveCapacity(blocks.count) + for block in blocks { + guard !Task.isCancelled else { return nil } + let rendered: MarkdownRenderedBlock + switch block { + case let .paragraph(source): + guard let inline = renderInline(source, style: .body) else { return nil } + rendered = .paragraph(inline) + + case let .image(image): + rendered = .image(image) + + case let .heading(level, source): + guard let inline = renderInline(source, style: .heading(level: level)) else { + return nil + } + rendered = .heading( + level: level, + inline: inline + ) + + case let .unorderedList(items): + guard let items = renderItems(items) else { return nil } + rendered = .unorderedList(items) + + case let .orderedList(start, items): + guard let items = renderItems(items) else { return nil } + rendered = .orderedList(start: start, items: items) + + case let .blockquote(document): + guard let blocks = renderBlocks(document.blocks) else { return nil } + rendered = .blockquote(blocks) + + case let .table(table): + guard let table = renderTable(table) else { return nil } + rendered = .table(table) + + case let .codeBlock(language, code): + guard let inline = renderInline(code, style: .code) else { return nil } + rendered = .codeBlock(language: language, code: code, inline: inline) + + case let .artifactTemplate(template): + rendered = .artifactTemplate(template) + + case .thematicBreak: + rendered = .thematicBreak + } + renderedBlocks.append(rendered) + } + return renderedBlocks + } + + private func renderTable(_ table: MarkdownTable) -> MarkdownRenderedTable? { + var header: [MarkdownRenderedInline] = [] + header.reserveCapacity(table.header.count) + for cell in table.header { + guard let inline = renderInline(cell, style: .tableHeader) else { return nil } + header.append(inline) + } + + var rows: [[MarkdownRenderedInline]] = [] + rows.reserveCapacity(table.rows.count) + for sourceRow in table.rows { + guard !Task.isCancelled else { return nil } + var row: [MarkdownRenderedInline] = [] + row.reserveCapacity(sourceRow.count) + for cell in sourceRow { + guard let inline = renderInline(cell, style: .tableCell) else { return nil } + row.append(inline) + } + rows.append(row) + } + + return MarkdownRenderedTable( + header: header, + alignments: table.alignments, + rows: rows, + columnWidths: MarkdownRenderedTable.estimatedColumnWidths( + header: header, + rows: rows + ) + ) + } + + private func renderItems(_ items: [MarkdownListItem]) -> [MarkdownRenderedListItem]? { + var renderedItems: [MarkdownRenderedListItem] = [] + renderedItems.reserveCapacity(items.count) + for item in items { + guard !Task.isCancelled, let blocks = renderBlocks(item.blocks) else { return nil } + renderedItems.append(MarkdownRenderedListItem(task: item.task, blocks: blocks)) + } + return renderedItems + } + + private func renderInline( + _ source: String, + style: MarkdownInlineStyle + ) -> MarkdownRenderedInline? { + guard !Task.isCancelled else { return nil } + let key = "\(style.rawValue)\u{0}\(source)" as NSString + if let cached = inlineRuns.object(forKey: key) { + return cached.value + } + + let attributedText = if style == .code { + AttributedString(source) + } else { + MarkdownInlineFormatter.format(source) + } + let inline = MarkdownRenderedInline(attributedText: attributedText, style: style) + guard !Task.isCancelled else { return nil } + inlineRuns.setObject( + MarkdownRenderedInlineBox(inline), + forKey: key, + cost: max(64, source.utf8.count * 2) + ) + return inline + } + + private func documentCost(_ document: MarkdownRenderedDocument) -> Int { + max(256, document.revision.utf8Count * 3) + } +} diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift new file mode 100644 index 000000000000..ef39f40339df --- /dev/null +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -0,0 +1,2649 @@ +import ImageIO +import SwiftUI +import UIKit + +public struct ThreadDetailView: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass + @SwiftUI.Environment(\.openURL) private var parentOpenURL + @SwiftUI.Environment(\.scenePhase) private var scenePhase + + @Bindable var model: FeatureRootModel + let thread: FeatureThread + let submitMessage: (FeatureMessageSubmission) async -> Bool + let onNavigateBack: () -> Void + private let draftStore: FeatureComposerDraftStore + + @State private var draft = "" + @State private var selection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var isSending = false + @State private var isLoading = true + @State private var sendFailed = false + @State private var feedbackMessages: [FeatureMessage] = [] + @State private var feedbackRevision: UInt64 = 0 + @State private var feedbackAlertMessage: String? + @State private var feedbackIdentifier: String? + @State private var didRestoreDraft = false + @State private var draftSaveTask: Task? + @State private var toolSurface: FeatureThreadToolSurface? + @State private var branchPullRequest: FeaturePullRequest? + @State private var linkedMediaPreview: FeatureLinkedMediaPreview? + @State private var linkedMediaPreviewError: String? + // Plain state, not `FocusState`: the composer's UIKit text view owns + // focus and mirrors it through this binding, because SwiftUI drops + // writes to a `FocusState` no `.focused()` view registers with. + @State private var composerFocused = false + + public init( + model: FeatureRootModel, + thread: FeatureThread, + submitMessage: @escaping (FeatureMessageSubmission) async -> Bool, + onNavigateBack: @escaping () -> Void = {}, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.model = model + self.thread = thread + self.submitMessage = submitMessage + self.onNavigateBack = onNavigateBack + self.draftStore = draftStore + } + + public var body: some View { + Group { + if let detail { + timeline(detail) + } else if isLoading { + FeatureThreadOpeningView() + } else { + ContentUnavailableView { + Label("Thread unavailable", systemImage: "exclamationmark.bubble") + } description: { + Text("The thread could not be loaded.") + } actions: { + Button("Retry", action: reloadThread) + } + } + } + .background(T3Colors.background) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(false) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .principal) { + threadHeaderTitle + } + ToolbarItem(placement: .primaryAction) { + threadActionsMenu + } + } + .task(id: thread.id) { + let restoreBaseline = composerDraft + let restoreKey = draftKey + isLoading = true + _ = await model.detail(for: thread.id, force: true) + isLoading = false + await restoreDraft(from: restoreBaseline, key: restoreKey) + } + .task(id: pullRequestObservationID) { + await observeThreadPullRequest() + } + .onChange(of: draft) { scheduleDraftSave() } + .onChange(of: attachments) { scheduleDraftSave() } + .onChange(of: selection) { scheduleDraftSave() } + .onChange(of: threadConnectionState) { _, state in + if state == .connected, + case .failed = model.detailLoadStates[thread.id], + !isLoading { + reloadThread() + } + } + .onChange(of: scenePhase) { _, phase in + if phase != .active { + persistDraftBeforeLeaving() + } + } + .onDisappear { + model.releaseThread(thread.id) + persistDraftBeforeLeaving() + } + .sheet(item: $toolSurface) { surface in + NavigationStack { + Group { + switch surface { + case .files: + FeatureFilesView( + client: model.client, + threadID: thread.id, + workspaceRoot: markdownImageContext?.workspaceRoot + ) + case let .file(path): + FeatureFilesView( + client: model.client, + threadID: thread.id, + initialPath: path, + workspaceRoot: markdownImageContext?.workspaceRoot + ) + case .review: + FeatureReviewView(client: model.client, threadID: thread.id) + case .sourceControl: + FeatureSourceControlView(client: model.client, threadID: thread.id) + case .terminal: + FeatureTerminalView(client: model.client, threadID: thread.id) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + toolSurface = nil + } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .alert("Message not sent", isPresented: $sendFailed) { + // Refocusing happens here rather than when the send fails: the + // alert takes first responder from the composer, so a refocus + // issued before it presents is lost by the time it dismisses. + Button("OK") { composerFocused = true } + } message: { + Text("Your draft is still here. Check your connection and try again.") + } + .alert( + feedbackIdentifier == nil ? "Could not send feedback" : "Feedback sent to OpenAI", + isPresented: Binding( + get: { feedbackAlertMessage != nil }, + set: { if !$0 { feedbackAlertMessage = nil; feedbackIdentifier = nil } } + ) + ) { + if let feedbackIdentifier { + Button("Copy ID") { + UIPasteboard.general.string = feedbackIdentifier + } + } + Button("OK", role: .cancel) {} + } message: { + Text(feedbackAlertMessage ?? "") + } + .background { + ThreadBackSwipeGestureView( + isEnabled: horizontalSizeClass == .compact, + onNavigateBack: onNavigateBack + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .environment(\.openURL, OpenURLAction { url in + if handleArtifactTemplateURL(url) { return .handled } + if handleTypedMediaPreviewURL(url) { return .handled } + guard let workspaceRoot = markdownImageContext?.workspaceRoot, + let path = MarkdownWorkspaceFileLink.relativePath( + for: url, + workspaceRoot: workspaceRoot + ) else { + if url.scheme?.lowercased() == "http" || url.scheme?.lowercased() == "https", + let kind = FeatureLinkedMediaPreview.previewKind(for: url) { + linkedMediaPreview = FeatureLinkedMediaPreview( + source: url.isFileURL ? .file(url) : .remote(url), + kind: kind, + fileName: url.lastPathComponent + ) + return .handled + } + if url.isFileURL { + let path = url.path + let kind = FeatureFilePreviewKind.infer(path: path) + if kind == .image || kind == .video { + resolveHostMedia(path: path, kind: kind) + return .handled + } + } + if url.scheme?.lowercased() == "t3code" { return .discarded } + parentOpenURL(url) + return .handled + } + let kind = FeatureFilePreviewKind.infer(path: path) + if kind == .image || kind == .video { + resolveHostMedia(path: path, kind: kind) + return .handled + } + toolSurface = .file(path) + return .handled + }) + .fullScreenCover(item: $linkedMediaPreview) { preview in + NavigationStack { + FeatureNativeMediaPreviewView( + source: preview.source, + kind: preview.kind, + fileName: preview.fileName + ) + .navigationTitle(preview.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { linkedMediaPreview = nil } + } + } + } + .preferredColorScheme(.dark) + } + .alert( + "Preview unavailable", + isPresented: Binding( + get: { linkedMediaPreviewError != nil }, + set: { if !$0 { linkedMediaPreviewError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(linkedMediaPreviewError ?? "The file could not be opened.") + } + } + + private var detail: FeatureThreadDetail? { + model.details[thread.id] + } + + private var currentThread: FeatureThread { + detail?.thread ?? thread + } + + private var currentSelection: FeatureSelection? { + guard let providerID = detail?.thread.providerID ?? thread.providerID, + let modelID = detail?.thread.modelID ?? thread.modelID else { return nil } + let provider = threadProviders.first { $0.id == providerID } + let featureModel = provider?.models.first { $0.id == modelID } + let savedOptions = detail?.thread.modelOptions ?? thread.modelOptions + return FeatureSelection( + providerID: providerID, + modelID: modelID, + options: savedOptions.isEmpty + ? featureModel.map(DailyUXModelOptions.defaults) ?? [] + : savedOptions + ) + } + + private var threadHeaderTitle: some View { + VStack(alignment: .leading, spacing: 1) { + Text(currentThread.title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + HStack(spacing: 5) { + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + Text(headerBranch) + .lineLimit(1) + if let environmentName = currentThread.homeEnvironmentLabel(in: model.snapshot) { + Text("·") + Text(environmentName) + .lineLimit(1) + } + } + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 6) + + // The per-second timeline only exists for the live working + // duration; idle threads render a static status instead of + // waking every second forever. + Group { + if currentThread.homeStatus == .working { + TimelineView(.periodic(from: .now, by: 1)) { context in + headerStatus(at: context.date) + } + } else { + headerStatus(at: .now) + } + } + .fixedSize(horizontal: true, vertical: false) + } + .font(T3Typography.navigationMetadata) + .foregroundStyle(T3Colors.textTertiary) + } + // Leave compact-width clearance for the trailing thread menu. + .padding(.trailing, horizontalSizeClass == .compact ? 10 : 0) + .frame(maxWidth: horizontalSizeClass == .compact ? 260 : 460, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityAddTraits( + currentThread.hasLiveWorkingDuration ? .updatesFrequently : [] + ) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } + + @ViewBuilder + private func headerStatus(at now: Date) -> some View { + let duration = currentThread.homeWorkingDuration(at: now) + if let label = duration ?? currentThread.detailHeaderStatusLabel { + HStack(spacing: 5) { + if let icon = currentThread.detailHeaderStatusIcon { + Image(systemName: icon) + } + headerStatusText(label, isDuration: duration != nil) + } + .font(T3Typography.status) + .foregroundStyle(headerStatusColor) + .lineLimit(1) + .accessibilityElement(children: .combine) + .accessibilityLabel(currentThread.homeStatusAccessibilityLabel(at: now)) + } + } + + @ViewBuilder + private func headerStatusText(_ label: String, isDuration: Bool) -> some View { + if isDuration { + Text(label) + .monospaced() + .monospacedDigit() + } else { + Text(label) + } + } + + private var threadActionsMenu: some View { + Menu { + Section("Thread") { + if let pullRequest = currentPullRequest { + Button { + parentOpenURL(pullRequest.url) + } label: { + Label("Open pull request #\(pullRequest.number)", systemImage: "arrow.triangle.pull") + } + } + if currentThread.supportsTitleRegeneration == true { + Button { + Task { await model.regenerateThreadTitle(thread.id) } + } label: { + Label("Regenerate title", systemImage: "sparkles") + } + } + Menu { + if !FeatureRuntimeMode.allCases.contains(currentThread.runtimeMode) { + Section("Current") { + Button {} label: { + Label( + runtimeModeLabel(currentThread.runtimeMode), + systemImage: "checkmark" + ) + } + .disabled(true) + } + } + ForEach(FeatureRuntimeMode.allCases, id: \.self) { mode in + Button { + guard currentThread.runtimeMode != mode else { + return + } + Task { await model.setRuntimeMode(thread.id, mode: mode) } + } label: { + if currentThread.runtimeMode == mode { + Label(runtimeModeLabel(mode), systemImage: "checkmark") + } else { + Text(runtimeModeLabel(mode)) + } + } + } + } label: { + Label("Permissions", systemImage: "checkmark.shield") + } + .disabled(model.isPerformingAction) + if currentThread.canTogglePin, !currentThread.isArchived { + Button { + Task { + await model.setPinned( + thread.id, + pinned: currentThread.pinnedAt == nil + ) + } + } label: { + Label( + currentThread.pinnedAt == nil ? "Pin" : "Unpin", + systemImage: currentThread.pinnedAt == nil ? "pin" : "pin.slash" + ) + } + } + let isSettled = model.isEffectivelySettled(currentThread) + if (isSettled || currentThread.canSettleNow()), !currentThread.isArchived { + Button { + Task { await model.setSettled(thread.id, settled: !isSettled) } + } label: { + Label( + isSettled ? "Reopen" : "Settle", + systemImage: isSettled ? "arrow.counterclockwise" : "checkmark" + ) + } + } + Button(action: reloadThread) { + Label("Reload", systemImage: "arrow.clockwise") + } + } + Section("Workspace") { + Button { toolSurface = .files } label: { + Label("Files", systemImage: "folder") + } + Button { toolSurface = .review } label: { + Label("Review changes", systemImage: "doc.text.magnifyingglass") + } + Button { toolSurface = .sourceControl } label: { + Label("Source control", systemImage: "arrow.triangle.branch") + } + Button { toolSurface = .terminal } label: { + Label("Terminal", systemImage: "terminal") + } + } + Section { + Button { + Task { + await model.setArchived(thread.id, archived: !currentThread.isArchived) + } + } label: { + Label( + currentThread.isArchived ? "Restore" : "Archive", + systemImage: currentThread.isArchived + ? "arrow.uturn.backward" + : "archivebox" + ) + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body.weight(.semibold)) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Thread actions") + .accessibilityHint("Shows thread actions and workspace tools") + .accessibilityIdentifier("thread-actions-menu") + } + + private func runtimeModeLabel(_ mode: FeatureRuntimeMode) -> String { + switch mode { + case .approvalRequired: "Supervised" + case .autoAcceptEdits: "Auto-accept edits" + case .automatic: "Automatic" + case .fullAccess: "Full access" + } + } + + private var currentPullRequest: ThreadPullRequestDestination? { + return ThreadPullRequestDestination.resolve( + thread: currentThread, + branchPullRequest: branchPullRequest + ) + } + + private var pullRequestObservationID: String? { + currentThread.pullRequestObservationIdentity + } + + @MainActor + private func observeThreadPullRequest() async { + guard let observationIdentity = pullRequestObservationID else { + branchPullRequest = nil + return + } + + if let linked = currentThread.linkedPullRequest, + let environmentID = currentThread.environmentID { + let target = FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: currentThread.environmentName ?? environmentID, + reference: PullRequestRef( + projectId: linked.projectId, + repository: linked.repository, + number: linked.number + ) + ) + while !Task.isCancelled { + if let detail = try? await model.client.pullRequestDetail(target), + let presentation = HomeThreadPullRequestPresentation.resolve( + linkedPullRequest: linked, + detail: detail + ) { + model.updatePullRequest( + presentation, + threadID: currentThread.id, + observationIdentity: observationIdentity + ) + } + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return + } + } + return + } + + for await status in model.client.sourceControlStatusEvents(threadID: thread.id) { + guard !Task.isCancelled else { return } + let next = status.branch == currentThread.branch ? status.pullRequest : nil + if next != branchPullRequest { + branchPullRequest = next + } + model.updatePullRequest( + HomeThreadPullRequestPresentation.resolve(thread: currentThread, status: status), + threadID: currentThread.id, + observationIdentity: observationIdentity + ) + } + } + + private func reloadThread() { + isLoading = true + Task { + _ = await model.detail(for: thread.id, force: true) + isLoading = false + } + } + + private var threadConnectionState: FeatureConnection.State? { + guard let environmentID = currentThread.environmentID else { return nil } + return model.snapshot.environments.first { $0.id == environmentID }?.connectionState + } + + private var refreshPresentation: ThreadRefreshPresentation? { + ThreadRefreshPresentation.resolve( + loadState: model.detailLoadStates[thread.id], + connectionState: threadConnectionState, + isOpening: isLoading + ) + } + + @ViewBuilder + private var refreshStatus: some View { + if let refreshPresentation { + HStack(spacing: 8) { + Label(refreshPresentation.title, systemImage: refreshPresentation.systemImage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 4) + if refreshPresentation.canRetry { + Button(action: reloadThread) { + Label("Retry", systemImage: "arrow.clockwise") + .font(T3Typography.control) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.accent) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("thread-refresh-retry") + } + } + .padding(.horizontal, 18) + .padding(.top, 8) + .accessibilityIdentifier("thread-refresh-status") + } + } + + private var headerBranch: String { + if let branch = currentThread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty { + return branch + } + if let path = currentThread.worktreePath, + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: path).lastPathComponent + } + return "workspace" + } + + private var headerStatusColor: Color { + switch currentThread.homeStatus { + case .working: T3Colors.statusRunning + case .monitoring: T3Colors.statusRunning + case .approval: T3Colors.warning + case .input: T3Colors.statusInput + case .failed: T3Colors.danger + case .done: T3Colors.success + case .ready: T3Colors.textTertiary + } + } + + private func timeline(_ detail: FeatureThreadDetail) -> some View { + let isWorking = detail.thread.state == .working + || detail.thread.state == .queued + || detail.thread.state == .monitoring + return Group { + if detail.messages.isEmpty, !isWorking { + ContentUnavailableView( + "Ready for a task", + systemImage: "sparkles", + description: Text("Tell the agent what you want to build.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + FeatureTranscriptCollectionView( + threadID: thread.id, + messages: timelineMessages(detail.messages), + imageContext: markdownImageContext, + renderUpdate: timelineRenderUpdate, + dynamicTypeSize: dynamicTypeSize, + isWorking: isWorking, + activeSubagentCount: detail.activeSubagentCount, + backgroundWorkIsActive: detail.backgroundWorkIsActive, + isMonitoring: detail.thread.state == .monitoring, + canLoadEarlier: detail.page?.hasMore == true, + isLoadingEarlier: detail.page?.isLoading == true, + onLoadEarlier: { + Task { await model.loadEarlierTurns(for: thread.id) } + }, + onDismissKeyboard: dismissKeyboard + ) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + refreshStatus + FeatureComposerView( + text: $draft, + selection: $selection, + attachments: $attachments, + draftOwnerID: "thread:\(currentThread.id)", + environmentID: currentThread.environmentID, + draftStorageKey: draftKey, + environmentIsConnected: threadConnectionState == .connected, + attachmentUploads: model.attachmentUploads, + attachmentPreferences: currentThread.environmentID.flatMap { + model.snapshot.preferencesByEnvironment?[$0] + } ?? FeatureEnvironmentPreferences(), + providers: threadProviders, + threadSelection: currentSelection, + materializesDefaultSelection: false, + isSending: isSending, + isWorking: detail.thread.state == .working || detail.thread.state == .queued, + focused: $composerFocused, + onSend: send, + onStop: { + Task { await model.cancelTurn(threadID: thread.id) } + }, + pendingApprovals: detail.approvals, + pendingUserInputs: detail.userInputs, + isResolvingRequest: model.isPerformingAction, + powerFeatures: composerPowerFeatures, + onDismissKeyboard: dismissKeyboard, + onApprovalDecision: { id, decision in + Task { await model.resolveApproval(id, decision: decision) } + }, + onUserInputSubmit: { id, answers in + Task { await model.resolveUserInput(id, answers: answers) } + }, + onRefreshModels: refreshThreadEnvironmentModels + ) + } + .background(T3Colors.background) + } + } + + private var composerPowerFeatures: FeatureComposerPowerFeatures { + let selectedProviderID = selection?.providerID ?? currentSelection?.providerID + let provider = threadProviders.first { $0.id == selectedProviderID } + return FeatureComposerPowerFeatures( + slashCommands: provider?.slashCommands ?? [], + skills: provider?.skills ?? [], + pathSearchScopeID: currentThread.id, + searchPaths: { query in + try await model.client.searchThreadFiles( + threadID: currentThread.id, + query: query, + limit: 20 + ).map { entry in + FeatureComposerPathEntry( + path: entry.path, + kind: entry.kind == .directory ? .directory : .file + ) + } + } + ) + } + + private var threadProviders: [FeatureProvider] { + ThreadComposerProviderCatalog.providers( + for: currentThread, + in: model.snapshot + ) + } + + private func refreshThreadEnvironmentModels() async throws { + guard let environmentID = currentThread.environmentID else { return } + guard await model.refreshProviders(environmentID: environmentID) else { + throw FeatureModelRefreshError() + } + } + + private var timelineRenderUpdate: FeatureDetailRenderUpdate? { + guard !feedbackMessages.isEmpty else { + return model.detailRenderUpdates[thread.id] + } + let revision = model.detailRevisions[thread.id] ?? 0 + return FeatureDetailRenderUpdate( + baseRevision: revision, + revision: (UInt64.max / 2) &+ revision &+ feedbackRevision, + change: .full + ) + } + + private func timelineMessages(_ messages: [FeatureMessage]) -> [FeatureMessage] { + guard !feedbackMessages.isEmpty else { return messages } + return (messages + feedbackMessages).sorted { + if $0.createdAt == $1.createdAt { + return $0.id < $1.id + } + return $0.createdAt < $1.createdAt + } + } + + private var markdownImageContext: MarkdownImageContext? { + guard let resolver = model.client as? any FeatureWorkspaceAssetResolving, + let project = model.snapshot.projects.first(where: { + $0.id == currentThread.projectID + }) else { + return nil + } + return MarkdownImageContext( + threadID: currentThread.id, + workspaceRoot: currentThread.worktreePath ?? project.path, + resolver: resolver + ) + } + + private func handleArtifactTemplateURL(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "t3code", + url.host?.lowercased() == "codex-artifact-template", + url.path == "/use", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.queryItems?.count == 1, + components.queryItems?.first?.name == "prompt", + let prompt = components.queryItems?.first?.value? + .trimmingCharacters(in: .whitespacesAndNewlines), + !prompt.isEmpty, prompt.count <= 4_096 else { return false } + if draft == prompt || draft.hasSuffix(" \(prompt)") || draft.hasSuffix("\n\(prompt)") { + composerFocused = true + return true + } + draft = draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? prompt + : draft + (draft.last?.isWhitespace == true ? "" : " ") + prompt + composerFocused = true + return true + } + + private func handleTypedMediaPreviewURL(_ url: URL) -> Bool { + guard let route = FeatureTypedMediaPreviewRoute.parse(url) else { return false } + resolveHostMedia(path: route.path, kind: route.kind) + return true + } + + private func resolveHostMedia(path: String, kind: FeatureFilePreviewKind) { + guard let resolver = model.client as? any FeatureWorkspaceAssetResolving else { + linkedMediaPreviewError = "This environment cannot resolve media files." + return + } + let requestedThreadID = currentThread.id + Task { + do { + let resolved = try await resolver.mediaAssetURL( + threadID: requestedThreadID, + path: path + ) + guard !Task.isCancelled, currentThread.id == requestedThreadID else { return } + linkedMediaPreview = FeatureLinkedMediaPreview( + source: .remote(resolved), + kind: kind, + fileName: URL(fileURLWithPath: path).lastPathComponent + ) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled, currentThread.id == requestedThreadID else { return } + linkedMediaPreviewError = error.localizedDescription + } + } + } + + private func dismissKeyboard() { + guard composerFocused else { return } + composerFocused = false + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) + } + + private func send() { + let message = draft + let pendingAttachments = currentThread.environmentID.map { + model.attachmentUploads.attachmentsForSend( + draftKey: draftKey, + environmentID: $0, + attachments: attachments + ) + } ?? attachments + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !pendingAttachments.isEmpty else { + return + } + if pendingAttachments.isEmpty, + let command = FeatureCodexFeedbackCommand.parse(message), + let providerID = currentThread.providerID, + threadProviders.first(where: { $0.id == providerID })?.driver == "codex" + || currentThread.providerName?.lowercased() == "codex", + let submitter = model.client as? any FeatureFeedbackSubmitting { + sendFeedback(command, message: message, submitter: submitter) + return + } + draftSaveTask?.cancel() + isSending = true + draft = "" + attachments = [] + composerFocused = false + Task { + let sent = await submitMessage( + FeatureMessageSubmission( + threadID: thread.id, + text: message, + selection: selection, + attachments: pendingAttachments + ) + ) + if sent { + let followUpDraft = composerDraft + if followUpDraft.text.isEmpty && followUpDraft.attachments.isEmpty { + try? await draftStore.removeDraft(for: draftKey) + } else { + try? await draftStore.setDraft(followUpDraft, for: draftKey) + } + } else { + let currentDraft = draft + let restoredMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + if currentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft = message + } else if !restoredMessage.isEmpty { + draft = "\(message)\n\(currentDraft)" + } + let pendingIDs = Set(pendingAttachments.map(\.id)) + attachments = pendingAttachments + attachments.filter { + !pendingIDs.contains($0.id) + } + sendFailed = true + } + isSending = false + if !sent { + persistDraftImmediately() + } + } + } + + private func sendFeedback( + _ command: FeatureCodexFeedbackCommand, + message: String, + submitter: any FeatureFeedbackSubmitting + ) { + guard detail?.messages.isEmpty == false else { + feedbackAlertMessage = "Send a message before you submit feedback." + return + } + + let identifier = UUID().uuidString + let createdAt = Date() + let assistantID = "\(identifier):feedback" + feedbackMessages.append(FeatureMessage( + id: identifier, + role: .user, + text: message, + createdAt: createdAt + )) + feedbackMessages.append(FeatureMessage( + id: assistantID, + role: .assistant, + text: "Sending feedback to OpenAI...", + createdAt: createdAt.addingTimeInterval(0.001) + )) + feedbackRevision &+= 1 + draftSaveTask?.cancel() + draft = "" + composerFocused = false + isSending = true + + Task { + defer { isSending = false } + do { + let identifier = try await submitter.submitCodexFeedback( + threadID: thread.id, + reason: command.reason + ) + updateFeedbackMessage( + id: assistantID, + text: "Feedback sent to OpenAI.\n\nThread ID: `\(identifier)`" + ) + feedbackIdentifier = identifier + feedbackAlertMessage = "Thread ID: \(identifier)" + try? await draftStore.removeDraft(for: draftKey) + } catch { + let detail = error.localizedDescription + updateFeedbackMessage( + id: assistantID, + text: "Could not send feedback to OpenAI.\n\n\(detail)" + ) + feedbackIdentifier = nil + feedbackAlertMessage = detail + } + } + } + + private func updateFeedbackMessage(id: String, text: String) { + guard let index = feedbackMessages.firstIndex(where: { $0.id == id }) else { return } + feedbackMessages[index].text = text + feedbackRevision &+= 1 + } + + private var draftKey: String { + FeatureComposerDraftStore.threadKey(currentThread) + } + + @MainActor + private func restoreDraft(from baseline: FeatureComposerDraft, key: String) async { + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled else { return } + + let liveDraft = composerDraft + var restored = FeatureComposerDraftRestoration.merge( + saved: saved, + baseline: baseline, + current: liveDraft + ) + restored.selection = ThreadComposerModelSelectionPolicy.explicitSelection( + restored.selection, + inherited: currentSelection, + providers: threadProviders + ) + draft = restored.text + attachments = restored.attachments + selection = restored.selection + didRestoreDraft = true + + // Changes made while the file read or thread refresh was in flight did + // not pass the didRestoreDraft gate, so enqueue their first save now. + if liveDraft != baseline { + scheduleDraftSave() + } else if saved != nil, let environmentID = currentThread.environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: restored.attachments + ) + } + } + + private func scheduleDraftSave() { + guard didRestoreDraft, !isSending else { return } + draftSaveTask?.cancel() + let snapshot = composerDraft + let key = draftKey + let environmentID = currentThread.environmentID + draftSaveTask = Task { + do { + try await Task.sleep(for: .milliseconds(220)) + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch is CancellationError { + return + } catch { + return + } + } + } + + private func persistDraftImmediately() { + guard didRestoreDraft else { return } + draftSaveTask?.cancel() + let snapshot = composerDraft + let key = draftKey + let environmentID = currentThread.environmentID + draftSaveTask = Task { + do { + try await draftStore.setDraft(snapshot, for: key) + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch { + return + } + } + } + + private func persistDraftBeforeLeaving() { + guard didRestoreDraft, !isSending else { return } + persistDraftImmediately() + } + + private var composerDraft: FeatureComposerDraft { + FeatureComposerDraft( + text: draft, + attachments: attachments, + selection: selection + ) + } + +} + +enum ThreadRefreshPresentation: Equatable { + case loading + case reconnecting + case offline + case failed + + var title: String { + switch self { + case .loading: "Updating thread..." + case .reconnecting: "Reconnecting..." + case .offline: "Computer offline" + case .failed: "Could not update thread" + } + } + + var systemImage: String { + switch self { + case .loading: "hourglass" + case .reconnecting: "wifi" + case .offline, .failed: "wifi.exclamationmark" + } + } + + var canRetry: Bool { self == .offline || self == .failed } + + static func resolve( + loadState: FeatureThreadLoadState?, + connectionState: FeatureConnection.State?, + isOpening: Bool + ) -> Self? { + if isOpening || loadState == .loading { return .loading } + if case .failed = loadState { return .failed } + switch connectionState { + case .connecting, .reconnecting: return .reconnecting + case .disconnected: return .offline + case .connected, nil: return nil + } + } +} + +private struct FeatureThreadOpeningView: View { + var body: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.regular) + Text("Loading thread…") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("thread-opening-state") + } +} + +private enum FeatureThreadToolSurface: Identifiable { + case files + case file(String) + case review + case sourceControl + case terminal + + var id: String { + switch self { + case .files: "files" + case let .file(path): "file:\(path)" + case .review: "review" + case .sourceControl: "sourceControl" + case .terminal: "terminal" + } + } +} + +struct ThreadPullRequestDestination: Equatable { + let number: Int + let url: URL + + static func resolve( + thread: FeatureThread, + branchPullRequest: FeaturePullRequest? + ) -> Self? { + if let linked = thread.linkedPullRequest, + let url = URL(string: linked.url) { + return Self(number: linked.number, url: url) + } + + guard let pullRequest = branchPullRequest, + let url = pullRequest.url else { return nil } + return Self(number: pullRequest.number, url: url) + } +} + +/// Merges a stored draft with edits made while that draft was loading. Each +/// field is restored only if its live value still matches the value captured +/// before the asynchronous read began. +enum FeatureComposerDraftRestoration { + static func merge( + saved: FeatureComposerDraft?, + baseline: FeatureComposerDraft, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil + ) -> FeatureComposerDraft { + FeatureComposerDraft( + text: current.text == baseline.text + ? saved?.text ?? "" + : current.text, + attachments: current.attachments == baseline.attachments + ? saved?.attachments ?? [] + : current.attachments, + selection: current.selection == baseline.selection + ? saved?.selection ?? fallbackSelection + : current.selection, + workspace: mergeWorkspace( + saved: saved?.workspace ?? fallbackWorkspace, + baseline: baseline.workspace, + current: current.workspace + ) + ) + } + + private static func mergeWorkspace( + saved: FeatureComposerWorkspaceDraft?, + baseline: FeatureComposerWorkspaceDraft?, + current: FeatureComposerWorkspaceDraft? + ) -> FeatureComposerWorkspaceDraft? { + guard let saved else { + return current == baseline ? nil : current + } + guard let baseline, let current else { + return current == baseline ? saved : current + } + return FeatureComposerWorkspaceDraft( + mode: current.mode == baseline.mode ? saved.mode : current.mode, + branch: current.branch == baseline.branch ? saved.branch : current.branch, + worktreePath: current.worktreePath == baseline.worktreePath + ? saved.worktreePath + : current.worktreePath, + startFromOrigin: current.startFromOrigin == baseline.startFromOrigin + ? saved.startFromOrigin + : current.startFromOrigin + ) + } +} + +/// A recycled transcript surface. SwiftUI still owns each message's rendering, +/// while UIKit keeps offscreen messages out of the active view hierarchy. +private struct FeatureTranscriptCollectionView: UIViewRepresentable { + private static let workingIndicatorID = "__t3-working-indicator__" + private static let loadEarlierID = "__t3-load-earlier__" + + private enum Section: Hashable { + case transcript + } + + let threadID: String + let messages: [FeatureMessage] + let imageContext: MarkdownImageContext? + let renderUpdate: FeatureDetailRenderUpdate? + let dynamicTypeSize: DynamicTypeSize + let isWorking: Bool + let activeSubagentCount: Int + let backgroundWorkIsActive: Bool + let isMonitoring: Bool + let canLoadEarlier: Bool + let isLoadingEarlier: Bool + let onLoadEarlier: () -> Void + let onDismissKeyboard: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UICollectionView { + let collectionView = BottomAnchoredTranscriptCollectionView( + frame: .zero, + collectionViewLayout: Self.makeLayout() + ) + collectionView.backgroundColor = T3Colors.uiBackground + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .onDrag + collectionView.delaysContentTouches = false + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.isPrefetchingEnabled = true + collectionView.accessibilityIdentifier = "thread-transcript" + context.coordinator.connect(to: collectionView) + return collectionView + } + + func updateUIView(_ collectionView: UICollectionView, context: Context) { + context.coordinator.update( + threadID: threadID, + messages: messages, + imageContext: imageContext, + renderUpdate: renderUpdate, + dynamicTypeSize: dynamicTypeSize, + isWorking: isWorking, + activeSubagentCount: activeSubagentCount, + backgroundWorkIsActive: backgroundWorkIsActive, + isMonitoring: isMonitoring, + canLoadEarlier: canLoadEarlier, + isLoadingEarlier: isLoadingEarlier, + onLoadEarlier: onLoadEarlier, + onDismissKeyboard: onDismissKeyboard, + in: collectionView + ) + } + + private static func makeLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { _, environment in + let width = environment.container.effectiveContentSize.width + let sideInset = max(18, (width - T3Metrics.readingWidth) / 2) + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(120) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical( + layoutSize: itemSize, + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 22 + section.contentInsets = NSDirectionalEdgeInsets( + top: 18, + leading: sideInset, + bottom: 14, + trailing: sideInset + ) + return section + } + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDataSourcePrefetching, UICollectionViewDelegate { + private struct MarkdownPrefetch { + let revision: MarkdownContentRevision + let task: Task + } + + private var dataSource: UICollectionViewDiffableDataSource? + private var messagesByID: [String: FeatureMessage] = [:] + private var orderedIDs: [String] = [] + private var currentThreadID: String? + private var currentImageContext: MarkdownImageContext? + private var currentDetailRevision: UInt64? + private var currentDynamicTypeSize: DynamicTypeSize? + private var currentIsWorking = false + private var currentActiveSubagentCount = 0 + private var currentBackgroundWorkIsActive = false + private var currentIsMonitoring = false + private var currentCanLoadEarlier = false + private var currentIsLoadingEarlier = false + private var markdownPrefetches: [String: MarkdownPrefetch] = [:] + private var onLoadEarlier: (() -> Void)? + private var onDismissKeyboard: (() -> Void)? + + deinit { + markdownPrefetches.values.forEach { $0.task.cancel() } + } + + func connect(to collectionView: UICollectionView) { + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, messageID in + if messageID == FeatureTranscriptCollectionView.loadEarlierID { + cell.contentConfiguration = UIHostingConfiguration { + FeatureLoadEarlierTurnsButton( + isLoading: self?.currentIsLoadingEarlier == true, + onLoad: { self?.onLoadEarlier?() } + ) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "load-earlier-turns" + return + } + if messageID == FeatureTranscriptCollectionView.workingIndicatorID { + cell.contentConfiguration = UIHostingConfiguration { + FeatureThreadWorkingIndicator( + activeSubagentCount: self?.currentActiveSubagentCount ?? 0, + backgroundWorkIsActive: self?.currentBackgroundWorkIsActive == true, + isMonitoring: self?.currentIsMonitoring == true + ) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "thread-working-indicator" + return + } + guard let message = self?.messagesByID[messageID] else { + cell.contentConfiguration = nil + return + } + + cell.contentConfiguration = UIHostingConfiguration { + FeatureMessageView(message: message, imageContext: self?.currentImageContext) + .frame(maxWidth: .infinity, alignment: .leading) + } + .margins(.all, 0) + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessibilityIdentifier = "message-cell-\(messageID)" + } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, messageID in + collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: messageID + ) + } + collectionView.prefetchDataSource = self + collectionView.delegate = self + } + + func update( + threadID: String, + messages: [FeatureMessage], + imageContext: MarkdownImageContext?, + renderUpdate: FeatureDetailRenderUpdate?, + dynamicTypeSize: DynamicTypeSize, + isWorking: Bool, + activeSubagentCount: Int, + backgroundWorkIsActive: Bool, + isMonitoring: Bool, + canLoadEarlier: Bool, + isLoadingEarlier: Bool, + onLoadEarlier: @escaping () -> Void, + onDismissKeyboard: @escaping () -> Void, + in collectionView: UICollectionView + ) { + guard let dataSource else { return } + self.onLoadEarlier = onLoadEarlier + self.onDismissKeyboard = onDismissKeyboard + + let threadChanged = currentThreadID != threadID + let imageContextChanged = currentImageContext != imageContext + let typeSizeChanged = currentDynamicTypeSize != dynamicTypeSize + let revisionChanged = currentDetailRevision != renderUpdate?.revision + let workingChanged = currentIsWorking != isWorking + let workingDetailChanged = currentActiveSubagentCount != activeSubagentCount + || currentBackgroundWorkIsActive != backgroundWorkIsActive + || currentIsMonitoring != isMonitoring + let loadEarlierChanged = currentCanLoadEarlier != canLoadEarlier + || currentIsLoadingEarlier != isLoadingEarlier + guard threadChanged || imageContextChanged || typeSizeChanged || revisionChanged || workingChanged + || workingDetailChanged || loadEarlierChanged else { return } + + let incremental = !threadChanged + ? incrementalState(messages: messages, renderUpdate: renderUpdate) + : nil + let state = incremental ?? fullState(messages: messages) + let newIDs = state.ids + let idsChanged = state.idsChanged + let changedIDs = typeSizeChanged || imageContextChanged + ? newIDs + : state.changedIDs + + currentImageContext = imageContext + currentDetailRevision = renderUpdate?.revision + currentDynamicTypeSize = dynamicTypeSize + currentIsWorking = isWorking + currentActiveSubagentCount = activeSubagentCount + currentBackgroundWorkIsActive = backgroundWorkIsActive + currentIsMonitoring = isMonitoring + currentCanLoadEarlier = canLoadEarlier + currentIsLoadingEarlier = isLoadingEarlier + guard threadChanged || idsChanged || !changedIDs.isEmpty || workingChanged + || workingDetailChanged || loadEarlierChanged else { return } + + if threadChanged { + cancelAllMarkdownPrefetches() + } else { + var invalidatedIDs = Set(changedIDs) + if idsChanged, !state.isAppendOnly { + invalidatedIDs.formUnion(Set(orderedIDs).subtracting(newIDs)) + } + cancelMarkdownPrefetches(for: invalidatedIDs) + } + + let wasNearBottom = isNearBottom(collectionView) + let lastIDChanged = orderedIDs.last != newIDs.last || workingChanged + let isInitialLoad = currentThreadID == nil || threadChanged + let previousIDs = orderedIDs + let prependedMessages = !threadChanged + && newIDs.count > previousIDs.count + && Array(newIDs.suffix(previousIDs.count)) == previousIDs + let shouldFollowBottom = isInitialLoad || wasNearBottom + let prependAnchor = !shouldFollowBottom + && (prependedMessages || (loadEarlierChanged && !canLoadEarlier)) + ? visibleAnchor(in: collectionView, dataSource: dataSource) + : nil + + currentThreadID = threadID + if let replacementMessagesByID = state.replacementMessagesByID { + messagesByID = replacementMessagesByID + } + orderedIDs = newIDs + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = + isInitialLoad || wasNearBottom + + var snapshot: NSDiffableDataSourceSnapshot + if threadChanged || loadEarlierChanged { + snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.transcript]) + if canLoadEarlier { + snapshot.appendItems( + [FeatureTranscriptCollectionView.loadEarlierID], + toSection: .transcript + ) + } + snapshot.appendItems(newIDs, toSection: .transcript) + } else if !idsChanged { + snapshot = dataSource.snapshot() + } else if state.isAppendOnly { + snapshot = dataSource.snapshot() + snapshot.appendItems(state.appendedIDs, toSection: .transcript) + } else if newIDs.starts(with: previousIDs) { + snapshot = dataSource.snapshot() + snapshot.appendItems(Array(newIDs.dropFirst(previousIDs.count)), toSection: .transcript) + } else { + snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.transcript]) + if canLoadEarlier { + snapshot.appendItems( + [FeatureTranscriptCollectionView.loadEarlierID], + toSection: .transcript + ) + } + snapshot.appendItems(newIDs, toSection: .transcript) + } + if snapshot.indexOfItem(FeatureTranscriptCollectionView.workingIndicatorID) != nil { + snapshot.deleteItems([FeatureTranscriptCollectionView.workingIndicatorID]) + } + if isWorking { + snapshot.appendItems( + [FeatureTranscriptCollectionView.workingIndicatorID], + toSection: .transcript + ) + } + let appendedIDSet = Set(state.appendedIDs) + var reconfiguredIDs = changedIDs.filter { !appendedIDSet.contains($0) } + if loadEarlierChanged, + snapshot.indexOfItem(FeatureTranscriptCollectionView.loadEarlierID) != nil { + reconfiguredIDs.append(FeatureTranscriptCollectionView.loadEarlierID) + } + if workingDetailChanged, + snapshot.indexOfItem(FeatureTranscriptCollectionView.workingIndicatorID) != nil { + reconfiguredIDs.append(FeatureTranscriptCollectionView.workingIndicatorID) + } + if !reconfiguredIDs.isEmpty { + snapshot.reconfigureItems(reconfiguredIDs) + } + + dataSource.apply(snapshot, animatingDifferences: false) { + [weak self, weak collectionView] in + guard let self, let collectionView else { return } + DispatchQueue.main.async { + if shouldFollowBottom { + self.scrollToBottom( + collectionView, + animated: !isInitialLoad && lastIDChanged + ) + } else if let prependAnchor { + self.restore(prependAnchor, in: collectionView, dataSource: dataSource) + } + } + } + } + + private struct VisibleAnchor { + let id: String + let offsetFromViewportTop: CGFloat + } + + private func visibleAnchor( + in collectionView: UICollectionView, + dataSource: UICollectionViewDiffableDataSource + ) -> VisibleAnchor? { + for indexPath in collectionView.indexPathsForVisibleItems.sorted() { + guard let id = dataSource.itemIdentifier(for: indexPath), + id != FeatureTranscriptCollectionView.loadEarlierID, + id != FeatureTranscriptCollectionView.workingIndicatorID, + let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + continue + } + return VisibleAnchor( + id: id, + offsetFromViewportTop: attributes.frame.minY - collectionView.contentOffset.y + ) + } + return nil + } + + private func restore( + _ anchor: VisibleAnchor, + in collectionView: UICollectionView, + dataSource: UICollectionViewDiffableDataSource + ) { + collectionView.layoutIfNeeded() + guard let indexPath = dataSource.indexPath(for: anchor.id), + let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + return + } + let minimumY = -collectionView.adjustedContentInset.top + let maximumY = max( + minimumY, + collectionView.contentSize.height + - collectionView.bounds.height + + collectionView.adjustedContentInset.bottom + ) + let targetY = min( + maximumY, + max(minimumY, attributes.frame.minY - anchor.offsetFromViewportTop) + ) + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetY), + animated: false + ) + } + + private struct MessageState { + let ids: [String] + let replacementMessagesByID: [String: FeatureMessage]? + let changedIDs: [String] + let appendedIDs: [String] + let idsChanged: Bool + let isAppendOnly: Bool + } + + private func incrementalState( + messages: [FeatureMessage], + renderUpdate: FeatureDetailRenderUpdate? + ) -> MessageState? { + guard let currentDetailRevision, + let renderUpdate, + renderUpdate.baseRevision == currentDetailRevision, + case let .delta(delta) = renderUpdate.change, + messages.count == orderedIDs.count + delta.appendedMessageIDs.count else { + return nil + } + + let appendedIDs = delta.appendedMessageIDs + guard Set(appendedIDs).count == appendedIDs.count, + appendedIDs.allSatisfy({ messagesByID[$0] == nil }) else { + return nil + } + + let appendedIDSet = Set(appendedIDs) + let changedMessageIDs = Set(delta.changedMessages.map(\.id)) + guard appendedIDs.allSatisfy(changedMessageIDs.contains), + delta.changedMessages.allSatisfy({ + messagesByID[$0.id] != nil || appendedIDSet.contains($0.id) + }) else { + return nil + } + + var changedIDs: [String] = [] + changedIDs.reserveCapacity(delta.changedMessages.count) + for message in delta.changedMessages { + if messagesByID[message.id] != message { + changedIDs.append(message.id) + } + messagesByID[message.id] = message + } + + return MessageState( + ids: appendedIDs.isEmpty ? orderedIDs : orderedIDs + appendedIDs, + replacementMessagesByID: nil, + changedIDs: changedIDs, + appendedIDs: appendedIDs, + idsChanged: !appendedIDs.isEmpty, + isAppendOnly: !appendedIDs.isEmpty + ) + } + + private func fullState(messages: [FeatureMessage]) -> MessageState { + var seenMessageIDs = Set() + let uniqueMessages = Array(messages.reversed().filter { + seenMessageIDs.insert($0.id).inserted + }.reversed()) + let ids = uniqueMessages.map(\.id) + let updatedMessages = uniqueMessages.reduce(into: [String: FeatureMessage]()) { + $0[$1.id] = $1 + } + return MessageState( + ids: ids, + replacementMessagesByID: updatedMessages, + changedIDs: ids.filter { messagesByID[$0] != updatedMessages[$0] }, + appendedIDs: [], + idsChanged: orderedIDs != ids, + isAppendOnly: false + ) + } + + func collectionView( + _ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath] + ) { + for indexPath in indexPaths where orderedIDs.indices.contains(indexPath.item) { + let messageID = orderedIDs[indexPath.item] + guard markdownPrefetches[messageID] == nil, + let message = messagesByID[messageID], + !message.text.isEmpty, + message.state != .streaming, + message.role == .user || message.role == .assistant else { + continue + } + + let revision = MarkdownContentRevision(message.text) + guard MarkdownRenderCache.shared.cachedDocument(for: revision) == nil else { + continue + } + + let task = Task { [weak self] in + guard !Task.isCancelled else { return } + _ = await MarkdownRenderCache.shared.document(for: revision) + guard !Task.isCancelled else { return } + self?.finishMarkdownPrefetch(messageID: messageID, revision: revision) + } + markdownPrefetches[messageID] = MarkdownPrefetch( + revision: revision, + task: task + ) + } + } + + func collectionView( + _ collectionView: UICollectionView, + cancelPrefetchingForItemsAt indexPaths: [IndexPath] + ) { + let messageIDs = indexPaths.compactMap { indexPath in + orderedIDs.indices.contains(indexPath.item) ? orderedIDs[indexPath.item] : nil + } + cancelMarkdownPrefetches(for: Set(messageIDs)) + } + + private func finishMarkdownPrefetch( + messageID: String, + revision: MarkdownContentRevision + ) { + guard markdownPrefetches[messageID]?.revision == revision else { return } + markdownPrefetches.removeValue(forKey: messageID) + } + + private func cancelMarkdownPrefetches(for messageIDs: Set) { + for messageID in messageIDs { + markdownPrefetches.removeValue(forKey: messageID)?.task.cancel() + } + } + + private func cancelAllMarkdownPrefetches() { + markdownPrefetches.values.forEach { $0.task.cancel() } + markdownPrefetches.removeAll(keepingCapacity: true) + } + + private func isNearBottom(_ collectionView: UICollectionView) -> Bool { + let visibleBottom = collectionView.contentOffset.y + + collectionView.bounds.height + - collectionView.adjustedContentInset.bottom + return collectionView.contentSize.height - visibleBottom < 120 + } + + private func scrollToBottom( + _ collectionView: UICollectionView, + animated: Bool + ) { + collectionView.layoutIfNeeded() + let geometry = TranscriptViewportGeometry( + contentHeight: collectionView.contentSize.height, + viewportHeight: collectionView.bounds.height, + topInset: collectionView.adjustedContentInset.top, + bottomInset: collectionView.adjustedContentInset.bottom + ) + let target = CGPoint(x: collectionView.contentOffset.x, y: geometry.bottomOffset) + collectionView.setContentOffset(target, animated: animated) + (collectionView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = true + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + (scrollView as? BottomAnchoredTranscriptCollectionView)?.maintainsBottomAnchor = false + onDismissKeyboard?() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard !decelerate else { return } + updateBottomAnchor(for: scrollView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateBottomAnchor(for: scrollView) + } + + private func updateBottomAnchor(for scrollView: UIScrollView) { + guard let collectionView = scrollView as? BottomAnchoredTranscriptCollectionView else { + return + } + collectionView.maintainsBottomAnchor = isNearBottom(collectionView) + } + } +} + +private struct FeatureLoadEarlierTurnsButton: View { + let isLoading: Bool + let onLoad: () -> Void + + var body: some View { + Button(action: onLoad) { + HStack(spacing: 7) { + if isLoading { + Image(systemName: "ellipsis") + .font(T3Typography.supporting.weight(.semibold)) + } + Text(isLoading ? "Loading earlier turns…" : "Load earlier turns") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity) + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .disabled(isLoading) + .accessibilityLabel(isLoading ? "Loading earlier turns" : "Load earlier turns") + } +} + +private struct FeatureThreadWorkingIndicator: View { + let activeSubagentCount: Int + let backgroundWorkIsActive: Bool + let isMonitoring: Bool + + private var title: String { + if isMonitoring { + return "Monitoring in the background" + } + if activeSubagentCount == 1 { + return "1 subagent is working" + } + if activeSubagentCount > 1 { + return "\(activeSubagentCount) subagents are working" + } + return backgroundWorkIsActive ? "Background work is running" : "Agent is working" + } + + private var detail: String? { + backgroundWorkIsActive || isMonitoring ? nil : "New output will appear here" + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "circle.dotted") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(T3Colors.statusRunning) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.statusRunning) + if let detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel(detail.map { "\(title). \($0)." } ?? "\(title).") + } +} + +struct TranscriptViewportGeometry: Equatable { + let contentHeight: CGFloat + let viewportHeight: CGFloat + let topInset: CGFloat + let bottomInset: CGFloat + + var bottomOffset: CGFloat { + max(-topInset, contentHeight - viewportHeight + bottomInset) + } + + func restoredBottomOffset( + after previous: Self?, + maintainsBottomAnchor: Bool, + isInteracting: Bool + ) -> CGFloat? { + guard maintainsBottomAnchor, !isInteracting else { + return nil + } + + guard let previous, + previous.contentHeight > 0, + previous.viewportHeight > 0 else { + return contentHeight > 0 && viewportHeight > 0 ? bottomOffset : nil + } + + let contentChanged = abs(contentHeight - previous.contentHeight) > 0.5 + let viewportChanged = abs(viewportHeight - previous.viewportHeight) > 0.5 + || abs(bottomInset - previous.bottomInset) > 0.5 + guard contentChanged || viewportChanged else { return nil } + + return bottomOffset + } +} + +/// The detail surface uses a native pan recognizer instead of a SwiftUI +/// `DragGesture`. SwiftUI's broad drag recognizer can begin before it knows +/// whether a gesture is vertical, which competes with the transcript's native +/// collection-view scrolling. This recognizer fails for vertical motion at +/// gesture-begin time and remains simultaneous with the collection view for +/// horizontal motion. +enum ThreadBackSwipeGesture { + static let minimumTranslation: CGFloat = 72 + static let horizontalToVerticalRatio: CGFloat = 1.4 + private static let scrollExtentEpsilon: CGFloat = 1 + + static func shouldBegin(with velocity: CGPoint) -> Bool { + shouldBegin(with: velocity, translation: .zero) + } + + static func shouldBegin(with velocity: CGPoint, translation: CGPoint) -> Bool { + let direction = hypot(translation.x, translation.y) >= 8 ? translation : velocity + return direction.x > 0 + && direction.x >= abs(direction.y) * horizontalToVerticalRatio + } + + static func shouldNavigateBack(with translation: CGPoint) -> Bool { + translation.x >= minimumTranslation + && translation.x >= abs(translation.y) * horizontalToVerticalRatio + } + + @MainActor + static func shouldAllowSimultaneousRecognition(with scrollView: UIScrollView) -> Bool { + let hasHorizontalContent = scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon + guard hasHorizontalContent else { + return scrollView.alwaysBounceVertical + || scrollView.contentSize.height + > scrollView.bounds.height + scrollExtentEpsilon + } + return isAtLeadingEdge(scrollView) + } + + @MainActor + static func shouldReceiveTouch(in view: UIView?, host: UIView) -> Bool { + var currentView = view + while let current = currentView { + // Editable text and an active transcript selection need to own + // horizontal drags for caret and selection-handle movement. Plain + // rendered message text still participates in the full-surface pan. + if current is UITextField { + return false + } + if let textView = current as? UITextView, + textView.isEditable || textView.isFirstResponder { + return false + } + if let scrollView = current as? UIScrollView, + scrollView.alwaysBounceHorizontal + || scrollView.contentSize.width + > scrollView.bounds.width + scrollExtentEpsilon { + guard isAtLeadingEdge(scrollView) else { return false } + } + if current === host { return true } + currentView = current.superview + } + return false + } + + @MainActor + private static func isAtLeadingEdge(_ scrollView: UIScrollView) -> Bool { + scrollView.contentOffset.x + <= -scrollView.adjustedContentInset.left + scrollExtentEpsilon + } + + @MainActor + static func shouldReceiveTouch( + _ touch: UITouch, + surface: UIView, + host: UIView + ) -> Bool { + guard surface.window === host.window, + surface.bounds.contains(touch.location(in: surface)), + shouldReceiveTouch(in: touch.view, host: host), + surface.window?.rootViewController?.presentedViewController == nil else { + return false + } + return true + } +} + +private struct ThreadBackSwipeGestureView: UIViewRepresentable { + let isEnabled: Bool + let onNavigateBack: () -> Void + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + return view + } + + func updateUIView(_ view: InstallerView, context: Context) { + view.update(isEnabled: isEnabled, onNavigateBack: onNavigateBack) + } + + static func dismantleUIView(_ view: InstallerView, coordinator: ()) { + view.uninstallGesture() + } + + final class InstallerView: UIView { + private var isEnabled = false + private var onNavigateBack: (() -> Void)? + private weak var gestureHost: UIView? + private var panGesture: UIPanGestureRecognizer? + private var gestureDelegate: GestureDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + uninstallGesture() + } else { + installGestureIfPossible() + } + } + + func update(isEnabled: Bool, onNavigateBack: @escaping () -> Void) { + self.isEnabled = isEnabled + self.onNavigateBack = onNavigateBack + installGestureIfPossible() + } + + func uninstallGesture() { + if let panGesture, let gestureHost { + gestureHost.removeGestureRecognizer(panGesture) + } + panGesture = nil + gestureDelegate = nil + gestureHost = nil + } + + private func installGestureIfPossible() { + // SwiftUI hosts a background UIViewRepresentable beside, rather than + // above, the transcript and composer. Install on their shared root + // view and use the representable's frame to scope received touches. + guard isEnabled, let window, let host = window.rootViewController?.view else { + if !isEnabled { uninstallGesture() } + return + } + guard gestureHost !== host else { return } + + uninstallGesture() + let panGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handlePan(_:)) + ) + let gestureDelegate = GestureDelegate(owner: self) + panGesture.delegate = gestureDelegate + panGesture.cancelsTouchesInView = false + panGesture.delaysTouchesBegan = false + panGesture.maximumNumberOfTouches = 1 + host.addGestureRecognizer(panGesture) + gestureHost = host + self.panGesture = panGesture + self.gestureDelegate = gestureDelegate + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard isEnabled, + gesture.state == .ended, + ThreadBackSwipeGesture.shouldNavigateBack( + with: gesture.translation(in: gesture.view) + ) else { + return + } + onNavigateBack?() + } + + private final class GestureDelegate: NSObject, UIGestureRecognizerDelegate { + weak var owner: InstallerView? + + init(owner: InstallerView) { + self.owner = owner + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let owner, + owner.isEnabled, + let panGesture = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + return ThreadBackSwipeGesture.shouldBegin( + with: panGesture.velocity(in: panGesture.view), + translation: panGesture.translation(in: panGesture.view) + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard let owner, + let gestureHost = owner.gestureHost, + ThreadBackSwipeGesture.shouldReceiveTouch( + touch, + surface: owner, + host: gestureHost + ) + else { return false } + return true + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + if otherGestureRecognizer is UIScreenEdgePanGestureRecognizer { + return true + } + guard let scrollView = otherGestureRecognizer.view as? UIScrollView else { + return false + } + return ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition( + with: scrollView + ) + } + } + } +} + +/// Self-sizing hosted Markdown can change the transcript height after a snapshot finishes, +/// while presenting the keyboard changes the viewport without changing the content at all. +/// Preserve the visual bottom only while the reader is already following the latest turn. +private final class BottomAnchoredTranscriptCollectionView: UICollectionView { + var maintainsBottomAnchor = false + + private var lastLaidOutGeometry: TranscriptViewportGeometry? + private var isRestoringBottomAnchor = false + + override func layoutSubviews() { + super.layoutSubviews() + + let geometry = TranscriptViewportGeometry( + contentHeight: contentSize.height, + viewportHeight: bounds.height, + topInset: adjustedContentInset.top, + bottomInset: adjustedContentInset.bottom + ) + defer { lastLaidOutGeometry = geometry } + + guard let bottomY = geometry.restoredBottomOffset( + after: lastLaidOutGeometry, + maintainsBottomAnchor: maintainsBottomAnchor, + isInteracting: isDragging || isDecelerating || isRestoringBottomAnchor + ) else { + return + } + guard abs(contentOffset.y - bottomY) > 0.5 else { return } + + isRestoringBottomAnchor = true + contentOffset = CGPoint(x: contentOffset.x, y: bottomY) + isRestoringBottomAnchor = false + } +} + +private struct FeatureRemoteAttachmentThumbnail: View { + private struct Request: Hashable { + let url: URL + let maximumPixelSize: Int + } + + @SwiftUI.Environment(\.displayScale) private var displayScale + @State private var image: UIImage? + @State private var loadedRequest: Request? + @State private var failedRequest: Request? + + let url: URL + + var body: some View { + Group { + if loadedRequest == request, let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if failedRequest == request { + placeholder(systemImage: "exclamationmark.triangle") + } else { + placeholder(systemImage: "photo") + } + } + .accessibilityHidden(true) + .task(id: request) { + let activeRequest = request + do { + let image = try await FeatureAttachmentThumbnailLoader.image( + for: activeRequest.url, + maximumPixelSize: activeRequest.maximumPixelSize + ) + try Task.checkCancellation() + self.image = image + loadedRequest = activeRequest + failedRequest = nil + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + image = nil + loadedRequest = nil + failedRequest = activeRequest + } + } + } + + private var request: Request { + Request( + url: url, + maximumPixelSize: min(768, max(190, Int(ceil(190 * displayScale)))) + ) + } + + private func placeholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Local preview bytes routed through the shared thumbnail cache so streaming +/// reconfigures of a message with attachments never re-allocate UIImages in +/// body. Decode happens once, off the main thread. +private struct FeatureLocalAttachmentThumbnail: View { + let attachmentID: String + let previewData: Data + + @State private var image: UIImage? + @State private var failed = false + + private var cacheKey: NSString { "local:\(attachmentID)" as NSString } + + var body: some View { + Group { + if let image = image ?? FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if failed { + placeholder(systemImage: "exclamationmark.triangle") + } else { + placeholder(systemImage: "photo") + } + } + .accessibilityHidden(true) + .task(id: attachmentID) { + guard FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) == nil else { return } + let data = previewData + let decoded = await Task.detached(priority: .utility) { + UIImage(data: data) + }.value + guard !Task.isCancelled else { return } + if let decoded { + FeatureAttachmentThumbnailCache.shared.insert(decoded, for: cacheKey) + image = decoded + } else { + failed = true + } + } + } + + private func placeholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private enum FeatureAttachmentThumbnailLoader { + static func image(for url: URL, maximumPixelSize: Int) async throws -> UIImage { + let cacheKey = "\(url.absoluteString)#\(maximumPixelSize)" as NSString + if let cached = FeatureAttachmentThumbnailCache.shared.image(for: cacheKey) { + return cached + } + + let (data, response) = try await URLSession.shared.data(from: url) + try Task.checkCancellation() + if let response = response as? HTTPURLResponse, + !(200...299).contains(response.statusCode) { + throw FeatureAttachmentThumbnailError.invalidResponse + } + + let image = try await Task.detached(priority: .utility) { + try downsample(data: data, maximumPixelSize: maximumPixelSize) + }.value + try Task.checkCancellation() + FeatureAttachmentThumbnailCache.shared.insert(image, for: cacheKey) + return image + } + + private static func downsample(data: Data, maximumPixelSize: Int) throws -> UIImage { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + throw FeatureAttachmentThumbnailError.decodingFailed + } + + let thumbnailOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + thumbnailOptions + ) else { + throw FeatureAttachmentThumbnailError.decodingFailed + } + return UIImage(cgImage: thumbnail) + } +} + +private final class FeatureAttachmentThumbnailCache: @unchecked Sendable { + static let shared = FeatureAttachmentThumbnailCache() + + private let images = NSCache() + + private init() { + images.countLimit = 96 + images.totalCostLimit = 32 * 1_024 * 1_024 + } + + func image(for key: NSString) -> UIImage? { + images.object(forKey: key) + } + + func insert(_ image: UIImage, for key: NSString) { + let cost = image.cgImage.map { $0.bytesPerRow * $0.height } ?? 0 + images.setObject(image, forKey: key, cost: cost) + } +} + +private enum FeatureAttachmentThumbnailError: Error { + case invalidResponse + case decodingFailed +} + +struct FeatureMessageView: View { + let message: FeatureMessage + var imageContext: MarkdownImageContext? = nil + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 44) + VStack(alignment: .leading, spacing: 10) { + FeatureMessageAttachmentsView(attachments: message.attachments) + if !message.text.isEmpty { + MarkdownMessageView( + message.text, + isStreaming: message.state == .streaming, + imageContext: imageContext + ) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: T3Metrics.readingWidth * 0.88, alignment: .leading) + .background( + T3Colors.subtleStrong, + in: UnevenRoundedRectangle( + topLeadingRadius: 16, + bottomLeadingRadius: 16, + bottomTrailingRadius: 4, + topTrailingRadius: 16 + ) + ) + } + .accessibilityLabel("You") + .accessibilityValue(accessibilityValue) + .accessibilityIdentifier("message-\(message.id)") + case .assistant: + VStack(alignment: .leading, spacing: 10) { + if message.state == .streaming { + HStack(spacing: 6) { + Image(systemName: "circle.dotted") + Text("Working") + } + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.statusRunning) + } + FeatureMessageAttachmentsView(attachments: message.attachments) + if !message.text.isEmpty { + MarkdownMessageView( + message.text, + isStreaming: message.state == .streaming, + imageContext: imageContext + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("message-\(message.id)") + case .tool: + FeatureWorkLogView(message: message, imageContext: imageContext) + .id(message.id) + case .system: + Text(message.text) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityIdentifier("message-\(message.id)") + } + } + + private var accessibilityValue: String { + let attachmentSummary = message.attachments.isEmpty + ? "" + : "\(message.attachments.count) image attachment" + + (message.attachments.count == 1 ? "" : "s") + return [message.text, attachmentSummary] + .filter { !$0.isEmpty } + .joined(separator: ", ") + } +} + +private struct FeatureWorkLogView: View { + let message: FeatureMessage + let imageContext: MarkdownImageContext? + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Label(message.toolName ?? "Tool output", systemImage: "terminal") + if let activeWorkLabel = message.activeWorkLabel { + Text(activeWorkLabel) + .lineLimit(1) + .foregroundStyle(T3Colors.statusRunning) + } + } + Spacer(minLength: 8) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.caption.weight(.semibold)) + } + .font(T3Typography.tool.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityIdentifier("work-log-toggle-\(message.id)") + + if isExpanded { + Text(message.text) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + .lineSpacing(3) + .textSelection(.enabled) + .padding(.top, 8) + .transition(.identity) + if FeatureWorkLogMedia.shouldRenderImages( + isExpanded: isExpanded, + paths: message.workLogImagePaths ?? [] + ) { + MarkdownMessageView( + FeatureWorkLogMedia.markdownSource( + for: message.workLogImagePaths ?? [] + ), + imageContext: imageContext + ) + .padding(.top, 8) + } + } + } + .padding(.vertical, 6) + .accessibilityIdentifier("message-\(message.id)") + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } +} + +enum FeatureWorkLogMedia { + static func shouldRenderImages(isExpanded: Bool, paths: [String]) -> Bool { + isExpanded && !paths.isEmpty + } + + static func markdownSource(for paths: [String]) -> String { + paths.prefix(8).compactMap { path in + guard let escaped = path.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed.subtracting( + CharacterSet(charactersIn: "()<>[]!\\\"' #%?\n\r") + ) + ) else { return nil } + return "![](\(escaped))" + }.joined(separator: "\n\n") + } +} + +private struct FeatureMessageAttachmentsView: View { + let attachments: [FeatureMessageAttachment] + @State private var previewedAttachment: FeatureMessageAttachment? + + var body: some View { + if !attachments.isEmpty { + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 118, maximum: 190), spacing: 7), + ], + alignment: .leading, + spacing: 7 + ) { + ForEach(attachments) { attachment in + VStack(alignment: .leading, spacing: 6) { + if attachment.mimeType.hasPrefix("image/") { + Group { + if let previewData = attachment.previewData { + FeatureLocalAttachmentThumbnail( + attachmentID: attachment.id, + previewData: previewData + ) + } else if let url = attachment.url { + FeatureRemoteAttachmentThumbnail(url: url) + } else { + attachmentPlaceholder(systemImage: "photo") + } + } + .frame(height: 160) + .frame(maxWidth: .infinity) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + HStack(spacing: 9) { + Image( + systemName: attachment.mimeType.hasPrefix("image/") + ? "photo" + : "doc" + ) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 30, height: 30) + .background( + T3Colors.surfaceRaised, + in: RoundedRectangle(cornerRadius: 6) + ) + VStack(alignment: .leading, spacing: 1) { + Text(attachment.name) + .font(T3Typography.control) + .lineLimit(1) + Text( + ByteCountFormatter.string( + fromByteCount: Int64(attachment.sizeBytes), + countStyle: .file + ) + ) + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(7) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(T3Colors.border, lineWidth: 1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel( + attachment.mimeType.hasPrefix("image/") + ? "Image attachment" + : "File attachment" + ) + .accessibilityValue(attachmentAccessibilityValue(attachment)) + .accessibilityIdentifier("attachment-\(attachment.id)") + .accessibilityAddTraits(canPreview(attachment) ? .isButton : []) + .accessibilityHint( + canPreview(attachment) + ? "Opens full-screen preview" + : "" + ) + .accessibilityAction { + if canPreview(attachment) { + previewedAttachment = attachment + } + } + .contentShape(Rectangle()) + .onTapGesture { + if canPreview(attachment) { + previewedAttachment = attachment + } + } + } + } + .fullScreenCover(item: $previewedAttachment) { attachment in + FeatureAttachmentPreview(attachment: attachment) + } + } + } + + private func attachmentPlaceholder(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func attachmentAccessibilityValue( + _ attachment: FeatureMessageAttachment + ) -> String { + let size = ByteCountFormatter.string( + fromByteCount: Int64(attachment.sizeBytes), + countStyle: .file + ) + return "\(attachment.name), \(size)" + } + + private func canPreview(_ attachment: FeatureMessageAttachment) -> Bool { + (attachment.previewData != nil && attachment.mimeType.hasPrefix("image/")) + || attachment.url != nil + } +} + +private struct FeatureAttachmentPreview: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let attachment: FeatureMessageAttachment + + var body: some View { + NavigationStack { + FeatureNativeMediaPreviewView( + source: attachment.previewData.map(FeatureMediaPreviewSource.localImage) + ?? attachment.url.map(FeatureMediaPreviewSource.remote) + ?? .localImage(Data()), + kind: FeatureLinkedMediaPreview.previewKind( + fileName: attachment.name, + mimeType: attachment.mimeType + ), + fileName: attachment.name + ) + .navigationTitle(attachment.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .t3NavigationChrome() + } + .preferredColorScheme(.dark) + } +} + +private struct FeatureLinkedMediaPreview: Identifiable { + let id = UUID() + let source: FeatureMediaPreviewSource + let kind: FeatureFilePreviewKind + let fileName: String + + static func previewKind(for url: URL) -> FeatureFilePreviewKind? { + let kind = FeatureFilePreviewKind.infer(path: url.path) + return switch kind { + case .image, .pdf, .video, .document: kind + case .markdown, .source, .plainText: nil + } + } + + static func previewKind(fileName: String, mimeType: String) -> FeatureFilePreviewKind { + if mimeType.hasPrefix("image/") { return .image } + if mimeType.hasPrefix("video/") { return .video } + if mimeType == "application/pdf" { return .pdf } + let inferred = FeatureFilePreviewKind.infer(path: fileName) + return inferred == .plainText ? .document : inferred + } +} diff --git a/apps/swift-ios/Features/Connection/ConnectionDetails.swift b/apps/swift-ios/Features/Connection/ConnectionDetails.swift new file mode 100644 index 000000000000..be783aafb219 --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionDetails.swift @@ -0,0 +1,282 @@ +import Foundation + +struct ConnectionDetails: Equatable, Sendable { + var endpoint: String + var pairingCode: String? +} + +enum ConnectionDetailsError: LocalizedError, Equatable { + case empty + case invalidAddress + case unsupportedScheme + + var errorDescription: String? { + switch self { + case .empty: + "Paste a T3 pairing link or enter your server address." + case .invalidAddress: + "That connection link does not include a valid server address." + case .unsupportedScheme: + "Use an HTTP or HTTPS T3 server address." + } + } +} + +/// Parses the pairing links emitted by local, shared, and hosted T3 environments. +enum ConnectionDetailsParser { + private static let tokenNames = [ + "token", + "pairing_token", + "pairingToken", + "pairing_code", + "pairingCode", + "code", + ] + private static let endpointNames = ["host", "endpoint", "server", "url"] + private static let wrappedPairingURLNames = ["pairingUrl", "pairing_url"] + + static func parse(_ input: String) throws -> ConnectionDetails { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ConnectionDetailsError.empty } + + let extracted = trimmed.lowercased().hasPrefix("t3code:") + ? trimmed + : (firstURL(in: trimmed) ?? trimmed) + let candidate = trimmingTrailingProsePunctuation(extracted) + let loweredCandidate = candidate.lowercased() + if candidate.contains("://") + || loweredCandidate.hasPrefix("t3code:") + || loweredCandidate.hasPrefix("t3:") { + return try parseURL(candidate) + } + + let pieces = candidate + .split(whereSeparator: \.isWhitespace) + .map(String.init) + guard let address = pieces.first else { throw ConnectionDetailsError.empty } + let code = pieces.dropFirst().first(where: { !$0.isEmpty }) + return ConnectionDetails( + endpoint: try normalizedEndpoint(address), + pairingCode: normalizedCode(code) + ) + } + + static func normalizedEndpoint(_ input: String) throws -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw ConnectionDetailsError.empty } + + let value: String + if trimmed.contains("://") { + value = trimmed + } else { + let address = bracketBareIPv6(trimmed) + value = "\(EndpointNetworkScope.isLocalHost(trimmed) ? "http" : "https")://\(address)" + } + + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased() + else { + throw ConnectionDetailsError.invalidAddress + } + + switch scheme { + case "http", "https": + break + case "ws": + components.scheme = "http" + case "wss": + components.scheme = "https" + default: + throw ConnectionDetailsError.unsupportedScheme + } + + guard let host = components.host, !host.isEmpty else { + throw ConnectionDetailsError.invalidAddress + } + + components.path = "" + components.query = nil + components.fragment = nil + guard var normalized = components.url?.absoluteString else { + throw ConnectionDetailsError.invalidAddress + } + while normalized.hasSuffix("/") { + normalized.removeLast() + } + return normalized + } + + private static func parseURL(_ input: String) throws -> ConnectionDetails { + guard let components = URLComponents(string: input), + let scheme = components.scheme?.lowercased() + else { + throw ConnectionDetailsError.invalidAddress + } + + let fragmentItems = URLComponents(string: "?\(components.fragment ?? "")")?.queryItems ?? [] + let queryItems = components.queryItems ?? [] + let allItems = queryItems + fragmentItems + let token = firstValue(named: tokenNames, in: allItems) + + if ["t3", "t3code", "t3code-swiftui", "t3code-swiftui-dev"].contains(scheme) { + if let wrappedPairingURL = firstValue(named: wrappedPairingURLNames, in: allItems) { + var wrapped = try parse(wrappedPairingURL) + if wrapped.pairingCode == nil { + wrapped.pairingCode = normalizedCode(token) + } + return wrapped + } + guard let target = firstValue(named: endpointNames, in: allItems) else { + throw ConnectionDetailsError.invalidAddress + } + return ConnectionDetails( + endpoint: try normalizedEndpoint(target), + pairingCode: normalizedCode(token) + ) + } + + guard ["http", "https", "ws", "wss"].contains(scheme) else { + throw ConnectionDetailsError.unsupportedScheme + } + + let advertisedHost = firstValue(named: endpointNames, in: queryItems) + return ConnectionDetails( + endpoint: try normalizedEndpoint(advertisedHost ?? input), + pairingCode: normalizedCode(token) + ) + } + + private static func firstValue(named names: [String], in items: [URLQueryItem]) -> String? { + items.first { item in + names.contains { $0.caseInsensitiveCompare(item.name) == .orderedSame } + }?.value + } + + private static func normalizedCode(_ input: String?) -> String? { + let value = input?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } + + private static func firstURL(in input: String) -> String? { + guard let expression = try? NSRegularExpression( + pattern: #"(?i)(?:(?:https?|wss?|t3)://|t3code(?:-swiftui(?:-dev)?)?:(?://)?)[^\s<>"']+"# + ) else { + return nil + } + let range = NSRange(input.startIndex..., in: input) + guard let match = expression.firstMatch(in: input, range: range), + let swiftRange = Range(match.range, in: input) + else { + return nil + } + return trimmingTrailingProsePunctuation(String(input[swiftRange])) + } + + /// Pairing links are commonly copied from sentences and terminal output. + /// Remove punctuation belonging to that prose while retaining balanced URL + /// delimiters such as the closing bracket around an IPv6 host. + static func trimmingTrailingProsePunctuation(_ input: String) -> String { + var value = input + while let last = value.last { + let shouldTrim = switch last { + case ".", ",", ";", "!": + true + case "?": + value.dropLast().contains("?") + case ")": + value.filter { $0 == ")" }.count > value.filter { $0 == "(" }.count + case "]": + value.filter { $0 == "]" }.count > value.filter { $0 == "[" }.count + case "}": + value.filter { $0 == "}" }.count > value.filter { $0 == "{" }.count + default: + false + } + guard shouldTrim else { break } + value.removeLast() + } + return value + } + + private static func bracketBareIPv6(_ value: String) -> String { + guard value.contains(":"), + !value.hasPrefix("["), + value.filter({ $0 == ":" }).count > 1 + else { + return value + } + return "[\(value)]" + } +} + +enum EndpointNetworkScope { + static func isLocal(_ endpoint: String) -> Bool { + guard let host = URLComponents(string: endpoint)?.host else { return false } + return isLocalHost(host) + } + + static func isLocalHost(_ rawHost: String) -> Bool { + let host = hostWithoutPort(rawHost).lowercased() + + if host == "localhost" || host.hasSuffix(".local") || host == "::1" { + return true + } + + let octets = host.split(separator: ".").compactMap { Int($0) } + if octets.count == 4, octets.allSatisfy({ 0 ... 255 ~= $0 }) { + return octets[0] == 10 + || octets[0] == 127 + || (octets[0] == 169 && octets[1] == 254) + || (octets[0] == 172 && 16 ... 31 ~= octets[1]) + || (octets[0] == 192 && octets[1] == 168) + } + + guard host.contains(":"), + let firstHextetText = host.split(separator: ":", omittingEmptySubsequences: true).first, + let firstHextet = UInt16(firstHextetText, radix: 16) + else { + return false + } + return firstHextet & 0xffc0 == 0xfe80 + || firstHextet & 0xfe00 == 0xfc00 + } + + private static func hostWithoutPort(_ rawHost: String) -> String { + let value = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("["), + let closingBracket = value.firstIndex(of: "]") { + return String(value[value.index(after: value.startIndex) ..< closingBracket]) + } + + if value.filter({ $0 == ":" }).count == 1, + let colon = value.lastIndex(of: ":"), + value[value.index(after: colon)...].allSatisfy(\.isNumber) { + return String(value[.. String { + let message = rawMessage? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + + if message.isEmpty || message == "cancelled" || message == "canceled" { + return "The connection stopped before it finished. Make sure T3 Code is running, then try again." + } + if message.contains("expired") || message.contains("invalid_credential") + || message.contains("invalid credential") || message.contains("401") { + return "That pairing code is invalid or has expired. Create a new code on the host and try again." + } + if message.contains("timed out") || message.contains("timeout") { + return "The server took too long to respond. Check the address and that both devices are online." + } + if message.contains("offline") || message.contains("network") + || message.contains("could not connect") || message.contains("not connected") { + return "This iPhone could not reach the server. Check the address and network, then try again." + } + return "T3 Code could not complete pairing. Check the server address and use a fresh pairing code." + } +} diff --git a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift new file mode 100644 index 000000000000..46f0bcf60bf7 --- /dev/null +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -0,0 +1,717 @@ +import SwiftUI +import UIKit + +public struct ConnectionOnboardingView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable private var model: FeatureRootModel + + private let readinessChecker: any ConnectionReadinessChecking + private let onConnected: @MainActor () -> Void + private let onCancel: (@MainActor () -> Void)? + private let showsT3ConnectOption: Bool + + @State private var stage = ConnectionStage.welcome + @State private var endpoint = "" + @State private var pairingCode = "" + @State private var errorMessage: String? + @State private var showsPermissionAction = false + @State private var showingScanner = false + @State private var entryHeading = "Connect manually" + @State private var connectionReturnStage = ConnectionStage.details + @State private var connectionTask: Task? + @State private var connectionAttemptID: UUID? + @FocusState private var focusedField: ConnectionField? + + public init( + model: FeatureRootModel, + showsT3ConnectOption: Bool = true, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + readinessChecker = LocalNetworkAccessChecker() + self.showsT3ConnectOption = showsT3ConnectOption + self.onConnected = onConnected + self.onCancel = onCancel + } + + init( + model: FeatureRootModel, + readinessChecker: any ConnectionReadinessChecking, + showsT3ConnectOption: Bool = true, + onConnected: @escaping @MainActor () -> Void = {}, + onCancel: (@MainActor () -> Void)? = nil + ) { + self.model = model + self.readinessChecker = readinessChecker + self.showsT3ConnectOption = showsT3ConnectOption + self.onConnected = onConnected + self.onCancel = onCancel + } + + public var body: some View { + NavigationStack { + Group { + switch stage { + case .welcome: + welcomeView + case .details: + detailsView + case .checking, .connecting: + progressView + case .success: + successView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background.ignoresSafeArea()) + .animation(.snappy(duration: 0.24), value: stage) + .toolbarBackground(T3Colors.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar { + if stage == .welcome, let onCancel { + ToolbarItem(placement: .cancellationAction) { + Button("Close", action: onCancel) + .accessibilityIdentifier("connection-onboarding-close") + } + } else if stage == .checking || stage == .connecting { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + cancelConnectionAttempt() + model.errorMessage = nil + stage = connectionReturnStage + } + .accessibilityIdentifier("connection-onboarding-cancel") + } + } + } + } + .fullScreenCover(isPresented: $showingScanner) { + QRCodeScannerView( + onScan: { value in + showingScanner = false + applyConnectionString( + value, + heading: "Confirm connection", + connectAutomatically: true + ) + }, + onCancel: { + showingScanner = false + }, + onPaste: { + showingScanner = false + pasteConnectionLink() + } + ) + } + .onOpenURL { url in + applyConnectionString(url.absoluteString, heading: "Confirm connection") + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active, showsPermissionAction { + showsPermissionAction = false + errorMessage = nil + } + } + .interactiveDismissDisabled(stage == .checking || stage == .connecting) + .onDisappear { + cancelConnectionAttempt() + } + } + + private var welcomeView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 36) + + Text("T3") + .font(.system(size: 34, weight: .black, design: .rounded)) + .foregroundStyle(Color(red: 0.02, green: 0.74, blue: 0.5)) + .accessibilityLabel("T3 Code") + + Text("Connect an environment") + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 20) + + Text("Choose how to connect to T3 Code.") + .font(.body) + .foregroundStyle(T3Colors.textSecondary) + .padding(.top, 8) + + if let errorMessage { + connectionError(message: errorMessage) + .padding(.top, 20) + } + + if showsT3ConnectOption, + let capability = model.client as? any T3ConnectCapable, + capability.t3ConnectController.unavailableReason == nil { + NavigationLink { + T3ConnectView(capability: capability, model: model) { + await model.reloadAfterConnection() + onConnected() + } + } label: { + Label("T3 Connect", systemImage: "cloud") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .padding(.top, 32) + .accessibilityHint("Sign in to connect a linked environment") + .accessibilityIdentifier("connection-onboarding-t3-connect") + } + + VStack(spacing: 0) { + connectionAction( + title: "Scan QR code", + subtitle: "Use the code on your computer", + systemImage: "qrcode.viewfinder" + ) { + errorMessage = nil + showsPermissionAction = false + showingScanner = true + } + + Divider().overlay(T3Colors.border) + + connectionAction( + title: "Paste connection link", + subtitle: "Use a link from your computer", + systemImage: "doc.on.clipboard" + ) { + pasteConnectionLink() + } + + Divider().overlay(T3Colors.border) + + connectionAction( + title: "Enter details", + subtitle: "Use an address and pairing code", + systemImage: "keyboard" + ) { + entryHeading = "Connect manually" + errorMessage = nil + showsPermissionAction = false + stage = .details + } + } + .padding(.top, showsT3ConnectOption ? 20 : 28) + + knownEnvironments + } + .padding(.horizontal, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + } + + @ViewBuilder + private var knownEnvironments: some View { + if !knownEnvironmentValues.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Text("Saved environments") + .font(.headline) + .foregroundStyle(T3Colors.textPrimary) + .padding(.top, 36) + .padding(.bottom, 8) + + ForEach(knownEnvironmentValues) { environment in + Button { + connect( + .activate( + id: environment.id, + endpoint: environment.endpoint + ) + ) + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.body) + .foregroundStyle(.secondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .font(T3Typography.threadBody) + .foregroundStyle(.primary) + Text(environment.endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer() + Image(systemName: "arrow.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .accessibilityLabel(environment.name) + .accessibilityValue(environment.endpoint) + .accessibilityHint("Connects to this saved environment") + + if environment.id != knownEnvironmentValues.last?.id { + Divider().overlay(T3Colors.border) + } + } + } + } + } + + private var knownEnvironmentValues: [FeatureEnvironment] { + guard showsT3ConnectOption else { return [] } + return model.snapshot.environments + } + + private var detailsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(entryHeading) + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + Text("Find these details in T3 Code on your computer.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Server address") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + TextField( + "Server address", + text: $endpoint, + prompt: Text("http://192.168.1.5:3773") + .foregroundStyle(T3Colors.placeholder) + ) + .textInputAutocapitalization(.never) + .keyboardType(.URL) + .autocorrectionDisabled() + .focused($focusedField, equals: .endpoint) + .connectionInput() + .accessibilityLabel("Server address") + .accessibilityIdentifier("connection-onboarding-address") + .submitLabel(.next) + .onSubmit { focusedField = .pairingCode } + .onChange(of: endpoint) { _, value in + autofillIfPairingLink(value) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Pairing code") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + + TextField( + "Pairing code", + text: $pairingCode, + prompt: Text("Enter pairing code") + .foregroundStyle(T3Colors.placeholder) + ) + .textInputAutocapitalization(.never) + .textContentType(.oneTimeCode) + .autocorrectionDisabled() + .focused($focusedField, equals: .pairingCode) + .connectionInput() + .privacySensitive() + .accessibilityLabel("Pairing code") + .accessibilityIdentifier("connection-onboarding-pairing-code") + .submitLabel(.go) + .onSubmit { + if canSubmit { submitDetails() } + } + } + + Button { + pasteConnectionLink() + } label: { + Label("Paste connection link", systemImage: "doc.on.clipboard") + .font(T3Typography.control.weight(.semibold)) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget, alignment: .leading) + .accessibilityIdentifier("connection-onboarding-paste-link") + + if let errorMessage { + connectionError(message: errorMessage) + } + + Button { + submitDetails() + } label: { + Text("Connect") + .frame(maxWidth: .infinity) + } + .buttonStyle(ConnectionPrimaryButtonStyle()) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.45) + .accessibilityIdentifier("connection-onboarding-submit") + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 40) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Add environment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + errorMessage = nil + showsPermissionAction = false + focusedField = nil + stage = .welcome + } label: { + Label("Back", systemImage: "chevron.left") + } + } + } + } + + private var progressView: some View { + VStack(alignment: .leading, spacing: 34) { + Spacer() + + Text(stage == .checking ? "Checking connection" : "Connecting") + .font(.largeTitle.bold()) + .foregroundStyle(T3Colors.textPrimary) + + VStack(alignment: .leading, spacing: 20) { + progressRow( + title: "Server address", + state: .complete + ) + progressRow( + title: EndpointNetworkScope.isLocal(endpoint) + ? "Local network access" + : "Network access", + state: stage == .checking ? .active : .complete + ) + progressRow( + title: "Pairing", + state: stage == .connecting ? .active : .waiting + ) + } + + Spacer() + + Text(endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + .padding(.horizontal, 32) + .padding(.vertical, 28) + .frame(maxWidth: 520) + .accessibilityElement(children: .contain) + } + + private var successView: some View { + VStack(spacing: 18) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 54)) + .foregroundStyle(.green) + Text("Connected") + .font(.title.bold()) + Text("Loading your projects.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityElement(children: .combine) + } + + private func connectionAction( + title: String, + subtitle: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .medium)) + .frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.body.weight(.semibold)) + Text(subtitle) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .contentShape(Rectangle()) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityElement(children: .combine) + } + + private func connectionError(message: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(message, systemImage: showsPermissionAction ? "network.slash" : "exclamationmark.circle") + .font(T3Typography.control) + .foregroundStyle(Color(red: 1, green: 0.58, blue: 0.2)) + + if showsPermissionAction { + Button("Open Settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .font(T3Typography.control.weight(.semibold)) + } + } + .accessibilityElement(children: .contain) + } + + private func progressRow(title: String, state: ProgressRowState) -> some View { + HStack(spacing: 14) { + Group { + switch state { + case .complete: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + case .active: + ProgressView() + .controlSize(.small) + case .waiting: + Image(systemName: "circle") + .foregroundStyle(.tertiary) + } + } + .frame(width: 22) + + Text(title) + .font(.body.weight(state == .active ? .semibold : .regular)) + .foregroundStyle(state == .waiting ? .secondary : .primary) + } + } + + private var canSubmit: Bool { + !endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !pairingCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + private func submitDetails() { + do { + let normalized = try ConnectionDetailsParser.normalizedEndpoint(endpoint) + endpoint = normalized + errorMessage = nil + showsPermissionAction = false + focusedField = nil + connect( + .pair( + endpoint: normalized, + code: pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + ) + ) + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func pasteConnectionLink() { + guard let value = UIPasteboard.general.string, !value.isEmpty else { + entryHeading = "Connect manually" + stage = .details + errorMessage = "Copy a T3 pairing link first, or enter the details below." + focusedField = .endpoint + return + } + applyConnectionString(value, heading: "Confirm connection") + } + + @MainActor + private func applyConnectionString( + _ value: String, + heading: String, + connectAutomatically: Bool = false + ) { + cancelConnectionAttempt() + do { + let details = try ConnectionDetailsParser.parse(value) + endpoint = details.endpoint + pairingCode = details.pairingCode ?? "" + entryHeading = heading + errorMessage = details.pairingCode == nil + ? "The link did not include a pairing code. Enter it below." + : nil + showsPermissionAction = false + if connectAutomatically, let code = details.pairingCode { + focusedField = nil + connect(.pair(endpoint: details.endpoint, code: code)) + } else { + stage = .details + focusedField = details.pairingCode == nil ? .pairingCode : nil + } + } catch { + entryHeading = "Connect manually" + errorMessage = error.localizedDescription + stage = .details + focusedField = .endpoint + } + } + + @MainActor + private func autofillIfPairingLink(_ value: String) { + guard let details = try? ConnectionDetailsParser.parse(value), + let code = details.pairingCode + else { + return + } + endpoint = details.endpoint + pairingCode = code + errorMessage = nil + focusedField = nil + } + + @MainActor + private func connect(_ action: ConnectionAction) { + cancelConnectionAttempt() + let attemptID = UUID() + connectionAttemptID = attemptID + switch action { + case .pair: + connectionReturnStage = .details + case .activate: + connectionReturnStage = .welcome + } + endpoint = action.endpoint + errorMessage = nil + showsPermissionAction = false + stage = .checking + + connectionTask = Task { + let readiness = await readinessChecker.check(endpoint: action.endpoint) + guard !Task.isCancelled, connectionAttemptID == attemptID else { return } + switch readiness { + case .ready: + stage = .connecting + case .localNetworkDenied: + errorMessage = "Allow Local Network access to connect to this environment." + showsPermissionAction = true + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + return + case .unreachable: + errorMessage = "Cannot reach this environment. Check the address and network connection." + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + return + } + + model.errorMessage = nil + let didConnect: Bool + switch action { + case let .pair(endpoint, code): + didConnect = await model.pair(endpoint: endpoint, token: code) + case let .activate(id, _): + didConnect = await model.setEnvironmentEnabled(id, enabled: true) + } + guard !Task.isCancelled, connectionAttemptID == attemptID else { return } + + if didConnect { + connectionAttemptID = nil + connectionTask = nil + stage = .success + onConnected() + } else { + let rawError = model.errorMessage + model.errorMessage = nil + errorMessage = ConnectionErrorCopy.message(for: rawError) + connectionAttemptID = nil + connectionTask = nil + stage = connectionReturnStage + } + } + } + + @MainActor + private func cancelConnectionAttempt() { + connectionTask?.cancel() + connectionTask = nil + connectionAttemptID = nil + } +} + +private enum ConnectionStage: Equatable { + case welcome + case details + case checking + case connecting + case success +} + +private enum ConnectionField: Hashable { + case endpoint + case pairingCode +} + +private enum ProgressRowState { + case complete + case active + case waiting +} + +private enum ConnectionAction { + case pair(endpoint: String, code: String) + case activate(id: String, endpoint: String) + + var endpoint: String { + switch self { + case let .pair(endpoint, _), let .activate(_, endpoint): + endpoint + } + } +} + +private extension View { + func connectionInput() -> some View { + self + .font(.body.monospaced()) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 14) + .frame(minHeight: 50) + .background(T3Colors.input) + .overlay(alignment: .bottom) { + Rectangle() + .fill(T3Colors.inputBorder) + .frame(height: 1) + } + } +} + +private struct ConnectionPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.primaryActionForeground) + .padding(.horizontal, 16) + .frame(minHeight: 52) + .background( + configuration.isPressed + ? T3Colors.primaryAction.opacity(0.76) + : T3Colors.primaryAction + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} diff --git a/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift b/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift new file mode 100644 index 000000000000..2adcf72b2f22 --- /dev/null +++ b/apps/swift-ios/Features/Connection/LocalNetworkAccessChecker.swift @@ -0,0 +1,112 @@ +import Foundation +import Network + +enum ConnectionReadiness: Equatable, Sendable { + case ready + case localNetworkDenied + case unreachable +} + +protocol ConnectionReadinessChecking: Sendable { + func check(endpoint: String) async -> ConnectionReadiness +} + +struct LocalNetworkAccessChecker: ConnectionReadinessChecking { + func check(endpoint: String) async -> ConnectionReadiness { + guard EndpointNetworkScope.isLocal(endpoint) else { return .ready } + guard let components = URLComponents(string: endpoint), + let hostname = components.host + else { + return .unreachable + } + let portNumber = components.port + ?? (components.scheme?.lowercased() == "https" ? 443 : 80) + guard + let rawPort = UInt16(exactly: portNumber), + let port = NWEndpoint.Port(rawValue: rawPort) + else { + return .unreachable + } + + return await withCheckedContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(hostname), + port: port, + using: .tcp + ) + let completion = ConnectionProbeCompletion( + connection: connection, + continuation: continuation + ) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + completion.finish(.ready) + case .waiting(let error), .failed(let error): + if connection.currentPath?.unsatisfiedReason == .localNetworkDenied + || error.indicatesPermissionDenied { + completion.finish(.localNetworkDenied) + } else if case .failed = state { + completion.finish(.unreachable) + } + case .cancelled: + completion.finish(.unreachable) + default: + break + } + } + + connection.start(queue: .global(qos: .userInitiated)) + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 6) { + if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { + completion.finish(.localNetworkDenied) + } else { + completion.finish(.unreachable) + } + } + } + } +} + +private final class ConnectionProbeCompletion: @unchecked Sendable { + private let lock = NSLock() + private var didFinish = false + private let connection: NWConnection + private var continuation: CheckedContinuation? + + init( + connection: NWConnection, + continuation: CheckedContinuation + ) { + self.connection = connection + self.continuation = continuation + } + + func finish(_ result: ConnectionReadiness) { + lock.lock() + guard !didFinish else { + lock.unlock() + return + } + didFinish = true + let continuation = continuation + self.continuation = nil + lock.unlock() + + connection.stateUpdateHandler = nil + connection.cancel() + continuation?.resume(returning: result) + } +} + +private extension NWError { + var indicatesPermissionDenied: Bool { + switch self { + case .posix(.EACCES), .posix(.EPERM): + true + default: + false + } + } +} diff --git a/apps/swift-ios/Features/Connection/QRCodeScannerView.swift b/apps/swift-ios/Features/Connection/QRCodeScannerView.swift new file mode 100644 index 000000000000..b00720b88c5d --- /dev/null +++ b/apps/swift-ios/Features/Connection/QRCodeScannerView.swift @@ -0,0 +1,332 @@ +@preconcurrency import AVFoundation +import SwiftUI +import UIKit + +struct QRCodeScannerView: View { + let onScan: (String) -> Void + let onCancel: () -> Void + let onPaste: () -> Void + + @State private var availability: QRScannerAvailability = .checking + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + QRScannerCameraView( + availability: $availability, + onScan: onScan + ) + .ignoresSafeArea() + + LinearGradient( + colors: [.black.opacity(0.7), .clear, .black.opacity(0.8)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .allowsHitTesting(false) + + VStack(spacing: 0) { + HStack { + Button("Cancel", action: onCancel) + .font(.body.weight(.semibold)) + Spacer() + Text("Scan QR code") + .font(.headline) + Spacer() + Button("Cancel", action: onCancel) + .hidden() + } + .padding(.horizontal, 20) + .frame(height: 56) + + Spacer() + + if availability == .ready { + scannerFrame + Text("Point your camera at the QR code shown by T3 Code.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + .multilineTextAlignment(.center) + .padding(.top, 28) + } else { + unavailableContent + } + + Spacer() + + Button { + onPaste() + } label: { + Label("Paste connection link", systemImage: "doc.on.clipboard") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(minHeight: 50) + } + .buttonStyle(.bordered) + .tint(.white) + .padding(.horizontal, 28) + .padding(.bottom, 24) + } + } + .preferredColorScheme(.dark) + } + + private var scannerFrame: some View { + RoundedRectangle(cornerRadius: 24) + .stroke(Color.white.opacity(0.9), lineWidth: 3) + .frame(width: 252, height: 252) + .overlay(alignment: .topLeading) { + Image(systemName: "viewfinder") + .resizable() + .frame(width: 282, height: 282) + .offset(x: -15, y: -15) + .foregroundStyle(.white) + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var unavailableContent: some View { + switch availability { + case .checking: + ProgressView() + .controlSize(.large) + .accessibilityLabel("Checking camera") + case .ready: + EmptyView() + case .denied: + VStack(spacing: 14) { + Image(systemName: "camera.fill") + .font(.system(size: 34)) + Text("Camera access is off") + .font(.title3.bold()) + Text("Allow camera access in Settings to scan a pairing code.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + .multilineTextAlignment(.center) + Button("Open Settings") { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(.black) + } + .padding(.horizontal, 36) + case .unavailable: + VStack(spacing: 14) { + Image(systemName: "camera.slash.fill") + .font(.system(size: 34)) + Text("Camera unavailable") + .font(.title3.bold()) + Text("Paste the connection link instead.") + .font(T3Typography.threadBody) + .foregroundStyle(.white.opacity(0.78)) + } + } + } +} + +private enum QRScannerAvailability: Equatable { + case checking + case ready + case denied + case unavailable +} + +private struct QRScannerCameraView: UIViewControllerRepresentable { + @Binding var availability: QRScannerAvailability + let onScan: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(availability: $availability, onScan: onScan) + } + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + context.coordinator.attach(to: controller) + return controller + } + + func updateUIViewController(_ controller: QRScannerViewController, context: Context) {} + + static func dismantleUIViewController( + _ controller: QRScannerViewController, + coordinator: Coordinator + ) { + controller.stop() + } + + @MainActor + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var availability: Binding + private let onScan: (String) -> Void + private weak var controller: QRScannerViewController? + private var didScan = false + + init( + availability: Binding, + onScan: @escaping (String) -> Void + ) { + self.availability = availability + self.onScan = onScan + } + + func attach(to controller: QRScannerViewController) { + self.controller = controller + controller.prepare(delegate: self) { [weak self] nextAvailability in + self?.availability.wrappedValue = nextAvailability + } + } + + nonisolated func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)? + .stringValue + else { + return + } + Task { @MainActor [weak self] in + guard let self, !didScan else { return } + didScan = true + controller?.stop() + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScan(value) + } + } + } +} + +@MainActor +private final class QRScannerViewController: UIViewController { + private let captureSession = AVCaptureSession() + private let sessionQueue = DispatchQueue(label: "codes.t3.swift-ios.qr-scanner") + private var previewLayer: AVCaptureVideoPreviewLayer? + private var metadataDelegate: AVCaptureMetadataOutputObjectsDelegate? + private var availabilityChanged: (@MainActor (QRScannerAvailability) -> Void)? + private var isConfiguring = false + private var isRequestingAuthorization = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + } + + // SwiftUI does not reliably dismantle a representable the moment its + // fullScreenCover dismisses, and backgrounding never dismantles it, so + // stop the camera on disappear and resume it when the view returns. + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + stop() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard previewLayer != nil else { + refreshAuthorization() + return + } + let session = captureSession + sessionQueue.async { + if !session.isRunning { + session.startRunning() + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + func prepare( + delegate: AVCaptureMetadataOutputObjectsDelegate, + availabilityChanged: @escaping @MainActor (QRScannerAvailability) -> Void + ) { + metadataDelegate = delegate + self.availabilityChanged = availabilityChanged + refreshAuthorization() + } + + private func refreshAuthorization() { + guard let metadataDelegate, let availabilityChanged else { return } + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configure(delegate: metadataDelegate, availabilityChanged: availabilityChanged) + case .notDetermined: + guard !isRequestingAuthorization else { return } + isRequestingAuthorization = true + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + Task { @MainActor in + guard let self else { return } + self.isRequestingAuthorization = false + guard granted else { + self.availabilityChanged?(.denied) + return + } + self.refreshAuthorization() + } + } + case .denied, .restricted: + availabilityChanged(.denied) + @unknown default: + availabilityChanged(.unavailable) + } + } + + func stop() { + let session = captureSession + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } + } + + private func configure( + delegate: AVCaptureMetadataOutputObjectsDelegate, + availabilityChanged: @escaping @MainActor (QRScannerAvailability) -> Void + ) { + guard previewLayer == nil, !isConfiguring else { return } + isConfiguring = true + defer { isConfiguring = false } + guard let camera = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera), + captureSession.canAddInput(input) + else { + availabilityChanged(.unavailable) + return + } + + let output = AVCaptureMetadataOutput() + guard captureSession.canAddOutput(output) else { + availabilityChanged(.unavailable) + return + } + + captureSession.beginConfiguration() + captureSession.sessionPreset = .high + captureSession.addInput(input) + captureSession.addOutput(output) + output.setMetadataObjectsDelegate(delegate, queue: .main) + output.metadataObjectTypes = [.qr] + captureSession.commitConfiguration() + + let preview = AVCaptureVideoPreviewLayer(session: captureSession) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + view.layer.insertSublayer(preview, at: 0) + previewLayer = preview + availabilityChanged(.ready) + + let session = captureSession + sessionQueue.async { + session.startRunning() + } + } +} diff --git a/apps/swift-ios/Features/Connection/T3ConnectView.swift b/apps/swift-ios/Features/Connection/T3ConnectView.swift new file mode 100644 index 000000000000..d4c28399b285 --- /dev/null +++ b/apps/swift-ios/Features/Connection/T3ConnectView.swift @@ -0,0 +1,668 @@ +import ClerkKit +import ClerkKitUI +import SwiftUI + +public struct T3ConnectView: View { + public enum Purpose: Sendable { + case connect + case manage + } + + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable private var controller: T3ConnectController + @State private var isAuthPresented = false + @State private var didFinishInitialRefresh = false + @State private var connectingEnvironmentID: String? + @State private var isSigningOut = false + private let connectEnvironment: + @MainActor (T3ConnectManagedEnvironmentCredential) async throws -> Void + private let signOut: @MainActor () async -> Void + private let onConnected: @MainActor () async -> Void + private let onUnlinked: @MainActor (String) async -> Void + private let purpose: Purpose + + public init( + capability: any T3ConnectCapable, + model: FeatureRootModel? = nil, + purpose: Purpose = .connect, + onConnected: @escaping @MainActor () async -> Void = {}, + onUnlinked: @escaping @MainActor (String) async -> Void = { _ in } + ) { + controller = capability.t3ConnectController + connectEnvironment = capability.connectT3Environment + signOut = if let model { + model.signOutT3Connect + } else { + capability.signOutT3Connect + } + self.purpose = purpose + self.onConnected = onConnected + self.onUnlinked = onUnlinked + } + + public var body: some View { + content + .navigationTitle("T3 Connect") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(T3Colors.background, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .refreshable { + await controller.refresh() + } + .task { + await controller.refresh() + guard !Task.isCancelled else { return } + didFinishInitialRefresh = true + presentAuthenticationIfNeeded() + } + .onChange(of: controller.account?.id) { _, accountID in + guard didFinishInitialRefresh, + !isSigningOut, + accountID == nil, + controller.unavailableReason == nil else { return } + isAuthPresented = true + } + .fullScreenCover( + isPresented: $isAuthPresented, + onDismiss: handleAuthenticationDismissal + ) { + authenticationView + } + .alert( + "T3 Connect", + isPresented: Binding( + get: { controller.errorMessage != nil }, + set: { if !$0 { controller.errorMessage = nil } } + ) + ) { + Button("OK") { controller.errorMessage = nil } + } message: { + Text(controller.errorMessage ?? "Something went wrong.") + } + } + + @ViewBuilder + private var content: some View { + if let reason = controller.unavailableReason { + connectList { + unavailableSection(reason) + } + } else if let account = controller.account { + connectList { + environmentSection + accountSection(account) + } + } else if isSigningOut { + loadingView("Signing out") + } else if didFinishInitialRefresh { + signedOutView + } else { + loadingView("Checking account") + } + } + + private func connectList( + @ViewBuilder content: () -> Content + ) -> some View { + List { + content() + } + .listStyle(.plain) + .listSectionSpacing(28) + .scrollContentBackground(.hidden) + .background(T3Colors.background.ignoresSafeArea()) + } + + private var signedOutView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Sign in to T3 Connect") + .font(.title2.bold()) + .foregroundStyle(T3Colors.textPrimary) + + Text("Access environments linked to your account.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + + Button("Sign in") { + isAuthPresented = true + } + .font(T3Typography.control.weight(.semibold)) + .frame(minHeight: T3Metrics.minimumTapTarget) + .padding(.top, 4) + .accessibilityIdentifier("t3-connect-sign-in") + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(24) + .background(T3Colors.background.ignoresSafeArea()) + } + + private func loadingView(_ message: String) -> some View { + VStack(spacing: 12) { + ProgressView() + .tint(T3Colors.textPrimary) + Text(message) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background.ignoresSafeArea()) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var authenticationView: some View { + if let clerk = controller.clerk { + T3ConnectAuthenticationView { + await controller.refreshAfterAuthentication() + if controller.account != nil { + isAuthPresented = false + return true + } + return false + } + .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) + .environment(clerk) + } else { + loadingView("Loading sign-in") + } + } + + private func presentAuthenticationIfNeeded() { + guard controller.unavailableReason == nil, + controller.account == nil else { return } + isAuthPresented = true + } + + private func handleAuthenticationDismissal() { + Task { + await controller.refresh() + if controller.account == nil { + dismiss() + } + } + } + + private func unavailableSection(_ reason: String) -> some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Label("T3 Connect unavailable", systemImage: "cloud.slash") + .font(T3Typography.homeTitle) + Text(reason) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + Text("You can still connect directly.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.vertical, 8) + .listRowBackground(T3Colors.background) + } + } + + private func accountSection(_ account: T3ConnectAccount) -> some View { + Section("Account") { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.fill") + .font(.title2) + .foregroundStyle(T3Colors.textSecondary) + VStack(alignment: .leading, spacing: 2) { + Text(account.email ?? "T3 account") + .font(T3Typography.homeTitle) + Text("Signed in") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(.vertical, 3) + .listRowBackground(T3Colors.background) + .accessibilityElement(children: .combine) + + Button(role: .destructive) { + handleSignOut() + } label: { + HStack { + Text("Sign out") + Spacer() + if isSigningOut { + ProgressView() + .controlSize(.small) + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + } + .disabled(controller.isRefreshing || isSigningOut || connectingEnvironmentID != nil) + .listRowBackground(T3Colors.background) + .accessibilityIdentifier("t3-connect-sign-out") + } + } + + private var environmentSection: some View { + Section("Environments") { + if controller.environments.isEmpty, !controller.isRefreshing { + VStack(alignment: .leading, spacing: 8) { + Text("No linked environments") + .font(T3Typography.homeTitle) + Text("Link an environment in T3 Code on your computer.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + Button("Refresh") { + Task { await controller.refresh() } + } + .font(T3Typography.control) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("t3-connect-refresh") + } + .padding(.top, 8) + .listRowBackground(T3Colors.background) + } + + ForEach(controller.environments) { item in + environmentRow(item) + .listRowBackground(T3Colors.background) + .swipeActions { + Button(role: .destructive) { + Task { + if await controller.unlink(item.environment) { + await onUnlinked(item.id) + } + } + } label: { + Label("Unlink", systemImage: "link.badge.minus") + } + } + } + + if controller.isRefreshing { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking environments") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .listRowBackground(T3Colors.background) + .accessibilityElement(children: .combine) + } + } + } + + private func environmentRow(_ item: T3ConnectCloudEnvironment) -> some View { + HStack(spacing: 12) { + Circle() + .fill(statusColor(item)) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text(item.environment.label) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Text(statusText(item)) + .font(T3Typography.supporting) + .foregroundStyle( + item.statusError == nil ? T3Colors.textSecondary : T3Colors.danger + ) + .lineLimit(2) + } + .accessibilityElement(children: .combine) + + Spacer(minLength: 8) + + if purpose == .connect { + Button { + Task { await handleConnect(item.environment) } + } label: { + if controller.busyEnvironmentID == item.id + || connectingEnvironmentID == item.id { + ProgressView() + .frame(width: 54) + .accessibilityLabel("Connecting to \(item.environment.label)") + } else { + Text("Connect") + .font(T3Typography.supportingStrong) + } + } + .buttonStyle(.borderless) + .frame(minHeight: T3Metrics.minimumTapTarget) + .disabled( + controller.busyEnvironmentID != nil + || connectingEnvironmentID != nil + || item.status?.status == .offline + ) + .accessibilityLabel("Connect to \(item.environment.label)") + .accessibilityHint(item.status?.status == .offline ? "Environment is offline" : "") + .accessibilityIdentifier("t3-connect-environment-\(item.id)") + } + } + .padding(.vertical, 5) + } + + private func handleSignOut() { + guard !isSigningOut else { return } + isSigningOut = true + Task { + await signOut() + isSigningOut = false + if controller.account == nil { + dismiss() + } + } + } + + private func handleConnect(_ environment: T3ConnectRelayEnvironment) async { + guard connectingEnvironmentID == nil else { return } + connectingEnvironmentID = environment.environmentId + defer { + if connectingEnvironmentID == environment.environmentId { + connectingEnvironmentID = nil + } + } + do { + let credential = try await controller.credential(for: environment) + try await connectEnvironment(credential) + await onConnected() + } catch { + controller.errorMessage = error.localizedDescription + } + } + + private func statusText(_ item: T3ConnectCloudEnvironment) -> String { + if let error = item.statusError { return error } + switch item.status?.status { + case .online: return "Online" + case .offline: return item.status?.error ?? "Offline" + case nil: return "Checking" + } + } + + private func statusColor(_ item: T3ConnectCloudEnvironment) -> Color { + if item.statusError != nil { + return T3Colors.danger + } + return switch item.status?.status { + case .online: T3Colors.success + case .offline: T3Colors.danger + case nil: T3Colors.textTertiary + } + } +} + +@MainActor +private struct T3ConnectAuthenticationView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @SwiftUI.Environment(Clerk.self) private var clerk + @State private var activeProvider: OAuthProvider? + @State private var errorMessage: String? + @State private var isEmailPresented = false + + private let onAuthenticationChanged: @MainActor () async -> Bool + private let preferredProviders: [OAuthProvider] = [ + .apple, + .github, + .google, + .microsoft, + ] + + init( + onAuthenticationChanged: @escaping @MainActor () async -> Bool + ) { + self.onAuthenticationChanged = onAuthenticationChanged + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + brand + .padding(.bottom, 38) + + Text("Sign in to T3 Connect") + .font(.system(.largeTitle, design: .default, weight: .bold)) + .foregroundStyle(T3Colors.textPrimary) + .padding(.bottom, 10) + + Text("Access your linked environments.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 34) + + providerButtons + + emailButton + .padding(.top, 24) + } + .frame(maxWidth: 440, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 34) + .padding(.bottom, 40) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .background(T3Colors.background.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + .accessibilityIdentifier("t3-connect-auth-close") + } + } + .toolbarBackground(.hidden, for: .navigationBar) + } + .task { + if clerk.environment == nil { + _ = try? await clerk.refreshEnvironment() + } + } + .sheet(isPresented: $isEmailPresented, onDismiss: authenticationDidFinish) { + AuthView(mode: .signInOrUp) + .prefetchClerkImages() + .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) + .environment(clerk) + } + .alert( + "Couldn’t sign in", + isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + ) + ) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "Please try again.") + } + } + + private var brand: some View { + HStack(spacing: 10) { + Text("T3") + .font(.system(size: 14, weight: .heavy, design: .rounded)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(width: 32, height: 32) + .background(T3Colors.primaryAction) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + + Text("T3 Connect") + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + } + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var providerButtons: some View { + if clerk.environment == nil { + HStack(spacing: 10) { + ProgressView() + .tint(T3Colors.textPrimary) + Text("Loading sign-in options") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, minHeight: 56) + } else { + VStack(spacing: 12) { + ForEach(availableProviders) { provider in + providerButton(provider) + } + } + } + } + + private func providerButton(_ provider: OAuthProvider) -> some View { + Button { + Task { await signIn(with: provider) } + } label: { + HStack(spacing: 12) { + T3ConnectAuthProviderIcon(provider: provider) + .frame(width: 22, height: 22) + + Text("Continue with \(provider.name)") + .font(.system(.body, design: .default, weight: .semibold)) + .foregroundStyle(T3Colors.textPrimary) + + Spacer(minLength: 8) + + if activeProvider == provider { + ProgressView() + .tint(T3Colors.textPrimary) + } + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, minHeight: 56) + .background(T3Colors.surfaceRaised) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(T3Colors.border, lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(activeProvider != nil) + .opacity(activeProvider == nil || activeProvider == provider ? 1 : 0.55) + .accessibilityIdentifier("t3-connect-auth-\(provider.strategy)") + } + + private var emailButton: some View { + Button { + isEmailPresented = true + } label: { + HStack(spacing: 10) { + Image(systemName: "envelope") + .font(.system(size: 15, weight: .medium)) + Text(availableProviders.isEmpty ? "Continue with email" : "Use email") + .font(T3Typography.control) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(activeProvider != nil) + .accessibilityIdentifier("t3-connect-auth-email") + } + + private var availableProviders: [OAuthProvider] { + guard let environment = clerk.environment else { return [] } + let enabledStrategies = Set( + environment.userSettings.social.values + .filter { $0.enabled && $0.authenticatable } + .map(\.strategy) + ) + return preferredProviders.filter { enabledStrategies.contains($0.strategy) } + } + + private func signIn(with provider: OAuthProvider) async { + activeProvider = provider + defer { activeProvider = nil } + + do { + if provider == .apple { + try await clerk.auth.signInWithApple() + } else { + try await clerk.auth.signInWithOAuth(provider: provider) + } + + if !(await onAuthenticationChanged()) { + isEmailPresented = true + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func authenticationDidFinish() { + Task { _ = await onAuthenticationChanged() } + } +} + +private struct T3ConnectAuthProviderIcon: View { + let provider: OAuthProvider + + @ViewBuilder + var body: some View { + switch provider { + case .apple: + Image(systemName: "apple.logo") + .resizable() + .scaledToFit() + .foregroundStyle(T3Colors.textPrimary) + case .github: + Image("AuthGitHub") + .resizable() + .scaledToFit() + case .google: + Image("AuthGoogle") + .resizable() + .scaledToFit() + case .microsoft: + Image("AuthMicrosoft") + .resizable() + .scaledToFit() + default: + Image(systemName: "person.crop.circle") + .resizable() + .scaledToFit() + .foregroundStyle(T3Colors.textPrimary) + } + } +} + +@MainActor +private enum T3ConnectClerkAppearance { + static let theme = ClerkTheme( + colors: .init( + primary: T3Colors.primaryAction, + background: T3Colors.background, + input: T3Colors.input, + danger: T3Colors.danger, + success: T3Colors.success, + warning: T3Colors.warning, + foreground: T3Colors.textPrimary, + mutedForeground: T3Colors.textSecondary, + primaryForeground: T3Colors.primaryActionForeground, + inputForeground: T3Colors.textPrimary, + neutral: T3Colors.textPrimary, + ring: T3Colors.textPrimary, + muted: T3Colors.surfaceRaised, + shadow: T3Colors.border, + border: T3Colors.border + ), + design: .init(borderRadius: 12) + ) +} diff --git a/apps/swift-ios/Features/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift new file mode 100644 index 000000000000..59a2a80a612e --- /dev/null +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +public struct DevicesView: View { + private let manager: any FeatureDeviceManaging + + @State private var sessions: [FeatureDeviceSession] = [] + @State private var isLoading = true + @State private var isRevoking = false + @State private var errorMessage: String? + @State private var revokeTarget: FeatureDeviceSession? + @State private var showingRevokeOthers = false + + public init(manager: any FeatureDeviceManaging) { + self.manager = manager + } + + public var body: some View { + Group { + if isLoading, sessions.isEmpty { + VStack(spacing: 12) { + ProgressView() + Text("Loading devices") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } else if let errorMessage, sessions.isEmpty { + ContentUnavailableView { + Label("Couldn’t load devices", systemImage: "exclamationmark.circle") + } description: { + Text(errorMessage) + } actions: { + Button("Try again") { + Task { await reload() } + } + .buttonStyle(.borderedProminent) + } + } else if sessions.isEmpty { + ContentUnavailableView { + Label("No devices found", systemImage: "laptopcomputer.and.iphone") + } description: { + Text("Device sessions will appear here when this server supports access management.") + } + } else { + deviceList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .navigationTitle("Devices") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !otherSessions.isEmpty { + ToolbarItem(placement: .primaryAction) { + Menu { + Button(role: .destructive) { + showingRevokeOthers = true + } label: { + Label("Remove all other devices", systemImage: "rectangle.stack.badge.minus") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(isRevoking) + .accessibilityLabel("Device actions") + } + } + } + .task { + await reload() + } + .alert( + "Remove this device?", + isPresented: Binding( + get: { revokeTarget != nil }, + set: { if !$0 { revokeTarget = nil } } + ), + presenting: revokeTarget + ) { device in + Button(manager.managesServerSessions ? "Remove access" : "Remove device", role: .destructive) { + Task { await revoke(device) } + } + Button("Cancel", role: .cancel) {} + } message: { device in + Text( + manager.managesServerSessions + ? "\(device.displayName) will need a new pairing code to reconnect." + : "\(device.displayName) will stop receiving T3 Connect notifications." + ) + } + .confirmationDialog( + "Remove all other devices?", + isPresented: $showingRevokeOthers, + titleVisibility: .visible + ) { + Button("Remove \(otherSessions.count) devices", role: .destructive) { + Task { await revokeOthers() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + manager.managesServerSessions + ? "Every other phone, tablet, browser, and desktop will be signed out." + : "Other registered devices will stop receiving T3 Connect notifications." + ) + } + } + + private var deviceList: some View { + List { + if let currentSession { + Section("THIS DEVICE") { + DeviceSessionRow(session: currentSession) + } + } + + if !otherSessions.isEmpty { + Section("OTHER DEVICES") { + ForEach(otherSessions) { session in + DeviceSessionRow(session: session) + .contentShape(Rectangle()) + .swipeActions { + Button("Remove", role: .destructive) { + revokeTarget = session + } + } + .contextMenu { + Button(role: .destructive) { + revokeTarget = session + } label: { + Label("Remove access", systemImage: "trash") + } + } + } + } + } + + if let errorMessage { + Section { + VStack(alignment: .leading, spacing: 10) { + Label(errorMessage, systemImage: "exclamationmark.circle") + .font(T3Typography.control) + .foregroundStyle(.orange) + Button("Try again") { + Task { await reload() } + } + .font(T3Typography.control.weight(.semibold)) + } + .padding(.vertical, 4) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .refreshable { + await reload() + } + .overlay(alignment: .top) { + if isRevoking { + ProgressView() + .padding(.top, 12) + .accessibilityLabel("Updating device access") + } + } + } + + private var currentSession: FeatureDeviceSession? { + sessions.first(where: \.isCurrent) + } + + private var otherSessions: [FeatureDeviceSession] { + sessions.filter { !$0.isCurrent } + } + + @MainActor + private func reload() async { + isLoading = true + defer { isLoading = false } + do { + sessions = FeatureDeviceSession.sortedForDisplay( + try await manager.loadDeviceSessions() + ) + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revoke(_ session: FeatureDeviceSession) async { + isRevoking = true + defer { + isRevoking = false + revokeTarget = nil + } + do { + try await manager.revokeDeviceSession(id: session.id) + sessions.removeAll { $0.id == session.id } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } + + @MainActor + private func revokeOthers() async { + isRevoking = true + defer { isRevoking = false } + do { + try await manager.revokeOtherDeviceSessions() + sessions.removeAll { !$0.isCurrent } + errorMessage = nil + } catch { + errorMessage = DeviceManagementErrorCopy.message(for: error) + } + } +} + +private struct DeviceSessionRow: View { + let session: FeatureDeviceSession + + var body: some View { + HStack(alignment: .top, spacing: 13) { + Image(systemName: session.deviceType.systemImage) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(session.isCurrent ? .green : .secondary) + .frame(width: 26, height: 26) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(session.displayName) + .font(T3Typography.homeTitle) + if session.isCurrent { + Text("Current") + .font(T3Typography.supportingStrong) + .foregroundStyle(.green) + } else if session.isConnected { + Text("Online") + .font(T3Typography.supportingStrong) + .foregroundStyle(.green) + } + } + + if !session.platformDescription.isEmpty { + Text(session.platformDescription) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + + Text(lastSeenDescription) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + + if let ipAddress = session.ipAddress, !ipAddress.isEmpty { + Text(ipAddress) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer(minLength: 8) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + private var lastSeenDescription: String { + if session.isConnected { + return "Active now" + } + return "Last seen \(session.lastSeenAt.formatted(.relative(presentation: .named)))" + } +} diff --git a/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift b/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift new file mode 100644 index 000000000000..0117c37c2018 --- /dev/null +++ b/apps/swift-ios/Features/Devices/FeatureDeviceManagement.swift @@ -0,0 +1,147 @@ +import Foundation + +public enum FeatureDeviceType: String, Sendable, Equatable, Codable { + case desktop + case mobile + case tablet + case bot + case unknown + + var displayName: String { + switch self { + case .desktop: "Desktop" + case .mobile: "Phone" + case .tablet: "Tablet" + case .bot: "Automation" + case .unknown: "Device" + } + } + + var systemImage: String { + switch self { + case .desktop: "desktopcomputer" + case .mobile: "iphone" + case .tablet: "ipad" + case .bot: "gearshape.2" + case .unknown: "network" + } + } +} + +public struct FeatureDeviceSession: Identifiable, Sendable, Equatable, Codable { + public var id: String { sessionID } + + public let sessionID: String + public var label: String? + public var deviceType: FeatureDeviceType + public var operatingSystem: String? + public var browser: String? + public var ipAddress: String? + public var issuedAt: Date + public var expiresAt: Date + public var lastConnectedAt: Date? + public var isConnected: Bool + public var isCurrent: Bool + + public init( + sessionID: String, + label: String? = nil, + deviceType: FeatureDeviceType = .unknown, + operatingSystem: String? = nil, + browser: String? = nil, + ipAddress: String? = nil, + issuedAt: Date, + expiresAt: Date, + lastConnectedAt: Date? = nil, + isConnected: Bool = false, + isCurrent: Bool = false + ) { + self.sessionID = sessionID + self.label = label + self.deviceType = deviceType + self.operatingSystem = operatingSystem + self.browser = browser + self.ipAddress = ipAddress + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.lastConnectedAt = lastConnectedAt + self.isConnected = isConnected + self.isCurrent = isCurrent + } + + var displayName: String { + let value = label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !value.isEmpty { + return value + } + return isCurrent ? "This device" : deviceType.displayName + } + + var platformDescription: String { + [operatingSystem, browser] + .compactMap { value in + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + .joined(separator: " · ") + } + + var lastSeenAt: Date { + lastConnectedAt ?? issuedAt + } + + static func sortedForDisplay(_ sessions: [Self]) -> [Self] { + sessions.sorted { left, right in + if left.isCurrent != right.isCurrent { + return left.isCurrent + } + if left.isConnected != right.isConnected { + return left.isConnected + } + return left.lastSeenAt > right.lastSeenAt + } + } +} + +/// The app adapter can opt into access management without making it a requirement +/// for environments that do not grant the access:read/access:write scopes. +@MainActor +public protocol FeatureDeviceManaging: AnyObject { + var managesServerSessions: Bool { get } + func loadDeviceSessions() async throws -> [FeatureDeviceSession] + func revokeDeviceSession(id: String) async throws + func revokeOtherDeviceSessions() async throws +} + +extension FeatureDeviceManaging { + var managesServerSessions: Bool { true } +} + +@MainActor +final class EmptyFeatureDeviceManager: FeatureDeviceManaging { + static let shared = EmptyFeatureDeviceManager() + + private init() {} + + func loadDeviceSessions() async throws -> [FeatureDeviceSession] { + [] + } + + func revokeDeviceSession(id: String) async throws {} + func revokeOtherDeviceSessions() async throws {} +} + +enum DeviceManagementErrorCopy { + static func message(for error: Error) -> String { + let value = error.localizedDescription.lowercased() + if value.contains("scope") || value.contains("403") || value.contains("forbidden") + || value.contains("permission") { + return "This connection does not have permission to manage devices." + } + if value.contains("offline") || value.contains("network") + || value.contains("not connected") || value.contains("timed out") { + return "Device access could not be updated. Check your connection and try again." + } + return "Device access could not be updated. Try again in a moment." + } +} diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift new file mode 100644 index 000000000000..fd35955a6233 --- /dev/null +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -0,0 +1,501 @@ +import ImageIO +import SwiftUI +import UIKit + +public struct FeatureFilesView: View { + let client: any FeatureClient + let threadID: String + let initialPath: String? + let workspaceRoot: String? + + public init( + client: any FeatureClient, + threadID: String, + initialPath: String? = nil, + workspaceRoot: String? = nil + ) { + self.client = client + self.threadID = threadID + self.initialPath = initialPath + self.workspaceRoot = workspaceRoot + } + + public var body: some View { + Group { + if let initialPath { + FeatureFilePreviewView( + client: client, + threadID: threadID, + entry: FeatureFileEntry( + path: initialPath, + name: URL(fileURLWithPath: initialPath).lastPathComponent, + kind: .file + ), + workspaceRoot: workspaceRoot + ) + } else { + FeatureFileDirectoryView( + client: client, + threadID: threadID, + path: nil, + title: "Files", + workspaceRoot: workspaceRoot + ) + } + } + .background(T3Colors.background) + } +} + +private struct FeatureFileDirectoryView: View { + let client: any FeatureClient + let threadID: String + let path: String? + let title: String + let workspaceRoot: String? + + @State private var entries: [FeatureFileEntry] = [] + @State private var searchText = "" + @State private var includesHidden = false + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + Group { + if isLoading, entries.isEmpty { + ProgressView("Loading files…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage, entries.isEmpty { + ContentUnavailableView( + "Files unavailable", + systemImage: "folder.badge.questionmark", + description: Text(errorMessage) + ) + } else if filteredEntries.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Empty folder" : "No matches", + systemImage: "folder", + description: Text(searchText.isEmpty ? "This folder has no visible files." : "Try another search.") + ) + } else { + List(filteredEntries) { entry in + NavigationLink { + destination(for: entry) + } label: { + FeatureFileRow(entry: entry) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + } + .background(T3Colors.background) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Filter files") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("Show hidden files", isOn: $includesHidden) + Button { + Task { await load() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("File browser options") + } + } + .task(id: path) { await load() } + } + + @ViewBuilder + private func destination(for entry: FeatureFileEntry) -> some View { + if entry.kind == .directory { + FeatureFileDirectoryView( + client: client, + threadID: threadID, + path: entry.path, + title: entry.name, + workspaceRoot: workspaceRoot + ) + } else { + FeatureFilePreviewView( + client: client, + threadID: threadID, + entry: entry, + workspaceRoot: workspaceRoot + ) + } + } + + private var filteredEntries: [FeatureFileEntry] { + entries.featureFiltered(by: searchText, includesHidden: includesHidden) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + entries = try await client.listFiles(threadID: threadID, path: path) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureFileRow: View { + let entry: FeatureFileEntry + + var body: some View { + HStack(spacing: 11) { + Image(systemName: icon) + .font(.system(size: 15)) + .foregroundStyle(entry.kind == .directory ? .blue : .secondary) + .frame(width: 20) + Text(entry.name) + .font(T3Typography.threadBody) + .lineLimit(1) + Spacer() + if let size = entry.sizeBytes, entry.kind != .directory { + Text(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) + .font(T3Typography.tool.monospacedDigit()) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var icon: String { + switch entry.kind { + case .directory: "folder.fill" + case .symbolicLink: "link" + case .file: + switch FeatureFilePreviewKind.infer(path: entry.path) { + case .image: "photo" + case .pdf: "doc.richtext" + case .video: "video" + case .document: "doc" + case .markdown: "doc.richtext" + case .source: entry.name.hasSuffix(".swift") ? "swift" : "chevron.left.forwardslash.chevron.right" + case .plainText: "doc.text" + } + } + } +} + +private struct FeatureFilePreviewView: View { + let client: any FeatureClient + let threadID: String + let entry: FeatureFileEntry + let workspaceRoot: String? + + @State private var content: FeatureFileContent? + @State private var sourceLines: [FeatureSourceLine] = [] + @State private var assetURL: URL? + @State private var errorMessage: String? + @State private var isLoading = true + + private var previewKind: FeatureFilePreviewKind { + FeatureFilePreviewKind.infer(path: entry.path, language: content?.language) + } + + var body: some View { + Group { + if isLoading, content == nil, assetURL == nil { + ProgressView("Loading file…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let assetURL { + FeatureNativeMediaPreviewView( + source: .remote(assetURL), + kind: previewKind, + fileName: entry.name + ) + } else if let content { + VStack(spacing: 0) { + if content.isTruncated { + Label("Partial preview", systemImage: "exclamationmark.triangle") + .font(T3Typography.supportingStrong) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.09)) + } + switch previewKind { + case .markdown: + ScrollView { + MarkdownMessageView( + content.text, + copyActionTitle: "Copy file contents", + imageContext: markdownImageContext + ) + .frame(maxWidth: T3Metrics.readingWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 16) + } + .scrollDismissesKeyboard(.interactively) + case .source, .plainText: + FeatureSourceTextView(lines: sourceLines) + case .image, .pdf, .video, .document: + EmptyView() + } + } + } else { + ContentUnavailableView( + previewKind == .image ? "Image unavailable" : "File unavailable", + systemImage: previewKind == .image ? "photo.badge.exclamationmark" : "doc.badge.ellipsis", + description: Text(errorMessage ?? "The file could not be read.") + ) + } + } + .background(T3Colors.background) + .navigationTitle(entry.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let content { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: content.text) { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Share file contents") + } + } + } + .task { await load() } + } + + private var markdownImageContext: MarkdownImageContext? { + guard let workspaceRoot, + let resolver = client as? any FeatureWorkspaceAssetResolving else { return nil } + return MarkdownImageContext( + threadID: threadID, + workspaceRoot: workspaceRoot, + resolver: resolver, + sourceFilePath: entry.path + ) + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + if [.image, .pdf, .video, .document].contains(previewKind) { + guard let resolver = client as? any FeatureWorkspaceAssetResolving else { + throw FeatureCapabilityUnavailable("Native file previews") + } + let resolvedURL = if previewKind == .image || previewKind == .video { + try await resolver.mediaAssetURL(threadID: threadID, path: entry.path) + } else { + try await resolver.workspaceAssetURL(threadID: threadID, path: entry.path) + } + guard !Task.isCancelled else { return } + assetURL = resolvedURL + content = nil + sourceLines = [] + } else { + let loaded = try await client.readFile(threadID: threadID, path: entry.path) + let loadedKind = FeatureFilePreviewKind.infer( + path: entry.path, + language: loaded.language + ) + let lines: [FeatureSourceLine] + switch loadedKind { + case .source: + lines = await Task.detached(priority: .userInitiated) { + FeatureSourceHighlighter.lines( + text: loaded.text, + language: loaded.language + ) + }.value + case .plainText: + lines = await Task.detached(priority: .userInitiated) { + FeatureSourceHighlighter.lines(text: loaded.text, language: "plain") + }.value + case .markdown: + _ = await MarkdownRenderCache.shared.document( + for: MarkdownContentRevision(loaded.text) + ) + lines = [] + case .image, .pdf, .video, .document: + lines = [] + } + guard !Task.isCancelled else { return } + content = loaded + sourceLines = lines + assetURL = nil + } + errorMessage = nil + } catch { + guard !Task.isCancelled else { return } + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureSourceTextView: View { + let lines: [FeatureSourceLine] + + var body: some View { + GeometryReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + HStack(alignment: .top, spacing: 10) { + Text("\(line.number)") + .foregroundStyle(.tertiary) + .frame(width: 44, alignment: .trailing) + .accessibilityHidden(true) + FeatureHighlightedSourceLine(line: line) + } + .font(T3Typography.code) + .fixedSize(horizontal: true, vertical: false) + .frame( + minWidth: proxy.size.width, + minHeight: 22, + alignment: .leading + ) + } + } + .frame(minWidth: proxy.size.width, alignment: .leading) + .padding(.vertical, 10) + .padding(.trailing, 14) + .textSelection(.enabled) + } + } + .background(T3Colors.background) + .accessibilityLabel("Source file") + } +} + +private struct FeatureHighlightedSourceLine: View { + let line: FeatureSourceLine + + var body: some View { + renderedText + .fixedSize(horizontal: true, vertical: false) + } + + private var renderedText: Text { + guard !line.spans.isEmpty else { return Text(" ") } + return line.spans.reduce(Text("")) { output, span in + output + Text(verbatim: span.text).foregroundColor(color(for: span.kind)) + } + } + + private func color(for kind: FeatureSourceTokenKind) -> Color { + switch kind { + case .plain: T3Colors.textPrimary.opacity(0.92) + case .comment: T3Colors.textTertiary + case .keyword: T3Colors.syntaxKeyword + case .literal: T3Colors.syntaxLiteral + case .number: T3Colors.syntaxNumber + case .property: T3Colors.syntaxProperty + } + } +} + +private struct FeatureZoomableImageView: UIViewRepresentable { + let image: UIImage + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.backgroundColor = .black + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = 6 + scrollView.bouncesZoom = true + scrollView.decelerationRate = .fast + + let imageView = context.coordinator.imageView + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "Image preview" + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.toggleZoom(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + context.coordinator.scrollView = scrollView + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + if context.coordinator.imageView.image !== image { + context.coordinator.imageView.image = image + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + } + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + weak var scrollView: UIScrollView? + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + @objc func toggleZoom(_ recognizer: UITapGestureRecognizer) { + guard let scrollView else { return } + let scale = scrollView.zoomScale > scrollView.minimumZoomScale + ? scrollView.minimumZoomScale + : min(2.5, scrollView.maximumZoomScale) + scrollView.setZoomScale(scale, animated: true) + } + } +} + +private enum FeatureImageDecoder { + static func downsample(_ data: Data, maxPixelSize: CGFloat) -> UIImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { + return nil + } + let options = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] as CFDictionary + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { + return nil + } + return UIImage(cgImage: image) + } +} + +private enum FeatureImagePreviewError: LocalizedError { + case httpStatus(Int) + case invalidImage + case tooLarge + + var errorDescription: String? { + switch self { + case let .httpStatus(status): "The image server returned HTTP \(status)." + case .invalidImage: "The file is not a supported image." + case .tooLarge: "The image is larger than the 64 MB preview limit." + } + } +} diff --git a/apps/swift-ios/Features/Prism/PrismView.swift b/apps/swift-ios/Features/Prism/PrismView.swift new file mode 100644 index 000000000000..a6366807aed3 --- /dev/null +++ b/apps/swift-ios/Features/Prism/PrismView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +public struct PrismView: View { + private let client: any FeatureClient + private let environments: [FeatureEnvironment] + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @State private var environmentID = "" + @State private var status: PrismResponse? + @State private var accounts: [PrismAccount] = [] + @State private var errorMessage: String? + @State private var pending = false + @State private var login: PrismResponse? + @State private var loginEnvironmentID = "" + @State private var callback = "" + @State private var loginCheck = 0 + @State private var removing: PrismAccount? + + public init(client: any FeatureClient, environments: [FeatureEnvironment]) { + self.client = client + self.environments = environments.filter { $0.isEnabled && $0.prismEnabled == true } + } + + private var writable: Bool { status?.state == "ready" && status?.role != "replica" && !pending } + + public var body: some View { + Form { + Section { + Picker("Environment", selection: $environmentID) { + ForEach(environments) { environment in Text(environment.name).tag(environment.id) } + } + .disabled(login != nil || pending) + LabeledContent("Gateway", value: status?.state ?? "Checking…") + if let role = status?.role { LabeledContent("Pool role", value: role) } + if let version = status?.version { LabeledContent("Engine", value: version) } + if status?.role == "replica" { + Text("Manage accounts on the primary environment. This gateway receives serving credentials and cannot refresh them.") + } + if let errorMessage { Text(errorMessage).foregroundStyle(.red) } + if status?.lastSyncError != nil { Text("Account sync needs attention on this environment.").foregroundStyle(.red) } + } header: { Text("Prism") } + + Section("Accounts") { + if accounts.isEmpty { Text("No accounts available. Sign in on the primary environment to add one.") } + ForEach(accounts) { account in + VStack(alignment: .leading, spacing: 6) { + Text(account.email ?? account.label).font(.headline) + Text(account.provider).font(.caption).foregroundStyle(.secondary) + if account.lifecycle?.requiresLogin == true { + Label("Sign-in required", systemImage: "exclamationmark.circle").foregroundStyle(.red) + } else if account.lifecycle?.unavailable == true { + Text("Unavailable").foregroundStyle(.secondary) + } + Text(account.lifecycle?.expiresAt.map { "Token expiry: \($0)" } ?? "Token expiry unknown") + .font(.caption).foregroundStyle(.secondary) + Toggle("Enabled", isOn: Binding(get: { !account.disabled }, set: { enabled in + Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "PATCH", body: ["disabled": .bool(!enabled)])) } + })).disabled(!writable) + Button("Remove account", role: .destructive) { removing = account }.disabled(!writable) + } + } + } + + Section("Add account") { + if let login { + if let rawURL = login.authUrl, let url = URL(string: rawURL) { + Link("Continue sign-in", destination: url) + } + if let code = login.userCode { Text(code).font(.body.monospaced()).textSelection(.enabled) } + if login.flow != "device" { + TextField("Completed callback URL", text: $callback) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Button("Submit callback") { Task { await submitCallback() } } + .disabled(callback.isEmpty || pending) + } + Button("Check sign-in") { loginCheck += 1 }.disabled(pending) + Button("Cancel sign-in", role: .cancel) { Task { await cancelLogin() } }.disabled(pending) + } else { + Button("Sign in to Claude") { Task { await beginLogin("anthropic") } }.disabled(!writable) + Button("Sign in to ChatGPT / Codex") { Task { await beginLogin("codex") } }.disabled(!writable) + Button("Sign in to Grok") { Task { await beginLogin("xai") } }.disabled(!writable) + } + } + } + .navigationTitle("Prism") + .refreshable { await load() } + .task { + environmentID = environments.first(where: \.isActive)?.id ?? environments.first?.id ?? "" + } + .task(id: environmentID + String(describing: scenePhase)) { + guard scenePhase == .active, !environmentID.isEmpty else { return } + repeat { + await load() + do { try await Task.sleep(for: .seconds(10)) } catch { return } + } while !Task.isCancelled + } + .task(id: (login?.sessionId ?? "") + String(loginCheck)) { + guard let id = login?.sessionId else { return } + while !Task.isCancelled { + do { + let result = try await client.prism(PrismRequest("/accounts/login/" + PrismRequest.component(id)), environmentID: loginEnvironmentID) + if result.status == "completed" { + login = nil; callback = ""; await load(); return + } + if result.status == "failed" || result.status == "cancelled" { + login = nil; callback = ""; errorMessage = "Sign-in did not complete. Try again."; return + } + try await Task.sleep(for: .seconds(2)) + } catch is CancellationError { return } + catch { errorMessage = "Could not check sign-in. Retry or cancel the flow."; return } + } + } + .confirmationDialog("Remove this account from the shared pool?", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } })) { + Button("Remove", role: .destructive) { + guard let account = removing else { return } + removing = nil + Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "DELETE")) } + } + } + } + + @MainActor private func load() async { + let selected = environmentID + guard !selected.isEmpty else { return } + do { + let nextStatus = try await client.prism(PrismRequest("/status"), environmentID: selected) + let nextAccounts = nextStatus.state == "ready" + ? try await client.prism(PrismRequest("/accounts"), environmentID: selected).accounts ?? [] : [] + guard selected == environmentID, !Task.isCancelled else { return } + status = nextStatus; accounts = nextAccounts; errorMessage = nil + } catch is CancellationError { } + catch { + guard selected == environmentID else { return } + status = nil; accounts = []; errorMessage = "Prism is unavailable. Check the connection and whether Prism is enabled on this environment." + } + } + + @MainActor private func change(_ request: PrismRequest) async { + guard !pending else { return } + pending = true + defer { pending = false } + do { _ = try await client.prism(request, environmentID: environmentID); await load() } + catch { errorMessage = "The account change failed. Manage pooled accounts on the primary environment." } + } + + @MainActor private func beginLogin(_ provider: String) async { + guard writable else { return } + pending = true + defer { pending = false } + loginEnvironmentID = environmentID + do { login = try await client.prism(PrismRequest("/accounts/login", method: "POST", body: ["provider": .string(provider)]), environmentID: loginEnvironmentID) } + catch { errorMessage = "Could not start sign-in. Check the primary gateway." } + } + + @MainActor private func submitCallback() async { + guard let id = login?.sessionId else { return } + pending = true + defer { pending = false } + do { + _ = try await client.prism(PrismRequest("/accounts/login/" + PrismRequest.component(id) + "/callback", method: "POST", body: ["redirectUrl": .string(callback)]), environmentID: loginEnvironmentID) + callback = "" + } catch { errorMessage = "The callback was not accepted. Check the sign-in flow and try again." } + } + + @MainActor private func cancelLogin() async { + guard let id = login?.sessionId else { return } + pending = true + defer { pending = false } + do { + _ = try await client.prism(PrismRequest("/accounts/login/" + PrismRequest.component(id), method: "DELETE"), environmentID: loginEnvironmentID) + login = nil; callback = "" + } catch { errorMessage = "Could not cancel sign-in. Try again." } + } +} diff --git a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift new file mode 100644 index 000000000000..43d82007767d --- /dev/null +++ b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift @@ -0,0 +1,1643 @@ +import Observation +import SwiftUI + +struct FeaturePullRequestRow: Identifiable, Equatable { + let environmentID: String + let environmentName: String + let entry: PullRequestListEntry + + var id: String { "\(environmentID):\(entry.id)" } + var target: FeaturePullRequestTarget { + FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: environmentName, + reference: PullRequestRef( + projectId: entry.projectId, + repository: entry.repository, + number: entry.number + ) + ) + } +} + +@MainActor +@Observable +final class PullRequestsModel { + var rows: [FeaturePullRequestRow] = [] + private var allRows: [FeaturePullRequestRow] = [] + var environments: [FeaturePullRequestEnvironmentList] = [] + var state: PullRequestListState = .open + var involvement: PullRequestInvolvement = .all + var query = "" + var draftFilter: String? + var reviewFilter: String? + var checksFilter: String? + var environmentFilter: String? + var hostFilter: String? + var projectFilter: String? + var isLoading = false + var isLoadingMore = false + var errorMessage: String? + + private let client: any FeatureClient + private var loadGeneration: UInt64 = 0 + private var loadedInput: PullRequestListInput? + + init(client: any FeatureClient) { + self.client = client + } + + func load(invalidate: Bool = false) async { + loadGeneration &+= 1 + let generation = loadGeneration + isLoading = true + isLoadingMore = false + errorMessage = nil + defer { + if loadGeneration == generation { + isLoading = false + } + } + do { + if invalidate { try await client.invalidatePullRequests(nil) } + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let filters = PullRequestListFilters( + draft: draftFilter, + review: reviewFilter, + checks: checksFilter + ) + let input = PullRequestListInput( + state: state, + involvement: involvement, + filters: filters == PullRequestListFilters() ? nil : filters, + query: trimmedQuery.isEmpty ? nil : trimmedQuery + ) + let result = try await client.pullRequestLists(input) + guard !Task.isCancelled, loadGeneration == generation else { return } + loadedInput = input + environments = result + updateRows() + } catch { + guard loadGeneration == generation, !(error is CancellationError) else { return } + errorMessage = error.localizedDescription + } + } + + var hasMorePages: Bool { + environments.contains { environment in + (environmentFilter == nil || environment.environmentID == environmentFilter) + && environment.result?.nextCursors.isEmpty == false + } + } + + func loadMore() async { + guard !isLoading, !isLoadingMore, let loadedInput else { return } + + let pending = environments.compactMap { environment -> (String, [String: String])? in + guard environmentFilter == nil || environment.environmentID == environmentFilter, + let cursors = environment.result?.nextCursors, + !cursors.isEmpty else { + return nil + } + return (environment.environmentID, cursors) + } + guard !pending.isEmpty else { return } + + let generation = loadGeneration + isLoadingMore = true + defer { + if loadGeneration == generation { + isLoadingMore = false + } + } + + for (environmentID, cursors) in pending { + guard !Task.isCancelled, loadGeneration == generation else { return } + + let input = PullRequestListInput( + state: loadedInput.state, + involvement: loadedInput.involvement, + filters: loadedInput.filters, + projectId: loadedInput.projectId, + projectIds: loadedInput.projectIds, + host: loadedInput.host, + limit: loadedInput.limit, + cursors: cursors, + query: loadedInput.query + ) + + do { + let pages = try await client.pullRequestLists( + input, + environmentID: environmentID + ) + guard !Task.isCancelled, loadGeneration == generation else { return } + guard let page = pages.first(where: { $0.environmentID == environmentID }), + let index = environments.firstIndex(where: { + $0.environmentID == environmentID + }) else { + continue + } + + let previous = environments[index] + let result: PullRequestListResult? = if let pageResult = page.result { + previous.result?.appending(pageResult) ?? pageResult + } else { + previous.result + } + environments[index] = FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: page.environmentName, + result: result, + errorMessage: page.errorMessage + ) + updateRows() + } catch { + guard loadGeneration == generation, + !(error is CancellationError), + let index = environments.firstIndex(where: { + $0.environmentID == environmentID + }) else { + return + } + let previous = environments[index] + environments[index] = FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: previous.environmentName, + result: previous.result, + errorMessage: error.localizedDescription + ) + } + } + } + + func applyLocalFilters() { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + rows = allRows.filter { row in + (environmentFilter == nil || row.environmentID == environmentFilter) + && (hostFilter == nil || row.entry.host == hostFilter) + && (projectFilter == nil + || "\(row.environmentID):\(row.entry.projectId)" == projectFilter) + && (needle.isEmpty + || row.entry.title.lowercased().contains(needle) + || row.entry.repository.lowercased().contains(needle) + || row.entry.author?.login.lowercased().contains(needle) == true + || String(row.entry.number) == needle) + } + } + + var environmentOptions: [(String, String)] { + Dictionary(uniqueKeysWithValues: environments.map { ($0.environmentID, $0.environmentName) }) + .sorted { $0.value < $1.value } + } + + var hostOptions: [String] { Array(Set(allRows.map(\.entry.host))).sorted() } + + var projectOptions: [(String, String)] { + let values = allRows.reduce(into: [String: String]()) { result, row in + result["\(row.environmentID):\(row.entry.projectId)"] = row.entry.projectTitle + } + return values.sorted { $0.value < $1.value } + } + + private func updateRows() { + var seenRowIDs = Set() + allRows = environments.flatMap { environment in + (environment.result?.entries ?? []).map { + FeaturePullRequestRow( + environmentID: environment.environmentID, + environmentName: environment.environmentName, + entry: $0 + ) + } + } + .filter { seenRowIDs.insert($0.id).inserted } + .sorted { $0.entry.updatedAt > $1.entry.updatedAt } + applyLocalFilters() + } +} + +public struct PullRequestsView: View { + @Bindable private var rootModel: FeatureRootModel + @State private var model: PullRequestsModel + @State private var searchTask: Task? + + public init(model: FeatureRootModel) { + rootModel = model + _model = State(initialValue: PullRequestsModel(client: model.client)) + } + + public var body: some View { + VStack(spacing: 0) { + filters + Divider().overlay(T3Colors.separator) + content + } + .background(T3Colors.background) + .navigationTitle("Pull Requests") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await model.load(invalidate: true) } } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(model.isLoading) + } + } + .t3NavigationChrome() + .task { await model.load() } + .onChange(of: model.state) { reload() } + .onChange(of: model.involvement) { reload() } + .onChange(of: model.draftFilter) { reload() } + .onChange(of: model.reviewFilter) { reload() } + .onChange(of: model.checksFilter) { reload() } + .onChange(of: model.environmentFilter) { model.applyLocalFilters() } + .onChange(of: model.hostFilter) { model.applyLocalFilters() } + .onChange(of: model.projectFilter) { model.applyLocalFilters() } + .onChange(of: model.query) { + searchTask?.cancel() + searchTask = Task { + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await model.load() + } + } + } + + private var filters: some View { + VStack(spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(T3Colors.textTertiary) + TextField("Search pull requests", text: $model.query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + filterMenu + } + .padding(.horizontal, 14) + .frame(minHeight: 44) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + + HStack(spacing: 12) { + Picker("State", selection: $model.state) { + ForEach(PullRequestListState.allCases, id: \.self) { + Text($0.label).tag($0) + } + } + .pickerStyle(.segmented) + + Menu { + Picker("Involvement", selection: $model.involvement) { + ForEach(PullRequestInvolvement.allCases, id: \.self) { + Text($0.label).tag($0) + } + } + } label: { + Label(model.involvement.label, systemImage: "person.2") + .font(T3Typography.control) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var filterMenu: some View { + Menu { + Menu("Drafts") { + filterButton("Any", value: nil, selection: $model.draftFilter) + filterButton("Only drafts", value: "only", selection: $model.draftFilter) + filterButton("Hide drafts", value: "hide", selection: $model.draftFilter) + } + Menu("Review") { + filterButton("Any", value: nil, selection: $model.reviewFilter) + filterButton("Approved", value: "approved", selection: $model.reviewFilter) + filterButton( + "Changes requested", + value: "changes-requested", + selection: $model.reviewFilter + ) + filterButton( + "Review required", + value: "review-required", + selection: $model.reviewFilter + ) + filterButton("No review", value: "none", selection: $model.reviewFilter) + } + Menu("Checks") { + filterButton("Any", value: nil, selection: $model.checksFilter) + filterButton("Passing", value: "passing", selection: $model.checksFilter) + filterButton("Failing", value: "failing", selection: $model.checksFilter) + } + Menu("Computer") { + filterButton("All computers", value: nil, selection: $model.environmentFilter) + ForEach(model.environmentOptions, id: \.0) { id, name in + filterButton(name, value: id, selection: $model.environmentFilter) + } + } + Menu("Host") { + filterButton("All hosts", value: nil, selection: $model.hostFilter) + ForEach(model.hostOptions, id: \.self) { host in + filterButton(host, value: host, selection: $model.hostFilter) + } + } + Menu("Project") { + filterButton("All projects", value: nil, selection: $model.projectFilter) + ForEach(model.projectOptions, id: \.0) { id, name in + filterButton(name, value: id, selection: $model.projectFilter) + } + } + if hasExtraFilters { + Divider() + Button("Clear filters") { + model.draftFilter = nil + model.reviewFilter = nil + model.checksFilter = nil + model.environmentFilter = nil + model.hostFilter = nil + model.projectFilter = nil + } + } + } label: { + Image(systemName: hasExtraFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .font(.system(size: 18)) + .foregroundStyle(hasExtraFilters ? T3Colors.accent : T3Colors.textSecondary) + } + } + + private func filterButton( + _ title: String, + value: String?, + selection: Binding + ) -> some View { + Button { + selection.wrappedValue = value + } label: { + if selection.wrappedValue == value { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + } + + private var hasExtraFilters: Bool { + model.draftFilter != nil || model.reviewFilter != nil || model.checksFilter != nil + || model.environmentFilter != nil || model.hostFilter != nil + || model.projectFilter != nil + } + + @ViewBuilder + private var content: some View { + if model.isLoading, model.rows.isEmpty { + ProgressView("Loading pull requests…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = model.errorMessage, model.rows.isEmpty { + ContentUnavailableView("Couldn’t load pull requests", systemImage: "exclamationmark.triangle", description: Text(error)) + } else { + List { + ForEach(model.environments.filter { $0.errorMessage != nil }) { environment in + Label { + VStack(alignment: .leading, spacing: 2) { + Text(environment.environmentName) + .font(T3Typography.supportingStrong) + Text("Unavailable. Other computers are still shown.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } icon: { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(T3Colors.warning) + } + } + + ForEach(model.rows) { row in + NavigationLink { + PullRequestDetailView(rootModel: rootModel, row: row) + } label: { + PullRequestRowView(row: row) + } + } + + if model.rows.isEmpty { + ContentUnavailableView( + "No pull requests", + systemImage: "arrow.triangle.pull", + description: Text("Try another state, involvement, or search.") + ) + .listRowBackground(Color.clear) + } + + if model.hasMorePages { + Button { + Task { await model.loadMore() } + } label: { + HStack { + Spacer() + if model.isLoadingMore { + ProgressView() + } + Text(model.isLoadingMore ? "Loading more..." : "Load more") + Spacer() + } + } + .disabled(model.isLoading || model.isLoadingMore) + .listRowBackground(Color.clear) + } + } + .listStyle(.plain) + .refreshable { await model.load(invalidate: true) } + } + } + + private func reload() { + Task { await model.load() } + } +} + +private struct PullRequestRowView: View { + let row: FeaturePullRequestRow + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: row.entry.state.systemImage) + .foregroundStyle(row.entry.state.color) + Text("\(row.entry.repository) #\(row.entry.number)") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 8) + Text(row.environmentName) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + Text(row.entry.title) + .font(T3Typography.threadBody.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(2) + HStack(spacing: 10) { + if let author = row.entry.author { Text(author.login) } + Text("\(row.entry.headBranch) → \(row.entry.baseBranch)") + .lineLimit(1) + Spacer(minLength: 4) + if row.entry.additions > 0 || row.entry.deletions > 0 { + Text("+\(row.entry.additions)").foregroundStyle(T3Colors.success) + Text("−\(row.entry.deletions)").foregroundStyle(T3Colors.danger) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(.vertical, 5) + } +} + +@MainActor +@Observable +private final class PullRequestDetailModel { + var detail: PullRequestDetail? + var activity: PullRequestActivity? + var diffFiles: [PullRequestDiffFile] = [] + var isDiffIncomplete = false + var isLoading = true + var isLoadingDiff = false + var isActing = false + var errorMessage: String? + var reviewDrafts: [PullRequestReviewCommentDraft] = [] + var reviewerCandidates: [PullRequestReviewerCandidate] = [] + var isLoadingReviewers = false + + private let client: any FeatureClient + let target: FeaturePullRequestTarget + + init(client: any FeatureClient, target: FeaturePullRequestTarget) { + self.client = client + self.target = target + } + + func load(invalidate: Bool = false) async { + isLoading = true + errorMessage = nil + do { + if invalidate { try await client.invalidatePullRequests(target) } + detail = try await client.pullRequestDetail(target) + activity = try? await client.pullRequestActivity(target) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func loadDiff() async { + guard detail?.capabilities.diff == true, diffFiles.isEmpty, !isLoadingDiff else { return } + isLoadingDiff = true + do { + var cursor: String? + var pagination = PullRequestDiffPagination() + repeat { + let page = try await client.pullRequestDiff(target, cursor: cursor) + cursor = pagination.append(page) + } while cursor != nil + diffFiles = PullRequestDiffParser.parse(pagination.patch) + isDiffIncomplete = pagination.isIncomplete + } catch { + errorMessage = error.localizedDescription + } + isLoadingDiff = false + } + + func run( + _ action: PullRequestAction, + mergeMethod: PullRequestMergeMethod? = nil, + updateMethod: PullRequestUpdateMethod? = nil + ) async { + await mutate { + try await client.runPullRequestAction( + target, + action: action, + mergeMethod: mergeMethod, + updateMethod: updateMethod + ) + } + } + + func update(title: String? = nil, body: String? = nil) async { + await mutate { try await client.updatePullRequest(target, title: title, body: body) } + } + + func comment(_ body: String) async { + await mutate { try await client.commentOnPullRequest(target, body: body) } + } + + func review(verdict: PullRequestReviewVerdict, body: String) async -> Bool { + var submitted = false + await mutate { + try await client.submitPullRequestReview( + target, + verdict: verdict, + body: body, + comments: reviewDrafts + ) + reviewDrafts = [] + submitted = true + } + return submitted + } + + func reply(threadID: String, body: String) async { + await mutate { + try await client.replyToPullRequestThread(target, threadID: threadID, body: body) + } + } + + func resolve(thread: PullRequestReviewThread) async { + await mutate { + try await client.setPullRequestThreadResolved( + target, + threadID: thread.id, + resolved: !thread.isResolved + ) + } + } + + func react(subjectID: String?, reaction: PullRequestReactionContent, reacted: Bool) async { + await mutate { + try await client.setPullRequestReaction( + target, + subjectID: subjectID, + content: reaction, + reacted: reacted + ) + } + } + + func loadReviewers() async { + guard !isLoadingReviewers else { return } + isLoadingReviewers = true + do { + reviewerCandidates = try await client.pullRequestReviewerCandidates(target).candidates + } catch { + errorMessage = error.localizedDescription + } + isLoadingReviewers = false + } + + func toggleReviewer(_ reviewer: PullRequestReviewerCandidate) async { + await mutate { + try await client.requestPullRequestReviewers( + target, + reviewers: [reviewer], + requested: !reviewer.isRequested + ) + } + await loadReviewers() + } + + private func mutate(_ operation: () async throws -> Void) async { + isActing = true + do { + try await operation() + try await client.invalidatePullRequests(target) + await load() + } catch { + errorMessage = error.localizedDescription + } + isActing = false + } +} + +private enum PullRequestDetailTab: String, CaseIterable { + case summary = "Summary" + case conversation = "Activity" + case files = "Files" +} + +struct PullRequestDetailView: View { + private struct PendingAction: Identifiable { + let id = UUID() + let action: PullRequestAction + var mergeMethod: PullRequestMergeMethod? + var updateMethod: PullRequestUpdateMethod? + } + + @Bindable var rootModel: FeatureRootModel + let target: FeaturePullRequestTarget + @State private var model: PullRequestDetailModel + @State private var tab: PullRequestDetailTab = .summary + @State private var editor: PullRequestEditor? + @State private var reviewSheet = false + @State private var reviewerSheet = false + @State private var notice: String? + @State private var pendingAction: PendingAction? + + init(rootModel: FeatureRootModel, row: FeaturePullRequestRow) { + self.init(rootModel: rootModel, target: row.target) + } + + init(rootModel: FeatureRootModel, target: FeaturePullRequestTarget) { + self.rootModel = rootModel + self.target = target + _model = State(initialValue: PullRequestDetailModel(client: rootModel.client, target: target)) + } + + var body: some View { + Group { + if model.isLoading, model.detail == nil { + ProgressView("Loading pull request…") + } else if let detail = model.detail { + VStack(spacing: 0) { + detailHeader(detail) + Picker("Section", selection: $tab) { + ForEach(PullRequestDetailTab.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 10) + + Divider().overlay(T3Colors.separator) + tabContent(detail) + } + } else { + ContentUnavailableView( + "Couldn’t load pull request", + systemImage: "exclamationmark.triangle", + description: Text(model.errorMessage ?? "Try again.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("#\(target.reference.number)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { actionMenu } + } + .t3NavigationChrome() + .task { await model.load() } + .onChange(of: tab) { _, value in + if value == .files { Task { await model.loadDiff() } } + } + .sheet(item: $editor) { editor in + PullRequestEditSheet(editor: editor) { value in + Task { + switch editor.kind { + case .title: await model.update(title: value) + case .body: await model.update(body: value) + case .comment: await model.comment(value) + } + } + } + } + .sheet(isPresented: $reviewSheet) { + PullRequestReviewSheet(model: model) + } + .sheet(isPresented: $reviewerSheet) { + PullRequestReviewerSheet(model: model) + } + .alert("Pull request", isPresented: Binding( + get: { notice != nil }, + set: { if !$0 { notice = nil } } + )) { Button("OK") {} } message: { Text(notice ?? "") } + .alert("Action failed", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { Button("OK") {} } message: { Text(model.errorMessage ?? "") } + .alert( + "Confirm pull request action", + isPresented: Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } } + ), + presenting: pendingAction + ) { pending in + Button(pending.action.label, role: .destructive) { + pendingAction = nil + Task { + await model.run( + pending.action, + mergeMethod: pending.mergeMethod, + updateMethod: pending.updateMethod + ) + } + } + Button("Cancel", role: .cancel) { pendingAction = nil } + } message: { pending in + Text("This action will \(pending.action.label.lowercased()).") + } + } + + private func detailHeader(_ detail: PullRequestDetail) -> some View { + VStack(alignment: .leading, spacing: 7) { + Text(detail.title) + .font(T3Typography.threadHeading3) + .foregroundStyle(T3Colors.textPrimary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 7) { + Label(detail.state.label, systemImage: detail.state.systemImage) + .foregroundStyle(detail.state.color) + Text("\(detail.repository) · \(target.environmentName)") + Spacer() + Text("+\(detail.additions)").foregroundStyle(T3Colors.success) + Text("−\(detail.deletions)").foregroundStyle(T3Colors.danger) + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(16) + } + + @ViewBuilder + private func tabContent(_ detail: PullRequestDetail) -> some View { + switch tab { + case .summary: + PullRequestSummaryView( + detail: detail, + activity: model.activity, + model: model, + onUpdateBranch: { method in + pendingAction = PendingAction(action: .updateBranch, updateMethod: method) + } + ) + case .conversation: + PullRequestActivityView(activity: model.activity, model: model) + case .files: + PullRequestFilesView( + files: model.diffFiles, + isLoading: model.isLoadingDiff, + isIncomplete: model.isDiffIncomplete, + drafts: $model.reviewDrafts, + canComment: detail.capabilities.review.inlineComment + && detail.viewerPermissions.comment, + sendToAgent: sendToAgent + ) + } + } + + @ViewBuilder + private var actionMenu: some View { + if let detail = model.detail { + Menu { + if detail.capabilities.edit?.changeRequest == true { + Button("Edit title", systemImage: "pencil") { + editor = PullRequestEditor(kind: .title, value: detail.title) + } + Button("Edit description", systemImage: "doc.text") { + editor = PullRequestEditor(kind: .body, value: detail.body) + } + } + if detail.capabilities.comment, detail.viewerPermissions.comment { + Button("Add comment", systemImage: "text.bubble") { + editor = PullRequestEditor(kind: .comment, value: "") + } + } + if !detail.viewerPermissions.verdicts.isEmpty { + Button("Review changes", systemImage: "checkmark.bubble") { reviewSheet = true } + } + if detail.capabilities.reviewers.request, + detail.capabilities.reviewers.listCandidates, + detail.viewerPermissions.requestReviewers { + Button("Manage reviewers", systemImage: "person.badge.plus") { + reviewerSheet = true + } + } + Divider() + ForEach(detail.viewerPermissions.actions, id: \.self) { action in + if detail.capabilities.actions.contains(action), + action != .merge, + action != .updateBranch { + Button(action.label, systemImage: action.systemImage) { + if action == .close || action == .enableAutoMerge { + pendingAction = PendingAction(action: action) + } else { + Task { await model.run(action) } + } + } + } + } + if detail.capabilities.actions.contains(.merge), + detail.viewerPermissions.actions.contains(.merge) { + Menu("Merge pull request") { + ForEach(availableMergeMethods(detail), id: \.self) { method in + Button(method.label) { + pendingAction = PendingAction(action: .merge, mergeMethod: method) + } + } + } + } + } label: { + if model.isActing { ProgressView() } else { Image(systemName: "ellipsis.circle") } + } + .disabled(model.isActing) + } + } + + private func availableMergeMethods(_ detail: PullRequestDetail) -> [PullRequestMergeMethod] { + detail.capabilities.mergeMethods.filter { + switch $0 { + case .merge: detail.mergeCapabilities.merge + case .squash: detail.mergeCapabilities.squash + case .rebase: detail.mergeCapabilities.rebase + } + } + } + + private func sendToAgent(_ line: PullRequestDiffLine, file: PullRequestDiffFile) { + guard let project = rootModel.snapshot.projects.first(where: { + $0.environmentID == target.environmentID + && ($0.wireID ?? $0.id) == target.reference.projectId + }) else { + notice = "The project for this pull request is not available on this computer." + return + } + guard let selection = DailyUXCreationContext.initialSelection( + for: project, + in: rootModel.snapshot + ) else { + notice = "Choose a default model for this project first." + return + } + let prompt = """ + Please inspect and address this line from pull request #\(target.reference.number) in \(target.reference.repository). + + File: \(file.path) + Line: \(line.displayLineNumber) + + ```diff + \(line.text) + ``` + """ + Task { + let thread = await rootModel.startTask( + NewTaskRequest( + projectID: project.id, + prompt: prompt, + selection: selection, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + ) + notice = thread == nil ? "The task could not be started." : "Sent to a new agent thread." + } + } +} + +private struct PullRequestSummaryView: View { + let detail: PullRequestDetail + let activity: PullRequestActivity? + @Bindable var model: PullRequestDetailModel + let onUpdateBranch: (PullRequestUpdateMethod) -> Void + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + if detail.baseComparison == .behind, + detail.capabilities.actions.contains(.updateBranch), + detail.viewerPermissions.actions.contains(.updateBranch) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("Branch is behind").font(T3Typography.supportingStrong) + Text(detail.behindBy.map { "\($0) commits behind \(detail.baseBranch)" } ?? "Update from \(detail.baseBranch)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + Menu("Update") { + ForEach(detail.viewerPermissions.updateMethods ?? [], id: \.self) { method in + Button(method.rawValue.capitalized) { + onUpdateBranch(method) + } + } + } + .buttonStyle(.borderedProminent) + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } + + if !detail.body.isEmpty { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Description") + MarkdownMessageView(detail.body, copyActionTitle: "Copy description") + } + } + + if !detail.checks.isEmpty { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Checks") + ForEach(detail.checks) { check in + HStack(spacing: 9) { + Image(systemName: check.status.systemImage) + .foregroundStyle(check.status.color) + VStack(alignment: .leading, spacing: 2) { + Text(check.name).font(T3Typography.control) + if let description = check.description { + Text(description) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + } + } + } + } + + if !detail.reviewers.isEmpty { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Reviewers") + ForEach(detail.reviewers, id: \.login) { reviewer in + Label(reviewer.name ?? reviewer.login, systemImage: "person.crop.circle") + .font(T3Typography.control) + } + } + } + + if detail.capabilities.reactions == true { + PullRequestReactionsView( + reactions: activity?.reactions ?? [], + onToggle: { reaction, reacted in + Task { await model.react(subjectID: nil, reaction: reaction, reacted: reacted) } + } + ) + } + } + .padding(16) + } + } + + private func sectionTitle(_ value: String) -> some View { + Text(value.uppercased()) + .font(T3Typography.eyebrow) + .foregroundStyle(T3Colors.textSecondary) + } +} + +private struct PullRequestActivityView: View { + let activity: PullRequestActivity? + @Bindable var model: PullRequestDetailModel + @State private var replyThread: PullRequestReviewThread? + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if let activity { + ForEach(activity.comments) { comment in + PullRequestCommentView(comment: comment, model: model) + } + ForEach(activity.reviewThreads) { thread in + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("\(thread.path):\(thread.line.map(String.init) ?? "file")", systemImage: "text.bubble") + .font(T3Typography.supportingStrong) + Spacer() + if model.detail?.capabilities.review.resolve == true, + model.detail?.viewerPermissions.resolve == true { + Button(thread.isResolved ? "Reopen" : "Resolve") { + Task { await model.resolve(thread: thread) } + } + .font(T3Typography.supportingStrong) + } + } + ForEach(thread.comments) { comment in + VStack(alignment: .leading, spacing: 5) { + Text(comment.author?.login ?? "Unknown") + .font(T3Typography.supportingStrong) + MarkdownMessageView(comment.body, copyActionTitle: "Copy comment") + if model.detail?.capabilities.reactions == true { + PullRequestReactionsView( + reactions: comment.reactions ?? [] + ) { reaction, reacted in + Task { + await model.react( + subjectID: comment.id, + reaction: reaction, + reacted: reacted + ) + } + } + } + } + } + if model.detail?.capabilities.review.reply == true, + model.detail?.viewerPermissions.comment == true { + Button("Reply") { replyThread = thread } + .font(T3Typography.control) + } + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } + ForEach(activity.commits) { commit in + HStack(spacing: 10) { + Image(systemName: "point.topleft.down.to.point.bottomright.curvepath") + Text(commit.messageHeadline).lineLimit(2) + Spacer() + Text(String(commit.oid.prefix(7))).monospaced() + } + .font(T3Typography.supporting) + } + if activity.comments.isEmpty && activity.reviewThreads.isEmpty && activity.commits.isEmpty { + ContentUnavailableView("No activity", systemImage: "text.bubble") + } + } else { + ProgressView("Loading activity…") + } + } + .padding(16) + } + .sheet(item: $replyThread) { thread in + PullRequestTextSheet(title: "Reply", initialValue: "") { body in + Task { await model.reply(threadID: thread.id, body: body) } + } + } + } +} + +private struct PullRequestCommentView: View { + let comment: PullRequestComment + @Bindable var model: PullRequestDetailModel + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack { + Text(comment.author?.name ?? comment.author?.login ?? "Unknown") + .font(T3Typography.supportingStrong) + if let state = comment.reviewState { + Text(state.replacingOccurrences(of: "_", with: " ").lowercased()) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer() + } + if !comment.body.isEmpty { + MarkdownMessageView(comment.body, copyActionTitle: "Copy comment") + } + if model.detail?.capabilities.reactions == true { + PullRequestReactionsView(reactions: comment.reactions ?? []) { reaction, reacted in + Task { + await model.react( + subjectID: comment.id, + reaction: reaction, + reacted: reacted + ) + } + } + } + } + .padding(14) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 14)) + } +} + +private struct PullRequestReactionsView: View { + let reactions: [PullRequestReaction] + let onToggle: (PullRequestReactionContent, Bool) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 7) { + ForEach(reactions) { reaction in + Button { + onToggle(reaction.content, !reaction.viewerHasReacted) + } label: { + Text("\(reaction.content.emoji) \(reaction.count)") + } + .buttonStyle(.bordered) + .tint(reaction.viewerHasReacted ? T3Colors.accent : T3Colors.textSecondary) + } + Menu { + ForEach(PullRequestReactionContent.allCases, id: \.self) { reaction in + Button("\(reaction.emoji) \(reaction.label)") { onToggle(reaction, true) } + } + } label: { + Image(systemName: "face.smiling") + } + .buttonStyle(.bordered) + } + } + .scrollIndicators(.hidden) + } +} + +private struct PullRequestFilesView: View { + let files: [PullRequestDiffFile] + let isLoading: Bool + let isIncomplete: Bool + @Binding var drafts: [PullRequestReviewCommentDraft] + let canComment: Bool + let sendToAgent: (PullRequestDiffLine, PullRequestDiffFile) -> Void + @State private var commentingLine: PullRequestDiffSelection? + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + if isIncomplete { + Label( + "Some changes are missing from this diff.", + systemImage: "exclamationmark.triangle" + ) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.warning) + } + if isLoading { + ProgressView("Loading diff…") + .frame(maxWidth: .infinity) + .padding(.top, 50) + } else if files.isEmpty { + ContentUnavailableView("No diff available", systemImage: "doc.text.magnifyingglass") + } else { + ForEach(files) { file in + VStack(alignment: .leading, spacing: 0) { + Text(file.path) + .font(T3Typography.supportingStrong.monospaced()) + .padding(12) + Divider().overlay(T3Colors.separator) + ScrollView(.horizontal) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(file.lines) { line in + HStack(spacing: 0) { + Text(line.oldLine.map(String.init) ?? "") + .frame(width: 42, alignment: .trailing) + Text(line.newLine.map(String.init) ?? "") + .frame(width: 42, alignment: .trailing) + Text(line.text) + .padding(.leading, 10) + .frame(minWidth: 500, alignment: .leading) + } + .font(T3Typography.code) + .foregroundStyle(line.foreground) + .padding(.vertical, 2) + .background(line.background) + .contentShape(Rectangle()) + .contextMenu { + if canComment, line.position != nil { + Button("Add review comment", systemImage: "text.bubble") { + commentingLine = PullRequestDiffSelection(file: file, line: line) + } + } + Button("Send line to agent", systemImage: "paperplane") { + sendToAgent(line, file) + } + } + } + } + } + } + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border)) + } + } + } + .padding(16) + } + .sheet(item: $commentingLine) { selection in + PullRequestTextSheet(title: "Review comment", initialValue: "") { body in + guard let position = selection.line.position else { return } + drafts.append( + PullRequestReviewCommentDraft( + path: selection.file.path, + oldPath: selection.file.oldPath, + position: position, + body: body + ) + ) + } + } + } +} + +private struct PullRequestReviewSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: PullRequestDetailModel + @State private var verdict: PullRequestReviewVerdict = .comment + @State private var reviewBody = "" + + var body: some View { + NavigationStack { + Form { + Picker("Verdict", selection: $verdict) { + ForEach(model.detail?.viewerPermissions.verdicts ?? [], id: \.self) { + Text($0.label).tag($0) + } + } + Section("Summary") { + TextEditor(text: $reviewBody).frame(minHeight: 130) + } + if !model.reviewDrafts.isEmpty { + Section("Inline comments") { + ForEach(model.reviewDrafts) { draft in + VStack(alignment: .leading, spacing: 4) { + Text(draft.path).font(T3Typography.supportingStrong.monospaced()) + Text(draft.body).font(T3Typography.threadBody) + } + } + .onDelete { model.reviewDrafts.remove(atOffsets: $0) } + } + } + } + .navigationTitle("Submit Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Submit") { + Task { + if await model.review(verdict: verdict, body: reviewBody) { + dismiss() + } + } + } + .disabled(model.isActing) + } + } + .onAppear { + verdict = model.detail?.viewerPermissions.verdicts.first ?? .comment + } + } + } +} + +private struct PullRequestReviewerSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: PullRequestDetailModel + + var body: some View { + NavigationStack { + List { + if model.isLoadingReviewers, model.reviewerCandidates.isEmpty { + ProgressView("Loading reviewers…") + } + ForEach(model.reviewerCandidates) { reviewer in + Button { + Task { await model.toggleReviewer(reviewer) } + } label: { + HStack { + Image(systemName: reviewer.kind == "team" ? "person.3" : "person.crop.circle") + VStack(alignment: .leading, spacing: 2) { + Text(reviewer.name ?? reviewer.login) + .foregroundStyle(T3Colors.textPrimary) + if reviewer.name != nil { + Text(reviewer.login) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + if reviewer.isRequested { Image(systemName: "checkmark") } + } + } + } + } + .navigationTitle("Reviewers") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } + } + .task { await model.loadReviewers() } + } + } +} + +private struct PullRequestEditor: Identifiable { + enum Kind { case title, body, comment } + let id = UUID() + let kind: Kind + let value: String +} + +private struct PullRequestEditSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let editor: PullRequestEditor + let save: (String) -> Void + @State private var value: String + + init(editor: PullRequestEditor, save: @escaping (String) -> Void) { + self.editor = editor + self.save = save + _value = State(initialValue: editor.value) + } + + var body: some View { + PullRequestTextSheet(title: editor.kind.title, initialValue: editor.value, save: save) + } +} + +private struct PullRequestTextSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let title: String + let save: (String) -> Void + @State private var value: String + + init(title: String, initialValue: String, save: @escaping (String) -> Void) { + self.title = title + self.save = save + _value = State(initialValue: initialValue) + } + + var body: some View { + NavigationStack { + TextEditor(text: $value) + .font(T3Typography.threadBody) + .padding(12) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save(value); dismiss() } + .disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +struct PullRequestDiffFile: Identifiable, Equatable { + let id: String + let path: String + let oldPath: String? + let lines: [PullRequestDiffLine] +} + +struct PullRequestDiffLine: Identifiable, Equatable { + enum Kind { case context, added, deleted, header } + let id: Int + let kind: Kind + let text: String + let oldLine: Int? + let newLine: Int? + + var displayLineNumber: Int { newLine ?? oldLine ?? 0 } + var position: PullRequestReviewPosition? { + switch kind { + case .added: newLine.map(PullRequestReviewPosition.added) + case .deleted: oldLine.map(PullRequestReviewPosition.deleted) + case .context: + if let oldLine, let newLine { + PullRequestReviewPosition.context(old: oldLine, new: newLine, side: .right) + } else { nil } + case .header: nil + } + } + var foreground: Color { + switch kind { + case .added: T3Colors.success + case .deleted: T3Colors.danger + case .header: T3Colors.accent + case .context: T3Colors.textPrimary + } + } + var background: Color { + switch kind { + case .added: T3Colors.success.opacity(0.08) + case .deleted: T3Colors.danger.opacity(0.08) + default: .clear + } + } +} + +private struct PullRequestDiffSelection: Identifiable { + var id: String { "\(file.id):\(line.id)" } + let file: PullRequestDiffFile + let line: PullRequestDiffLine +} + +struct PullRequestDiffPagination { + private(set) var patch = "" + private(set) var isIncomplete = false + private var seenCursors = Set() + + mutating func append(_ page: PullRequestDiffResult) -> String? { + patch += page.patch + isIncomplete = isIncomplete || page.truncated + || !(page.omittedFileStats ?? []).isEmpty + + guard let cursor = page.nextCursor, !cursor.isEmpty else { return nil } + guard seenCursors.insert(cursor).inserted else { + isIncomplete = true + return nil + } + return cursor + } +} + +enum PullRequestDiffParser { + static func parse(_ patch: String) -> [PullRequestDiffFile] { + var files: [PullRequestDiffFile] = [] + var path = "" + var oldPath: String? + var lines: [PullRequestDiffLine] = [] + var oldLine = 0 + var newLine = 0 + var lineID = 0 + + func finish() { + guard !path.isEmpty else { return } + files.append(.init(id: "\(files.count):\(path)", path: path, oldPath: oldPath, lines: lines)) + lines = [] + oldPath = nil + } + + for raw in patch.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { + if raw == "\\ No newline at end of file" { + continue + } + if raw.hasPrefix("diff --git ") { + finish() + let parts = raw.split(separator: " ") + path = parts.count > 3 ? String(parts[3]).replacingOccurrences(of: "b/", with: "", options: .anchored) : "Changed file" + continue + } + if raw.hasPrefix("--- ") { + let value = String(raw.dropFirst(4)) + oldPath = value == "/dev/null" ? nil : value.replacingOccurrences(of: "a/", with: "", options: .anchored) + continue + } + if raw.hasPrefix("+++ ") { + let value = String(raw.dropFirst(4)) + if value != "/dev/null" { path = value.replacingOccurrences(of: "b/", with: "", options: .anchored) } + continue + } + if raw.hasPrefix("@@") { + let parts = raw.split(separator: " ") + oldLine = parts.count > 1 ? startLine(String(parts[1])) : 0 + newLine = parts.count > 2 ? startLine(String(parts[2])) : 0 + lines.append(.init(id: lineID, kind: .header, text: raw, oldLine: nil, newLine: nil)) + } else if raw.hasPrefix("+") { + lines.append(.init(id: lineID, kind: .added, text: raw, oldLine: nil, newLine: newLine)) + newLine += 1 + } else if raw.hasPrefix("-") { + lines.append(.init(id: lineID, kind: .deleted, text: raw, oldLine: oldLine, newLine: nil)) + oldLine += 1 + } else { + lines.append(.init(id: lineID, kind: .context, text: raw, oldLine: oldLine, newLine: newLine)) + oldLine += 1 + newLine += 1 + } + lineID += 1 + } + finish() + return files + } + + private static func startLine(_ range: String) -> Int { + Int(range.dropFirst().split(separator: ",").first ?? "0") ?? 0 + } +} + +private extension PullRequestListState { + var label: String { rawValue.capitalized } +} + +private extension PullRequestInvolvement { + var label: String { rawValue.capitalized } +} + +private extension PullRequestState { + var label: String { rawValue.capitalized } + var systemImage: String { + switch self { + case .open: "arrow.triangle.pull" + case .closed: "xmark.circle" + case .merged: "arrow.triangle.merge" + } + } + var color: Color { + switch self { + case .open: T3Colors.success + case .closed: T3Colors.danger + case .merged: T3Colors.syntaxKeyword + } + } +} + +private extension PullRequestCheckStatus { + var systemImage: String { + switch self { + case .success: "checkmark.circle.fill" + case .failure: "xmark.circle.fill" + case .pending: "clock" + case .skipped, .neutral, .cancelled: "minus.circle" + } + } + var color: Color { + switch self { + case .success: T3Colors.success + case .failure: T3Colors.danger + case .pending: T3Colors.warning + case .skipped, .neutral, .cancelled: T3Colors.textTertiary + } + } +} + +private extension PullRequestAction { + var label: String { + switch self { + case .merge: "Merge pull request" + case .ready: "Mark ready for review" + case .draft: "Convert to draft" + case .close: "Close pull request" + case .reopen: "Reopen pull request" + case .updateBranch: "Update branch" + case .enableAutoMerge: "Enable auto-merge" + case .disableAutoMerge: "Disable auto-merge" + } + } + var systemImage: String { + switch self { + case .merge: "arrow.triangle.merge" + case .ready: "checkmark.circle" + case .draft: "pencil.circle" + case .close: "xmark.circle" + case .reopen: "arrow.uturn.backward.circle" + case .updateBranch: "arrow.clockwise" + case .enableAutoMerge: "bolt.circle" + case .disableAutoMerge: "bolt.slash.circle" + } + } +} + +private extension PullRequestReviewVerdict { + var label: String { + switch self { + case .comment: "Comment" + case .approve: "Approve" + case .requestChanges: "Request changes" + } + } +} + +private extension PullRequestMergeMethod { + var label: String { + switch self { + case .merge: "Create merge commit" + case .squash: "Squash and merge" + case .rebase: "Rebase and merge" + } + } +} + +private extension PullRequestReactionContent { + var emoji: String { + switch self { + case .thumbsUp: "👍" + case .thumbsDown: "👎" + case .laugh: "😄" + case .hooray: "🎉" + case .confused: "😕" + case .heart: "❤️" + case .rocket: "🚀" + case .eyes: "👀" + } + } + var label: String { rawValue.replacingOccurrences(of: "-", with: " ").capitalized } +} + +private extension PullRequestEditor.Kind { + var title: String { + switch self { + case .title: "Edit Title" + case .body: "Edit Description" + case .comment: "Add Comment" + } + } +} diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift new file mode 100644 index 000000000000..a6e6db343c68 --- /dev/null +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -0,0 +1,526 @@ +import SwiftUI +import UIKit + +public struct FeatureReviewView: View { + @SwiftUI.Environment(\.scenePhase) private var scenePhase + let client: any FeatureClient + let threadID: String + + @State private var review: FeatureReview? + @State private var isLoading = true + @State private var errorMessage: String? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + Group { + if isLoading, review == nil { + ProgressView("Loading changes…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let review { + reviewList(review) + } else { + ContentUnavailableView( + "Review unavailable", + systemImage: "doc.text.magnifyingglass", + description: Text(errorMessage ?? "Changes could not be loaded.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("Review") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel("Reload changes") + } + } + .task { await load() } + .onChange(of: scenePhase) { _, phase in + guard phase == .active, review != nil, !isLoading else { return } + Task { await load() } + } + } + + private func reviewList(_ review: FeatureReview) -> some View { + List { + Section { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(review.title) + .font(T3Typography.navigationTitle) + if let base = review.baseReference { + Text(base) + .font(T3Typography.tool) + .foregroundStyle(T3Colors.textSecondary) + } + } + Spacer() + FeatureDiffStatsLabel(additions: review.additions, deletions: review.deletions) + } + .padding(.vertical, 3) + + if review.isTruncated { + Label("Large diff, showing a partial result", systemImage: "exclamationmark.triangle") + .font(T3Typography.supporting) + .foregroundStyle(.orange) + } + } + + Section("\(review.files.count) changed \(review.files.count == 1 ? "file" : "files")") { + if review.files.isEmpty { + ContentUnavailableView( + "No changes", + systemImage: "checkmark.circle", + description: Text("The working tree is clean.") + ) + .listRowBackground(Color.clear) + } + ForEach(review.files) { file in + NavigationLink { + FeatureDiffView(client: client, threadID: threadID, file: file) + } label: { + FeatureReviewFileRow(file: file) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await load() } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + review = try await client.loadReview(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private struct FeatureReviewFileRow: View { + let file: FeatureReviewFile + + var body: some View { + HStack(spacing: 10) { + Text(changeLabel) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(changeColor) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(fileName) + .font(T3Typography.homeTitle) + .lineLimit(1) + if !directory.isEmpty { + Text(directory) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + } + Spacer() + FeatureDiffStatsLabel(additions: file.additions, deletions: file.deletions) + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var fileName: String { + file.path.split(separator: "/").last.map(String.init) ?? file.path + } + + private var directory: String { + let components = file.path.split(separator: "/") + return components.dropLast().joined(separator: "/") + } + + private var changeLabel: String { + switch file.change { + case .added: "A" + case .modified: "M" + case .deleted: "D" + case .renamed: "R" + case .binary: "B" + } + } + + private var changeColor: Color { + switch file.change { + case .added: .green + case .deleted: .red + case .renamed: .blue + case .modified, .binary: .orange + } + } +} + +struct FeatureDiffStatsLabel: View { + let additions: Int + let deletions: Int + + var body: some View { + HStack(spacing: 5) { + if additions > 0 { + Text("+\(additions)").foregroundStyle(.green) + } + if deletions > 0 { + Text("−\(deletions)").foregroundStyle(.red) + } + } + .font(T3Typography.tool.monospacedDigit().weight(.medium)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(additions) additions, \(deletions) deletions") + } +} + +private struct FeatureDiffView: View { + let client: any FeatureClient + let threadID: String + let file: FeatureReviewFile + + @State private var renderedLines: [FeatureDiffLine] + @State private var isHydrating = false + @State private var selectedLine: FeatureReviewLineSelection? + @State private var isCommenting = false + @State private var comment = "" + @State private var isSending = false + @State private var commentError: String? + @FocusState private var isCommentFocused: Bool + + init(client: any FeatureClient, threadID: String, file: FeatureReviewFile) { + self.client = client + self.threadID = threadID + self.file = file + _renderedLines = State(initialValue: file.lines) + } + + var body: some View { + Group { + if renderedLines.isEmpty, isHydrating { + ProgressView("Loading full diff…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if renderedLines.isEmpty { + ContentUnavailableView( + file.change == .binary ? "Binary file" : "Diff unavailable", + systemImage: file.change == .binary ? "doc.richtext" : "doc.text.magnifyingglass", + description: Text("No line-level preview is available.") + ) + } else { + GeometryReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(renderedLines) { line in + FeatureDiffLineRow( + line: line, + isSelected: selection(for: line) == selectedLine, + minimumWidth: proxy.size.width + ) { + guard let selection = selection(for: line) else { return } + selectedLine = selection + openCommentComposer() + } + } + } + .frame(minWidth: proxy.size.width, alignment: .leading) + .padding(.vertical, 8) + } + } + } + } + .background(T3Colors.background) + .navigationTitle(file.path.split(separator: "/").last.map(String.init) ?? file.path) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + selectedLine = nil + openCommentComposer() + } label: { + Image(systemName: "text.bubble") + } + .accessibilityLabel("Add file review comment") + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if isCommenting { + commentComposer + } + } + .task(id: file.id) { await hydrate() } + } + + private var commentComposer: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("REVIEW COMMENT") + .font(T3Typography.eyebrow) + .foregroundStyle(T3Colors.textTertiary) + Text(commentLocation) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 8) + Button { + isCommenting = false + isCommentFocused = false + commentError = nil + } label: { + Image(systemName: "xmark") + .frame(width: T3Metrics.minimumTapTarget, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Close review comment") + } + + TextField( + "What should change?", + text: $comment, + axis: .vertical + ) + .font(T3Typography.composer) + .lineLimit(2 ... 6) + .focused($isCommentFocused) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(T3Colors.input) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke(T3Colors.border, lineWidth: 1) + } + + if let commentError { + Text(commentError) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + } + + HStack(spacing: 10) { + Button { + UIPasteboard.general.string = reviewDraft.prompt + } label: { + Label("Copy prompt", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .background(T3Colors.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .disabled(trimmedComment.isEmpty) + + Button { + sendComment() + } label: { + HStack(spacing: 7) { + if isSending { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.up") + } + Text("Send to agent") + } + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(T3Colors.accent) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .disabled(trimmedComment.isEmpty || isSending) + } + .font(T3Typography.control) + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 8) + .background(T3Colors.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(T3Colors.separator) + .frame(height: 1) + } + } + + private var trimmedComment: String { + comment.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var reviewDraft: FeatureReviewCommentDraft { + FeatureReviewCommentDraft(filePath: file.path, line: selectedLine, body: comment) + } + + private var commentLocation: String { + guard let selectedLine else { return file.path } + return "\(file.path) · \(selectedLine.side.rawValue) line \(selectedLine.line)" + } + + private func selection(for line: FeatureDiffLine) -> FeatureReviewLineSelection? { + if let newLine = line.newLine { + return FeatureReviewLineSelection(side: .new, line: newLine) + } + if let oldLine = line.oldLine { + return FeatureReviewLineSelection(side: .old, line: oldLine) + } + return nil + } + + private func openCommentComposer() { + isCommenting = true + commentError = nil + Task { @MainActor in + await Task.yield() + isCommentFocused = true + } + } + + private func hydrate() async { + isHydrating = true + defer { isHydrating = false } + guard let contents = try? await client.loadReviewFileContents( + threadID: threadID, + file: file + ) else { + return + } + renderedLines = FeatureFullDiffHydrator.lines(for: file, contents: contents) + } + + private func sendComment() { + guard !trimmedComment.isEmpty, !isSending else { return } + let prompt = reviewDraft.prompt + isSending = true + commentError = nil + Task { + do { + try await client.sendMessage(threadID: threadID, text: prompt, selection: nil) + comment = "" + selectedLine = nil + isCommenting = false + isCommentFocused = false + } catch { + commentError = error.localizedDescription + } + isSending = false + } + } +} + +private struct FeatureDiffLineRow: View { + let line: FeatureDiffLine + let isSelected: Bool + let minimumWidth: CGFloat + let select: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if line.kind == .hunk { + Text(line.text) + .foregroundStyle(.blue) + .padding(.horizontal, 10) + .fixedSize(horizontal: true, vertical: false) + } else { + lineNumber(line.oldLine) + lineNumber(line.newLine) + Text(prefix) + .foregroundStyle(prefixColor) + .frame(width: 18) + diffText + .fixedSize(horizontal: true, vertical: false) + .textSelection(.enabled) + .padding(.trailing, 12) + } + } + .font(T3Typography.code) + .fixedSize(horizontal: true, vertical: false) + .frame( + minWidth: minimumWidth, + minHeight: line.kind == .hunk ? 30 : 22, + alignment: .leading + ) + .background(isSelected ? T3Colors.accent.opacity(0.14) : background) + .overlay(alignment: .leading) { + if isSelected { + Rectangle() + .fill(T3Colors.accent) + .frame(width: 2) + } + } + .contentShape(Rectangle()) + .onTapGesture(perform: select) + .accessibilityAction(named: "Add review comment", select) + } + + private func lineNumber(_ value: Int?) -> some View { + Text(value.map(String.init) ?? "") + .foregroundStyle(.tertiary) + .frame(width: 48, alignment: .trailing) + .padding(.trailing, 7) + .accessibilityHidden(true) + } + + private var prefix: String { + switch line.kind { + case .addition: "+" + case .deletion: "−" + case .context, .hunk: " " + } + } + + private var prefixColor: Color { + switch line.kind { + case .addition: .green + case .deletion: .red + case .context, .hunk: .secondary + } + } + + @ViewBuilder + private var diffText: some View { + if let spans = line.spans, !spans.isEmpty { + HStack(spacing: 0) { + ForEach(spans.indices, id: \.self) { index in + let span = spans[index] + Text(verbatim: span.text.isEmpty ? " " : span.text) + .foregroundStyle(.primary) + .fontWeight(span.kind == .changed ? .semibold : .regular) + .background(span.kind == .changed ? changedSpanBackground : Color.clear) + } + } + } else { + Text(line.text.isEmpty ? " " : line.text) + .foregroundStyle(.primary) + } + } + + private var changedSpanBackground: Color { + switch line.kind { + case .addition: Color.green.opacity(0.28) + case .deletion: Color.red.opacity(0.28) + case .context, .hunk: Color.clear + } + } + + private var background: Color { + switch line.kind { + case .addition: Color.green.opacity(0.11) + case .deletion: Color.red.opacity(0.11) + case .hunk: Color.blue.opacity(0.08) + case .context: Color.clear + } + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift new file mode 100644 index 000000000000..26a20e5fa85c --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -0,0 +1,1726 @@ +import Foundation +import Observation + +private struct FeatureConnectionUnavailableError: LocalizedError { + var errorDescription: String? { + "Could not connect to the selected computer." + } +} + +enum FeatureDetailRenderChange: Equatable { + case full + case delta(FeatureDetailDelta) +} + +struct FeatureDetailRenderUpdate: Equatable { + let baseRevision: UInt64 + let revision: UInt64 + let change: FeatureDetailRenderChange +} + +enum FeatureThreadLoadState: Equatable { + case loading + case failed(String) +} + +@MainActor +@Observable +public final class FeatureRootModel { + private static let maximumRetainedThreadDetails = 6 + + private struct PendingSettlementMutation { + let id: UUID + let settled: Bool + let settledAt: Date? + let unsettledAt: Date? + + func apply(to thread: inout FeatureThread) { + thread.isSettled = settled + thread.keepsActive = !settled + thread.settlementFacts?.settlementOverride = settled ? .settled : .active + thread.settledAt = settledAt + thread.unsettledAt = unsettledAt + if settled { + thread.pinnedAt = nil + } + } + } + + public private(set) var snapshot = FeatureSnapshot() + private(set) var pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + private var pullRequestObservationIdentities: [String: String] = [:] + public private(set) var details: [String: FeatureThreadDetail] = [:] + private(set) var detailLoadStates: [String: FeatureThreadLoadState] = [:] + /// Advances whenever a Home presentation input changes. + public private(set) var homePresentationRevision: UInt64 = 0 + /// Advances when a Home-visible thread is inserted, removed, or changed. + public private(set) var threadCollectionRevision: UInt64 = 0 + /// Advances for any selected-thread metadata, message, approval, or input change. + public private(set) var detailRevision: UInt64 = 0 + /// The latest detail revision for each loaded thread. + public private(set) var detailRevisions: [String: UInt64] = [:] + private(set) var detailRenderUpdates: [String: FeatureDetailRenderUpdate] = [:] + public private(set) var isLoading = true + public private(set) var isPerformingAction = false + public private(set) var isManagingConnections = false + private(set) var isSigningOutT3Connect = false + public var errorMessage: String? + + let client: any FeatureClient + private let outboxStore: FeatureOutboxStore + private let draftStore: FeatureComposerDraftStore + @ObservationIgnored + public private(set) lazy var attachmentUploads = FeatureAttachmentUploadCoordinator( + client: client, + draftStore: draftStore + ) + private var pendingSubmissionsByID: [String: FeatureQueuedSubmission] = [:] + private var pendingThreadsByID: [String: FeatureThread] = [:] + private var pendingSettlementMutations: [String: PendingSettlementMutation] = [:] + private var pendingCompletionSubmissionIDs: Set = [] + private var pendingDiscardSubmissionIDs: Set = [] + private var detailRecency: [String] = [] + private var detailLoadGeneration: UInt64 = 0 + private var detailLoadRevisions: [String: UInt64] = [:] + private var detailLoadRequestRevision: UInt64 = 0 + private var activeDetailLoadRequests: [String: UInt64] = [:] + private var storedDetailLoadRequestRevisions: [String: UInt64] = [:] + private var detailMetadataRevisions: [String: UInt64] = [:] + private var outboxDrainTask: Task? + private var outboxRetryAttempt = 0 + private var outboxGeneration: UInt64 = 0 + + public init( + client: any FeatureClient, + outboxStore: FeatureOutboxStore = .shared, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.client = client + self.outboxStore = outboxStore + self.draftStore = draftStore + } + + public func start() async { + do { + install(try await client.initialSnapshot()) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + await restoreOutbox() + isLoading = false + scheduleOutboxDrain() + + for await event in client.events() { + apply(event) + } + } + + public func reload() async { + do { + install(try await client.initialSnapshot()) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + } + + /// Background refresh is deliberately separate from `reload()`: native + /// clients must not mount WebSocket streams or timers for a bounded BG task. + public func refreshInBackground() async -> Bool { + do { + install(try await client.backgroundSnapshot()) + return !Task.isCancelled + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + @discardableResult + public func refreshProviders(environmentID: String) async -> Bool { + await perform { + let providers = try await client.refreshProviders(environmentID: environmentID) + var byEnvironment = snapshot.providersByEnvironment ?? [:] + byEnvironment[environmentID] = providers + snapshot.providersByEnvironment = byEnvironment + } + } + + public func reloadAfterConnection() async { + clearDetails() + await reload() + } + + public func pair(endpoint: String, token: String?) async -> Bool { + await perform { + try await client.pair(endpoint: endpoint, token: token) + let next = try await client.initialSnapshot() + clearDetails() + install(next) + guard next.connection.state != .disconnected else { + throw FeatureConnectionUnavailableError() + } + } + } + + public func removeEnvironment(_ id: String) async { + var logicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.environmentID == id, project.repositoryIdentity != nil else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + let remainingLogicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.environmentID != id, project.repositoryIdentity != nil else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + logicalProjectIDs.subtract(remainingLogicalProjectIDs) + await stopOutboxDrain() + await perform { + try await client.removeEnvironment(id: id) + var cleanupError: (any Error)? + do { + try await outboxStore.removeAll(environmentID: id) + removePendingSubmissions(environmentID: id) + } catch { + markPendingSubmissionsForDiscard(environmentID: id) + cleanupError = error + } + do { + try await draftStore.removeDrafts( + environmentID: id, + logicalProjectIDs: logicalProjectIDs + ) + } catch { + cleanupError = cleanupError ?? error + } + if let cleanupError { + errorMessage = "Environment removed, but its queued messages or drafts could not be cleared: \(cleanupError.localizedDescription)" + } + install(try await client.initialSnapshot()) + clearDetails() + } + scheduleOutboxDrain() + } + + public func signOutT3Connect() async { + guard let capability = client as? any T3ConnectCapable else { return } + isSigningOutT3Connect = true + defer { isSigningOutT3Connect = false } + let removedEnvironmentIDs = snapshot.environments + .filter { $0.source == .t3Connect } + .map(\.id) + let removedEnvironmentIDSet = Set(removedEnvironmentIDs) + let groupedProjects = Dictionary( + grouping: snapshot.projects.filter { $0.repositoryIdentity != nil }, + by: \.environmentID + ) + let retainedLogicalProjectIDs = Set(snapshot.projects.compactMap { project in + guard project.repositoryIdentity != nil, + !removedEnvironmentIDSet.contains(project.environmentID) else { + return nil + } + return DailyUXCreationContext.logicalProjectID(for: project, in: snapshot) + }) + let logicalProjectIDs = removedEnvironmentIDs.reduce(into: [String: Set]()) { + result, environmentID in + let projectIDs = Set((groupedProjects[environmentID] ?? []).map { + DailyUXCreationContext.logicalProjectID(for: $0, in: snapshot) + }) + result[environmentID] = projectIDs.subtracting(retainedLogicalProjectIDs) + } + + await stopOutboxDrain() + await capability.signOutT3Connect() + for environmentID in removedEnvironmentIDs { + var cleanupError: (any Error)? + do { + try await outboxStore.removeAll(environmentID: environmentID) + } catch { + cleanupError = error + } + removePendingSubmissions(environmentID: environmentID) + do { + try await draftStore.removeDrafts( + environmentID: environmentID, + logicalProjectIDs: logicalProjectIDs[environmentID] ?? [] + ) + } catch { + cleanupError = cleanupError ?? error + } + if let cleanupError { + errorMessage = "Could not clear saved T3 Connect data: \(cleanupError.localizedDescription)" + } + } + clearDetails() + await reload() + scheduleOutboxDrain() + } + + func removeManagedEnvironmentsAfterAccountChange() async { + let managedIDs = snapshot.environments + .filter { $0.source == .t3Connect } + .map(\.id) + for id in managedIDs { + await removeEnvironment(id) + } + } + + @discardableResult + public func setEnvironmentEnabled(_ id: String, enabled: Bool) async -> Bool { + await stopOutboxDrain() + let succeeded = await perform { + try await client.setEnvironmentEnabled(id: id, enabled: enabled) + install(try await client.initialSnapshot()) + if !enabled { clearDetails() } + } + scheduleOutboxDrain() + return succeeded + } + + public func disconnect() async { + await stopOutboxDrain() + isManagingConnections = false + await client.disconnect() + let disconnectedEnvironments = snapshot.environments.map { environment in + var environment = environment + environment.connectionState = .disconnected + environment.connectionDetail = nil + return environment + } + install(FeatureSnapshot( + environments: disconnectedEnvironments, + settings: snapshot.settings + )) + clearDetails() + } + + public func setConnectionManagementPresented(_ isPresented: Bool) { + isManagingConnections = isPresented + } + + public func addProject(path: String) async -> Bool { + await perform { + try await client.addProject(path: path) + install(try await client.initialSnapshot()) + } + } + + public func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async -> FeatureThread? { + let environment = currentEnvironmentIdentity + var created: FeatureThread? + let succeeded = await perform { + let thread = try await client.createThread( + projectID: projectID, + title: title, + selection: selection + ) + guard currentEnvironmentIdentity == environment else { + throw CancellationError() + } + upsert(thread) + created = thread + } + return succeeded ? created : nil + } + + public func startTask(_ request: NewTaskRequest) async -> FeatureThread? { + let prompt = request.trimmedPrompt + guard !prompt.isEmpty || !request.attachments.isEmpty else { return nil } + guard request.workspaceMode != .worktree || request.branch != nil else { return nil } + + guard let project = snapshot.projects.first(where: { $0.id == request.projectID }) else { + errorMessage = "That project is no longer available." + return nil + } + let identity = FeatureSubmissionIdentity() + let threadID = FeatureScopedID.thread( + environmentID: project.environmentID, + wireID: identity.threadID + ) + let uploads = request.attachments.map(\.upload) + let queued = FeatureQueuedSubmission( + environmentID: project.environmentID, + identity: identity, + threadID: threadID, + text: prompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + attachments: uploads, + creation: FeatureQueuedCreation( + projectID: request.projectID, + projectName: project.name, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin + ) + ) + guard await enqueue(queued) else { return nil } + installPendingCreation(queued, project: project) + + isPerformingAction = true + defer { isPerformingAction = false } + do { + let thread = try await client.createThreadAndSend( + projectID: request.projectID, + prompt: prompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode.mobileNormalized, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin, + attachments: uploads, + identity: identity + ) + if !(await completeQueuedSubmission(queued)) { + scheduleOutboxRetry() + } + if thread.id != queued.threadID { + removeThread(id: queued.threadID) + removeDetail(id: queued.threadID) + } + upsert(thread) + return thread + } catch { + if Self.shouldQueue(error, environmentID: project.environmentID, snapshot: snapshot) { + if isEnvironmentConnected(project.environmentID) { + scheduleOutboxRetry() + } + return snapshot.threads.first { $0.id == threadID } + ?? pendingThreadsByID[threadID] + } + let discarded = await discardQueuedSubmission(queued) + if !discarded { + scheduleOutboxRetry() + } + if discarded, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return nil + } + } + + public func workspaceBranches( + projectID: String, + refresh: Bool = false + ) async throws -> [FeatureWorkspaceBranch] { + try await client.listWorkspaceBranches(projectID: projectID, refresh: refresh) + } + + public func renameThread(_ id: String, title: String) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.renameThread(id: id, title: title) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.title = title } + } + } + + public func regenerateThreadTitle(_ id: String) async { + await perform { + try await client.regenerateThreadTitle(id: id) + } + } + + public func setArchived(_ id: String, archived: Bool) async { + if archived, + let thread = snapshot.threads.first(where: { $0.id == id }), + [.queued, .working, .monitoring, .waitingForApproval, .waitingForInput] + .contains(thread.state) { + errorMessage = "This thread is still active. Stop it before archiving." + return + } + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadArchived(id: id, archived: archived) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.isArchived = archived } + } + } + + @discardableResult + public func setSettled(_ id: String, settled: Bool) async -> Bool { + guard let previous = snapshot.threads.first(where: { $0.id == id }) else { + return false + } + if settled, !previous.canSettleNow() { + errorMessage = "This thread still needs attention. Resolve or stop it first." + return false + } + + let environment = currentEnvironmentIdentity + let now = Date.now + let mutation = PendingSettlementMutation( + id: UUID(), + settled: settled, + settledAt: settled ? now : nil, + unsettledAt: settled ? nil : now + ) + pendingSettlementMutations[id] = mutation + mutateThread(id: id) { mutation.apply(to: &$0) } + + let succeeded = await perform { + try await client.setThreadSettled(id: id, settled: settled) + } + + guard pendingSettlementMutations[id]?.id == mutation.id else { return false } + pendingSettlementMutations.removeValue(forKey: id) + guard !succeeded else { return true } + guard currentEnvironmentIdentity == environment else { return false } + + mutateThread(id: id) { + guard $0.isSettled == settled, $0.settledAt == mutation.settledAt else { return } + $0.isSettled = previous.isSettled + $0.keepsActive = previous.keepsActive + $0.settlementFacts?.settlementOverride = previous.settlementFacts?.settlementOverride + $0.settledAt = previous.settledAt + $0.unsettledAt = previous.unsettledAt + $0.pinnedAt = previous.pinnedAt + } + return false + } + + public func setSnoozed(_ id: String, until: Date?) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadSnoozed(id: id, until: until) + guard currentEnvironmentIdentity == environment else { return } + let snoozedAt = until.map { _ in Date.now } + mutateThread(id: id) { + $0.snoozedUntil = until + $0.snoozedAt = snoozedAt + } + } + } + + public func setPinned(_ id: String, pinned: Bool) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.setThreadPinned(id: id, pinned: pinned) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { + $0.pinnedAt = pinned ? Date.now : nil + if pinned { + $0.snoozedUntil = nil + $0.snoozedAt = nil + } + } + } + } + + func updatePullRequest( + _ pullRequest: HomeThreadPullRequestPresentation?, + threadID: String, + observationIdentity: String + ) { + guard snapshot.threads.first(where: { $0.id == threadID })? + .pullRequestObservationIdentity == observationIdentity else { + return + } + if pullRequest == nil, pullRequestsByThreadID[threadID] == nil { return } + if pullRequestsByThreadID[threadID] == pullRequest, + pullRequestObservationIdentities[threadID] == observationIdentity { + return + } + if let pullRequest { + pullRequestsByThreadID[threadID] = pullRequest + pullRequestObservationIdentities[threadID] = observationIdentity + } else { + pullRequestsByThreadID.removeValue(forKey: threadID) + pullRequestObservationIdentities.removeValue(forKey: threadID) + } + homePresentationRevision &+= 1 + } + + func isEffectivelySettled(_ thread: FeatureThread) -> Bool { + thread.isEffectivelySettled() + } + + public func setRuntimeMode(_ id: String, mode: FeatureRuntimeMode) async { + guard let environmentID = snapshot.threads.first(where: { $0.id == id })?.environmentID else { + return + } + await perform { + try await client.setRuntimeMode(id: id, mode: mode) + guard snapshot.threads.first(where: { $0.id == id })?.environmentID == environmentID else { + return + } + mutateThread(id: id) { $0.runtimeMode = mode } + } + } + + public func setInteractionMode(_ id: String, mode: FeatureInteractionMode) async { + let mode = mode.mobileNormalized + let environment = currentEnvironmentIdentity + await perform { + try await client.setInteractionMode(id: id, mode: mode) + guard currentEnvironmentIdentity == environment else { return } + mutateThread(id: id) { $0.interactionMode = mode } + } + } + + public func deleteThread(_ id: String) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.deleteThread(id: id) + guard currentEnvironmentIdentity == environment else { return } + removeThread(id: id) + removeDetail(id: id) + } + } + + public func detail(for id: String, force: Bool = false) async -> FeatureThreadDetail? { + if !force, let cached = details[id] { + return cached + } + let environment = currentEnvironmentIdentity + let loadGenerationBeforeLoad = detailLoadGeneration + let loadRevisionBeforeLoad = detailLoadRevisions[id] + let metadataRevisionBeforeLoad = detailMetadataRevisions[id] + let threadBeforeLoad = snapshot.threads.first { $0.id == id } + detailLoadRequestRevision &+= 1 + let loadRequestRevision = detailLoadRequestRevision + activeDetailLoadRequests[id] = loadRequestRevision + detailLoadStates[id] = .loading + defer { + if activeDetailLoadRequests[id] == loadRequestRevision { + activeDetailLoadRequests[id] = nil + if detailLoadStates[id] == .loading { + detailLoadStates[id] = nil + } + } + } + do { + var detail = try await client.loadThread(id: id) + guard currentEnvironmentIdentity == environment else { + return details[id] + } + if detailLoadGeneration != loadGenerationBeforeLoad + || detailLoadRevisions[id] != loadRevisionBeforeLoad { + return details[id] + } + if let storedLoadRequestRevision = storedDetailLoadRequestRevisions[id], + loadRequestRevision < storedLoadRequestRevision { + return details[id] + } + let currentThread = snapshot.threads.first { $0.id == id } + if detailMetadataRevisions[id] != metadataRevisionBeforeLoad { + if let currentThread = details[id]?.thread ?? currentThread { + detail.thread = currentThread + } + } else if let currentThread, currentThread != threadBeforeLoad { + detail.thread = currentThread + } + store(detail, invalidatesInFlightLoad: false) + storedDetailLoadRequestRevisions[id] = loadRequestRevision + upsert(detail.thread) + return detail + } catch { + if !Self.isBenignCancellation(error), + activeDetailLoadRequests[id] == loadRequestRevision, + detailLoadGeneration == loadGenerationBeforeLoad, + detailLoadRevisions[id] == loadRevisionBeforeLoad, + currentEnvironmentIdentity == environment { + detailLoadStates[id] = .failed(error.localizedDescription) + if details[id] == nil { + errorMessage = error.localizedDescription + } + } + return details[id] + } + } + + public func loadEarlierTurns(for id: String) async { + guard details[id]?.page?.hasMore == true, + details[id]?.page?.isLoading != true else { return } + let environment = currentEnvironmentIdentity + do { + guard let detail = try await client.loadEarlierThreadTurns(id: id), + currentEnvironmentIdentity == environment else { return } + store(detail, invalidatesInFlightLoad: false) + } catch { + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + } + } + + /// Ends any selected-thread transport work when its detail view closes. + public func releaseThread(_ id: String) { + client.releaseThread(id: id) + markDetailRecentlyUsed(id) + evictOldThreadDetailsIfNeeded() + } + + public func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async -> Bool { + await sendMessage( + FeatureMessageSubmission( + threadID: threadID, + text: text, + selection: selection + ) + ) + } + + public func sendMessage(_ submission: FeatureMessageSubmission) async -> Bool { + let trimmed = submission.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !submission.attachments.isEmpty else { return false } + + guard let thread = snapshot.threads.first(where: { $0.id == submission.threadID }), + let environmentID = thread.environmentID else { + return false + } + let identity = FeatureSubmissionIdentity(threadID: thread.wireID ?? thread.id) + let uploads = submission.attachments.map(\.upload) + let queued = FeatureQueuedSubmission( + environmentID: environmentID, + identity: identity, + threadID: submission.threadID, + text: trimmed, + selection: submission.selection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + attachments: uploads + ) + guard await enqueue(queued) else { return false } + + let optimistic = FeatureMessage( + id: identity.messageID, + role: .user, + text: trimmed, + createdAt: identity.createdAt, + state: .queued, + attachments: submission.attachments.map { + FeatureMessageAttachment( + id: $0.id.uuidString, + name: $0.filename, + mimeType: $0.mimeType, + sizeBytes: $0.byteCount, + previewData: $0.thumbnailData + ) + } + ) + mutateDetail( + id: submission.threadID, + change: .delta(FeatureDetailDelta( + changedMessages: [optimistic], + appendedMessageIDs: [optimistic.id] + )) + ) { + $0.messages.append(optimistic) + } + + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await client.sendMessage( + threadID: submission.threadID, + text: trimmed, + selection: submission.selection, + runtimeMode: queued.runtimeMode, + attachments: uploads, + identity: identity + ) + if !(await completeQueuedSubmission(queued)) { + scheduleOutboxRetry() + } + return true + } catch { + if Self.shouldQueue(error, environmentID: environmentID, snapshot: snapshot) { + if isEnvironmentConnected(environmentID) { + scheduleOutboxRetry() + } + return true + } + let discarded = await discardQueuedSubmission(queued) + if !discarded { + scheduleOutboxRetry() + } + if discarded, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + public func cancelTurn(threadID: String) async { + if pendingSubmissionsByID.values.contains(where: { + $0.threadID == threadID && $0.creation != nil + }) { + await stopOutboxDrain() + let queued = pendingSubmissionsByID.values.filter { $0.threadID == threadID } + for submission in queued { + if !(await discardQueuedSubmission(submission)) { + scheduleOutboxRetry() + } + } + if pendingThreadsByID[threadID] == nil, + snapshot.threads.contains(where: { $0.id == threadID }) { + await perform { + try await client.cancelTurn(threadID: threadID) + } + } + scheduleOutboxDrain() + return + } + await perform { + try await client.cancelTurn(threadID: threadID) + } + } + + public func resolveApproval(_ id: String, decision: FeatureApprovalDecision) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.resolveApproval(id: id, decision: decision) + guard currentEnvironmentIdentity == environment else { return } + // Only touch details that actually hold the request; mutateDetail + // deep-compares each mutated detail and the cache never shrinks. + for key in Array(details.keys) + where details[key]?.approvals.contains(where: { $0.id == id }) == true { + mutateDetail( + id: key, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { + $0.approvals.removeAll { $0.id == id } + } + } + } + } + + public func resolveUserInput(_ id: String, answers: [String: FeatureInputAnswer]) async { + let environment = currentEnvironmentIdentity + await perform { + try await client.resolveUserInput(id: id, answers: answers) + guard currentEnvironmentIdentity == environment else { return } + for key in Array(details.keys) + where details[key]?.userInputs.contains(where: { $0.id == id }) == true { + mutateDetail( + id: key, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { + $0.userInputs.removeAll { $0.id == id } + } + } + } + } + + /// Convenience for callers that only submit free-form or single-select text. + public func resolveUserInput(_ id: String, answers: [String: String]) async { + await resolveUserInput( + id, + answers: answers.mapValues(FeatureInputAnswer.text) + ) + } + + @discardableResult + public func saveSettings(_ settings: FeatureSettings) async -> Bool { + await perform { + try await client.saveSettings(settings) + snapshot.settings = settings + } + } + + @discardableResult + public func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async -> Bool { + await perform { + let updated = try await client.updateAutomaticSettlement( + environmentID: environmentID, + change: change + ) + guard var preferences = snapshot.preferencesByEnvironment?[environmentID], + preferences.automaticSettlement != nil else { + return + } + preferences.automaticSettlement = updated + snapshot.preferencesByEnvironment?[environmentID] = preferences + } + } + + /// Applies appearance optimistically so selecting a theme updates every + /// surface immediately, then persists just that preference in the current + /// settings snapshot. Other unsaved Settings edits remain drafts. + @discardableResult + public func saveAppearance(_ appearance: FeatureAppearance) async -> Bool { + let previous = snapshot.settings + guard previous.appearance != appearance else { return true } + + var updated = previous + updated.appearance = appearance + snapshot.settings = updated + + do { + try await client.saveSettings(updated) + return true + } catch { + if snapshot.settings == updated { + snapshot.settings = previous + } + if !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + @discardableResult + private func perform( + reportError: Bool = true, + _ operation: () async throws -> Void + ) async -> Bool { + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await operation() + return true + } catch { + if reportError, !Self.isBenignCancellation(error) { + errorMessage = error.localizedDescription + } + return false + } + } + + private static func isBenignCancellation(_ error: any Error) -> Bool { + if error is CancellationError { return true } + let message = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return message == "cancelled" || message == "canceled" + } + + private var currentEnvironmentIdentity: String { + snapshot.environments + .sorted { $0.id < $1.id } + .map { "\($0.id)|\($0.endpoint)|\($0.isEnabled)" } + .joined(separator: ";") + } + + private func apply(_ event: FeatureEvent) { + switch event { + case let .snapshot(value): + install(value) + case let .connection(value): + guard snapshot.connection != value else { return } + snapshot.connection = value + homePresentationRevision &+= 1 + if value.state == .connected { + scheduleOutboxDrain() + } + case let .thread(value): + pendingThreadsByID.removeValue(forKey: value.id) + upsert(value) + case let .threadRemoved(id): + removeThread(id: id) + removeDetail(id: id) + case let .detail(value): + pendingThreadsByID.removeValue(forKey: value.thread.id) + store(value) + upsert(value.thread) + case let .detailDelta(value, delta): + pendingThreadsByID.removeValue(forKey: value.thread.id) + store(value, delta: delta) + upsert(value.thread) + case let .failure(message): + errorMessage = message + } + } + + private func upsert(_ thread: FeatureThread) { + let thread = retainingPendingSettlement(in: thread) + discardStalePullRequest(for: thread) + var metadataChanged = false + if let index = snapshot.threads.firstIndex(where: { $0.id == thread.id }) { + let previous = snapshot.threads[index] + if previous != thread { + snapshot.threads[index] = thread + metadataChanged = true + if previous.projectID != thread.projectID { + adjustProjectCount(id: previous.projectID, by: -1) + adjustProjectCount(id: thread.projectID, by: 1) + } + } + } else { + snapshot.threads.append(thread) + adjustProjectCount(id: thread.projectID, by: 1) + metadataChanged = true + } + if metadataChanged { + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + let detailChanged = mutateDetail( + id: thread.id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + $0.thread = thread + } + if metadataChanged || detailChanged { + bumpDetailMetadataRevision(id: thread.id) + } + } + + private func removeThread(id: String) { + guard let index = snapshot.threads.firstIndex(where: { $0.id == id }) else { return } + let projectID = snapshot.threads[index].projectID + snapshot.threads.remove(at: index) + pullRequestsByThreadID.removeValue(forKey: id) + pullRequestObservationIdentities.removeValue(forKey: id) + adjustProjectCount(id: projectID, by: -1) + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + + private func adjustProjectCount(id: String, by delta: Int) { + guard let index = snapshot.projects.firstIndex(where: { $0.id == id }) else { return } + snapshot.projects[index].threadCount = max(0, snapshot.projects[index].threadCount + delta) + } + + private func install(_ value: FeatureSnapshot) { + var value = value + for index in value.threads.indices { + value.threads[index] = retainingPendingSettlement(in: value.threads[index]) + } + let authoritativeThreadIDs = Set(value.threads.map(\.id)) + for id in authoritativeThreadIDs { + pendingThreadsByID.removeValue(forKey: id) + } + for pending in pendingThreadsByID.values where !authoritativeThreadIDs.contains(pending.id) { + value.threads.append(pending) + if let index = value.projects.firstIndex(where: { $0.id == pending.projectID }) { + value.projects[index].threadCount += 1 + } + } + + let previousThreads = snapshot.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + let nextThreads = value.threads.reduce(into: [String: FeatureThread]()) { + $0[$1.id] = $1 + } + for thread in value.threads { + discardStalePullRequest(for: thread) + } + for id in Array(pullRequestsByThreadID.keys) where nextThreads[id] == nil { + pullRequestsByThreadID.removeValue(forKey: id) + pullRequestObservationIdentities.removeValue(forKey: id) + } + for id in previousThreads.keys where nextThreads[id] == nil { + removeDetail(id: id) + } + for thread in value.threads where previousThreads[thread.id] != thread { + mutateDetail( + id: thread.id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + $0.thread = thread + } + bumpDetailMetadataRevision(id: thread.id) + } + + if snapshot.connection != value.connection + || snapshot.environments != value.environments + || snapshot.projects != value.projects + || snapshot.providers != value.providers + || snapshot.providersByEnvironment != value.providersByEnvironment + || snapshot.preferencesByEnvironment != value.preferencesByEnvironment + || snapshot.threads != value.threads { + homePresentationRevision &+= 1 + } + if snapshot.threads != value.threads { + threadCollectionRevision &+= 1 + } + snapshot = value + if value.connection.state == .connected + || value.environments.contains(where: { $0.connectionState == .connected }) { + scheduleOutboxDrain() + } + } + + private func discardStalePullRequest(for thread: FeatureThread) { + guard let cachedIdentity = pullRequestObservationIdentities[thread.id], + cachedIdentity != thread.pullRequestObservationIdentity else { + return + } + pullRequestsByThreadID.removeValue(forKey: thread.id) + pullRequestObservationIdentities.removeValue(forKey: thread.id) + } + + private func mutateThread( + id: String, + _ mutation: (inout FeatureThread) -> Void + ) { + var metadataChanged = false + if let index = snapshot.threads.firstIndex(where: { $0.id == id }) { + let previous = snapshot.threads[index] + mutation(&snapshot.threads[index]) + if snapshot.threads[index] != previous { + metadataChanged = true + threadCollectionRevision &+= 1 + homePresentationRevision &+= 1 + } + } + let detailChanged = mutateDetail( + id: id, + change: .delta(FeatureDetailDelta(changedMessages: [])), + invalidatesInFlightLoad: false + ) { + mutation(&$0.thread) + } + if metadataChanged || detailChanged { + bumpDetailMetadataRevision(id: id) + } + } + + private func store( + _ incoming: FeatureThreadDetail, + invalidatesInFlightLoad: Bool = true + ) { + var incoming = retainingLocalAttachmentPreviews(in: incoming) + incoming.thread = retainingPendingSettlement(in: incoming.thread) + let id = incoming.thread.id + acknowledgeDeliveredMessages(incoming.messages) + let prepared = addingPendingMessages(to: incoming) + let next = details[id].map { current in + FeatureThreadDetail( + thread: prepared.thread, + messages: replacingChangedSuffix(current.messages, with: prepared.messages), + approvals: replacingChangedSuffix(current.approvals, with: prepared.approvals), + userInputs: replacingChangedSuffix(current.userInputs, with: prepared.userInputs), + page: prepared.page, + activeSubagentCount: prepared.activeSubagentCount, + backgroundWorkIsActive: prepared.backgroundWorkIsActive + ) + } ?? prepared + guard details[id] != next else { return } + details[id] = next + markDetailRecentlyUsed(id) + if invalidatesInFlightLoad { + bumpDetailLoadRevision(id: id) + } + bumpDetailRevision(id: id, change: .full) + } + + private func store(_ incoming: FeatureThreadDetail, delta: FeatureDetailDelta) { + var incoming = retainingLocalAttachmentPreviews(in: incoming) + incoming.thread = retainingPendingSettlement(in: incoming.thread) + let id = incoming.thread.id + acknowledgeDeliveredMessages(incoming.messages) + let next = addingPendingMessages(to: incoming) + details[id] = next + markDetailRecentlyUsed(id) + bumpDetailLoadRevision(id: id) + let appended = next.messages.dropFirst(incoming.messages.count).map(\.id) + let pendingDelta = FeatureDetailDelta( + changedMessages: delta.changedMessages + next.messages.dropFirst(incoming.messages.count), + appendedMessageIDs: delta.appendedMessageIDs + appended + ) + bumpDetailRevision(id: id, change: .delta(pendingDelta)) + } + + private func retainingPendingSettlement(in thread: FeatureThread) -> FeatureThread { + guard let mutation = pendingSettlementMutations[thread.id] else { return thread } + var thread = thread + mutation.apply(to: &thread) + return thread + } + + @discardableResult + private func mutateDetail( + id: String, + change: FeatureDetailRenderChange = .full, + invalidatesInFlightLoad: Bool = true, + _ mutation: (inout FeatureThreadDetail) -> Void + ) -> Bool { + guard var detail = details[id] else { return false } + let previous = detail + mutation(&detail) + guard detail != previous else { return false } + details[id] = detail + markDetailRecentlyUsed(id) + if invalidatesInFlightLoad { + bumpDetailLoadRevision(id: id) + } + bumpDetailRevision(id: id, change: change) + return true + } + + private func removeDetail(id: String) { + if details.removeValue(forKey: id) != nil { + detailRecency.removeAll { $0 == id } + } + storedDetailLoadRequestRevisions.removeValue(forKey: id) + activeDetailLoadRequests.removeValue(forKey: id) + detailLoadStates.removeValue(forKey: id) + bumpDetailLoadRevision(id: id) + bumpDetailRevision(id: id, change: .full) + } + + private func clearDetails() { + detailLoadGeneration &+= 1 + detailLoadRevisions.removeAll() + storedDetailLoadRequestRevisions.removeAll() + activeDetailLoadRequests.removeAll() + detailLoadStates.removeAll() + detailMetadataRevisions.removeAll() + let hadDetails = !details.isEmpty + details.removeAll() + detailRecency.removeAll() + if hadDetails { + detailRevision &+= 1 + } + detailRevisions.removeAll() + detailRenderUpdates.removeAll() + } + + private func bumpDetailLoadRevision(id: String) { + detailLoadRevisions[id] = (detailLoadRevisions[id] ?? 0) &+ 1 + } + + private func bumpDetailMetadataRevision(id: String) { + detailMetadataRevisions[id] = (detailMetadataRevisions[id] ?? 0) &+ 1 + } + + private func markDetailRecentlyUsed(_ id: String) { + detailRecency.removeAll { $0 == id } + detailRecency.append(id) + } + + private func evictOldThreadDetailsIfNeeded() { + let protected = Set(pendingSubmissionsByID.values.map(\.threadID)) + while details.count > Self.maximumRetainedThreadDetails, + let candidate = detailRecency.first(where: { !protected.contains($0) }) { + detailRecency.removeAll { $0 == candidate } + removeDetail(id: candidate) + } + } + + private func bumpDetailRevision(id: String, change: FeatureDetailRenderChange) { + let baseRevision = detailRevisions[id] ?? 0 + detailRevision &+= 1 + detailRevisions[id] = detailRevision + detailRenderUpdates[id] = FeatureDetailRenderUpdate( + baseRevision: baseRevision, + revision: detailRevision, + change: change + ) + } + + private func replacingChangedSuffix( + _ current: [Element], + with incoming: [Element] + ) -> [Element] { + guard current != incoming else { return current } + let prefixCount = zip(current, incoming).prefix { pair in + pair.0 == pair.1 + }.count + var result = current + result.replaceSubrange(prefixCount..., with: incoming.dropFirst(prefixCount)) + return result + } + + private func restoreOutbox() async { + let submissions: [FeatureQueuedSubmission] + do { + submissions = try await outboxStore.submissions() + } catch { + errorMessage = "Could not restore queued messages: \(error.localizedDescription)" + return + } + + for submission in submissions { + setAttachmentOutboxOwnership(true, for: submission) + if let creation = submission.creation { + if snapshot.threads.contains(where: { $0.id == submission.threadID }) { + pendingSubmissionsByID[submission.id] = submission + if let detail = details[submission.threadID] { + store(addingPendingMessages(to: detail)) + } + continue + } + guard let project = snapshot.projects.first(where: { + $0.id == creation.projectID && $0.environmentID == submission.environmentID + }) else { + if isEnvironmentConnected(submission.environmentID) { + await discardRestoredSubmission(submission) + } else { + pendingSubmissionsByID[submission.id] = submission + } + continue + } + pendingSubmissionsByID[submission.id] = submission + installPendingCreation(submission, project: project) + continue + } + + guard snapshot.threads.contains(where: { $0.id == submission.threadID }) else { + if pendingThreadsByID[submission.threadID] != nil { + pendingSubmissionsByID[submission.id] = submission + } else if isEnvironmentConnected(submission.environmentID) { + await discardRestoredSubmission(submission) + } else { + pendingSubmissionsByID[submission.id] = submission + } + continue + } + pendingSubmissionsByID[submission.id] = submission + if let detail = details[submission.threadID] { + store(addingPendingMessages(to: detail)) + } + } + } + + private func discardRestoredSubmission(_ submission: FeatureQueuedSubmission) async { + pendingSubmissionsByID[submission.id] = submission + await discardQueuedSubmission(submission) + } + + private func enqueue(_ submission: FeatureQueuedSubmission) async -> Bool { + do { + try await outboxStore.enqueue(submission) + pendingSubmissionsByID[submission.id] = submission + setAttachmentOutboxOwnership(true, for: submission) + return true + } catch { + errorMessage = "Could not safely queue this message: \(error.localizedDescription)" + return false + } + } + + private func installPendingCreation( + _ submission: FeatureQueuedSubmission, + project: FeatureProject + ) { + guard let creation = submission.creation else { return } + let provider = provider( + id: submission.selection?.providerID, + environmentID: submission.environmentID + ) + let environmentName = snapshot.environments.first { + $0.id == submission.environmentID + }?.name + let title = submission.text + .split(whereSeparator: \.isNewline) + .first + .map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let thread = FeatureThread( + id: submission.threadID, + wireID: submission.identity.threadID, + projectID: project.id, + environmentID: submission.environmentID, + environmentName: environmentName, + title: title?.isEmpty == false ? title! : "New task", + preview: submission.text, + branch: creation.branch, + worktreePath: creation.worktreePath, + createdAt: submission.identity.createdAt, + updatedAt: submission.identity.createdAt, + state: .queued, + providerID: submission.selection?.providerID, + providerName: provider?.name, + modelID: submission.selection?.modelID, + runtimeMode: submission.runtimeMode, + interactionMode: submission.interactionMode + ) + pendingThreadsByID[thread.id] = thread + upsert(thread) + store(FeatureThreadDetail( + thread: thread, + messages: [queuedMessage(for: submission)] + )) + } + + private func provider(id: String?, environmentID: String) -> FeatureProvider? { + guard let id else { return nil } + let providers = snapshot.providersByEnvironment?[environmentID] ?? [] + return providers.first { $0.id == id } + } + + private func queuedMessage(for submission: FeatureQueuedSubmission) -> FeatureMessage { + FeatureMessage( + id: submission.identity.messageID, + role: .user, + text: submission.text, + createdAt: submission.identity.createdAt, + state: .queued, + attachments: submission.attachments.enumerated().map { index, attachment in + FeatureMessageAttachment( + id: "\(submission.id)-attachment-\(index)", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.byteCount ?? attachment.data?.count ?? 0 + ) + } + ) + } + + private func addingPendingMessages(to incoming: FeatureThreadDetail) -> FeatureThreadDetail { + let queued = pendingSubmissionsByID.values + .filter { $0.threadID == incoming.thread.id } + .sorted { $0.identity.createdAt < $1.identity.createdAt } + guard !queued.isEmpty else { return incoming } + var result = incoming + let existing = Set(result.messages.map(\.id)) + result.messages.append(contentsOf: queued.lazy + .filter { !existing.contains($0.identity.messageID) } + .map(queuedMessage(for:))) + return result + } + + private func retainingLocalAttachmentPreviews( + in incoming: FeatureThreadDetail + ) -> FeatureThreadDetail { + guard let current = details[incoming.thread.id] else { return incoming } + let currentMessages = current.messages.reduce(into: [String: FeatureMessage]()) { + $0[$1.id] = $1 + } + var result = incoming + result.messages = incoming.messages.map { message in + guard let local = currentMessages[message.id], !message.attachments.isEmpty else { + return message + } + var message = message + message.attachments = message.attachments.enumerated().map { index, attachment in + guard attachment.previewData == nil else { return attachment } + let matching = local.attachments.first { candidate in + candidate.id == attachment.id + } ?? ( + local.attachments.indices.contains(index) + ? local.attachments[index] + : nil + ) + guard let previewData = matching?.previewData else { return attachment } + var attachment = attachment + attachment.previewData = previewData + return attachment + } + return message + } + return result + } + + private func acknowledgeDeliveredMessages(_ messages: [FeatureMessage]) { + // Runs on every detail publish; skip the full message-ID scan in the + // common case where nothing is waiting in the outbox. + guard !pendingSubmissionsByID.isEmpty else { return } + // Local optimistic rows reuse the final message ID but are not proof + // that the server accepted the turn. Only authoritative, non-queued + // rows can retire a durable outbox entry. + let messageIDs = Set(messages.lazy + .filter { $0.state != .queued } + .map(\.id)) + let delivered = pendingSubmissionsByID.values.filter { + messageIDs.contains($0.identity.messageID) + } + for submission in delivered { + scheduleQueuedSubmissionCompletion(submission) + } + } + + private func scheduleQueuedSubmissionCompletion(_ submission: FeatureQueuedSubmission) { + guard pendingCompletionSubmissionIDs.insert(submission.id).inserted else { return } + pendingDiscardSubmissionIDs.remove(submission.id) + Task { @MainActor [weak self] in + guard let self else { return } + if !(await self.completeQueuedSubmission(submission)) { + self.scheduleOutboxRetry() + } + } + } + + @discardableResult + private func completeQueuedSubmission(_ submission: FeatureQueuedSubmission) async -> Bool { + pendingCompletionSubmissionIDs.insert(submission.id) + pendingDiscardSubmissionIDs.remove(submission.id) + do { + try await outboxStore.remove(id: submission.id) + } catch { + errorMessage = "The message was delivered, but its queued copy could not be cleared: \(error.localizedDescription)" + return false + } + pendingCompletionSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + pendingThreadsByID.removeValue(forKey: submission.threadID) + markQueuedMessageDelivered(submission) + outboxRetryAttempt = 0 + return true + } + + private func markQueuedMessageDelivered(_ submission: FeatureQueuedSubmission) { + mutateDetail( + id: submission.threadID, + change: .delta(FeatureDetailDelta(changedMessages: [])) + ) { detail in + guard let index = detail.messages.firstIndex(where: { + $0.id == submission.identity.messageID + }) else { return } + detail.messages[index].state = .complete + } + } + + @discardableResult + private func discardQueuedSubmission(_ submission: FeatureQueuedSubmission) async -> Bool { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.insert(submission.id) + do { + try await outboxStore.remove(id: submission.id) + } catch { + errorMessage = "Could not remove the queued message: \(error.localizedDescription)" + return false + } + pendingDiscardSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + let wasPendingCreation = pendingThreadsByID.removeValue(forKey: submission.threadID) != nil + if wasPendingCreation { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } else { + mutateDetail(id: submission.threadID) { + $0.messages.removeAll { $0.id == submission.identity.messageID } + } + } + return true + } + + private func setAttachmentOutboxOwnership( + _ owned: Bool, + for submission: FeatureQueuedSubmission + ) { + if owned { + attachmentUploads.syncOutboxOwner( + ownerID: submission.id, + environmentID: submission.environmentID, + attachmentIDs: submission.attachments.map(\.id) + ) + } else { + attachmentUploads.removeOutboxOwner(ownerID: submission.id) + } + } + + private func removePendingSubmissions(environmentID: String) { + let removed = pendingSubmissionsByID.values.filter { + $0.environmentID == environmentID + } + for submission in removed { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.remove(submission.id) + pendingSubmissionsByID.removeValue(forKey: submission.id) + setAttachmentOutboxOwnership(false, for: submission) + if pendingThreadsByID.removeValue(forKey: submission.threadID) != nil { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } else { + mutateDetail(id: submission.threadID) { + $0.messages.removeAll { $0.id == submission.identity.messageID } + } + } + } + } + + private func markPendingSubmissionsForDiscard(environmentID: String) { + for submission in pendingSubmissionsByID.values where submission.environmentID == environmentID { + pendingCompletionSubmissionIDs.remove(submission.id) + pendingDiscardSubmissionIDs.insert(submission.id) + } + } + + private func scheduleOutboxDrain(after delay: Duration = .zero) { + guard outboxDrainTask == nil, !pendingSubmissionsByID.isEmpty else { return } + let generation = outboxGeneration + outboxDrainTask = Task { @MainActor [weak self] in + if delay > .zero { + try? await Task.sleep(for: delay) + } + guard !Task.isCancelled, + let self, + self.outboxGeneration == generation else { return } + let needsRetry = await self.drainOutbox(generation: generation) + self.outboxDrainTask = nil + if needsRetry, + !Task.isCancelled, + self.outboxGeneration == generation { + self.scheduleOutboxRetry() + } + } + } + + private func stopOutboxDrain() async { + outboxGeneration &+= 1 + guard let task = outboxDrainTask else { return } + task.cancel() + await task.value + outboxDrainTask = nil + } + + private func scheduleOutboxRetry() { + guard outboxDrainTask == nil else { return } + let seconds = min(16, 1 << min(outboxRetryAttempt, 4)) + outboxRetryAttempt += 1 + scheduleOutboxDrain(after: .seconds(seconds)) + } + + private func drainOutbox(generation: UInt64) async -> Bool { + let submissions = pendingSubmissionsByID.values.sorted { + $0.identity.createdAt < $1.identity.createdAt + } + var needsRetry = false + for submission in submissions where pendingSubmissionsByID[submission.id] != nil { + guard !Task.isCancelled, outboxGeneration == generation else { return false } + if pendingCompletionSubmissionIDs.contains(submission.id) { + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + continue + } + if pendingDiscardSubmissionIDs.contains(submission.id) { + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } + continue + } + var policySnapshot = snapshot + if pendingThreadsByID[submission.threadID] != nil { + policySnapshot.threads.removeAll { $0.id == submission.threadID } + } + switch FeatureOutboxPolicy.decision( + for: submission, + snapshot: policySnapshot, + pendingCreationThreadIDs: Set( + pendingSubmissionsByID.values.compactMap { + $0.creation == nil ? nil : $0.threadID + } + ) + ) { + case .discard: + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } + case .wait: + // Connectivity and snapshot events wake the drain immediately. + // Avoid a permanent timer while the owning device is offline. + continue + case .send: + do { + guard pendingSubmissionsByID[submission.id] != nil, + snapshot.environments.contains(where: { + $0.id == submission.environmentID + }) else { + continue + } + if let creation = submission.creation { + let thread = try await client.createThreadAndSend( + projectID: creation.projectID, + prompt: submission.text, + selection: submission.selection, + runtimeMode: submission.runtimeMode, + interactionMode: submission.interactionMode, + workspaceMode: creation.workspaceMode, + branch: creation.branch, + worktreePath: creation.worktreePath, + startFromOrigin: creation.startFromOrigin, + attachments: submission.uploads, + identity: submission.identity + ) + guard !Task.isCancelled, + outboxGeneration == generation else { return false } + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + if thread.id != submission.threadID { + removeThread(id: submission.threadID) + removeDetail(id: submission.threadID) + } + upsert(thread) + } else { + try await client.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection, + runtimeMode: submission.runtimeMode, + attachments: submission.uploads, + identity: submission.identity + ) + guard !Task.isCancelled, + outboxGeneration == generation else { return false } + if !(await completeQueuedSubmission(submission)) { + needsRetry = true + } + } + } catch { + if Self.shouldQueue( + error, + environmentID: submission.environmentID, + snapshot: snapshot + ) { + needsRetry = true + } else { + if !(await discardQueuedSubmission(submission)) { + needsRetry = true + } else { + errorMessage = error.localizedDescription + } + } + } + } + } + return needsRetry + } + + private func isEnvironmentConnected(_ environmentID: String) -> Bool { + guard let environment = snapshot.environments.first(where: { $0.id == environmentID }) else { + return false + } + return environment.isEnabled && environment.connectionState == .connected + } + + static func shouldQueue( + _ error: any Error, + environmentID: String, + snapshot: FeatureSnapshot + ) -> Bool { + if error is CancellationError || error is URLError { return true } + if let rpcError = error as? RPCError, + case .responseTimedOut = rpcError { + return true + } + if let environment = snapshot.environments.first(where: { $0.id == environmentID }) { + let disconnected = !environment.isEnabled + || environment.connectionState != .connected + if disconnected { return true } + } + let message = error.localizedDescription.lowercased() + return [ + "cancelled", "canceled", "connection", "network", "offline", + "socket", "timed out", "timeout", "transport", "not connected", + "request deadline", + ].contains { message.contains($0) } + } +} + +private extension FeatureDraftAttachment { + var upload: FeatureUploadAttachment { + FeatureUploadAttachment(self) + } +} diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift new file mode 100644 index 000000000000..3505dca51577 --- /dev/null +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +public struct FeatureRootView: View { + @State private var model: FeatureRootModel + private let navigationRequest: FeatureWorkspaceNavigationRequest? + private let onNavigationRequestConsumed: @MainActor (UUID) -> Void + + public init(client: any FeatureClient) { + _model = State(initialValue: FeatureRootModel(client: client)) + navigationRequest = nil + onNavigationRequestConsumed = { _ in } + } + + init( + model: FeatureRootModel, + navigationRequest: FeatureWorkspaceNavigationRequest? = nil, + onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void = { _ in } + ) { + _model = State(initialValue: model) + self.navigationRequest = navigationRequest + self.onNavigationRequestConsumed = onNavigationRequestConsumed + } + + public var body: some View { + Group { + if model.isLoading { + FeatureLoadingView() + } else if shouldShowWorkspace { + WorkspaceView( + model: model, + navigationRequest: navigationRequest, + onNavigationRequestConsumed: onNavigationRequestConsumed, + submitNewTask: { request in + await model.startTask(request) + }, + submitMessage: { submission in + await model.sendMessage(submission) + } + ) + } else { + ConnectionOnboardingView(model: model) + } + } + .preferredColorScheme(preferredColorScheme) + .tint(T3Colors.accent) + .background(T3Colors.background.ignoresSafeArea()) + .task { await model.start() } + .alert( + "Something went wrong", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ), + actions: { + Button("OK") { model.errorMessage = nil } + }, + message: { + Text(model.errorMessage ?? "Unknown error") + } + ) + } + + /// Keep the last-known workspace visible through a degraded connection. + /// Connection management also stays mounted while saved servers are being + /// removed, so a disconnected fallback cannot destroy its own Settings sheet. + private var shouldShowWorkspace: Bool { + FeatureRootPresentation.showsWorkspace( + snapshot: model.snapshot, + isManagingConnections: model.isManagingConnections + ) + } + + private var preferredColorScheme: ColorScheme? { + switch model.snapshot.settings.appearance { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +enum FeatureRootPresentation { + static func showsWorkspace( + snapshot: FeatureSnapshot, + isManagingConnections: Bool + ) -> Bool { + isManagingConnections + || !snapshot.environments.isEmpty + || !snapshot.projects.isEmpty + || !snapshot.threads.isEmpty + } +} + +private struct FeatureLoadingView: View { + var body: some View { + VStack(spacing: 14) { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 30, weight: .semibold)) + ProgressView() + .controlSize(.small) + Text("Connecting to T3 Code") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + .accessibilityElement(children: .combine) + } +} diff --git a/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift b/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift new file mode 100644 index 000000000000..08e2a266d5cb --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionHubPresentation.swift @@ -0,0 +1,149 @@ +import Foundation + +enum ConnectionHubStatus: Equatable { + case disabled + case checking + case connecting + case offline + case online + + var title: String { + switch self { + case .disabled: "Off" + case .checking: "Checking" + case .connecting: "Connecting" + case .offline: "Offline" + case .online: "Online" + } + } +} + +struct T3ConnectEnvironmentPresentation: Identifiable, Equatable { + let linkedEnvironment: T3ConnectCloudEnvironment? + let savedEnvironment: FeatureEnvironment? + + var id: String { + linkedEnvironment?.id ?? savedEnvironment?.id ?? "" + } + + var name: String { + linkedEnvironment?.environment.label ?? savedEnvironment?.name ?? "T3 environment" + } + + var isEnabled: Bool { + savedEnvironment?.isEnabled ?? false + } + + var endpoint: String? { + linkedEnvironment?.environment.endpoint.httpBaseUrl ?? savedEnvironment?.endpoint + } + + var isOnline: Bool { + status == .online + } + + var status: ConnectionHubStatus { + connectionStatus() + } + + func connectionStatus( + pendingEnabled: Bool? = nil, + isConnecting: Bool = false + ) -> ConnectionHubStatus { + if isConnecting || (pendingEnabled == true && savedEnvironment == nil) { + return .connecting + } + + if let savedEnvironment { + return ConnectionHubPresentation.status( + for: savedEnvironment, + pendingEnabled: pendingEnabled + ) + } + + return switch linkedEnvironment?.status?.status { + case .online: .online + case .offline: .offline + case nil: linkedEnvironment?.statusError == nil ? .checking : .offline + } + } +} + +enum ConnectionHubPresentation { + static func status( + for environment: FeatureEnvironment, + pendingEnabled: Bool? = nil + ) -> ConnectionHubStatus { + guard pendingEnabled ?? environment.isEnabled else { return .disabled } + + if pendingEnabled == true && !environment.isEnabled { + return .connecting + } + + return switch environment.connectionState { + case .connected: .online + case .connecting, .reconnecting: .connecting + case .disconnected: .offline + case nil: .checking + } + } + + static func disambiguatingEndpoint( + _ endpoint: String, + for name: String, + among names: [String] + ) -> String? { + let matchingNames = names.filter { + $0.localizedCaseInsensitiveCompare(name) == .orderedSame + } + guard matchingNames.count > 1, + let components = URLComponents(string: endpoint), + let host = components.host else { return nil } + + return components.port.map { "\(host):\($0)" } ?? host + } + + static func directEnvironments( + in environments: [FeatureEnvironment] + ) -> [FeatureEnvironment] { + environments.enumerated() + .filter { $0.element.source == .direct } + .sorted { first, second in + if first.element.isEnabled != second.element.isEnabled { + return first.element.isEnabled + } + return first.offset < second.offset + } + .map(\.element) + } + + /// T3 Connect owns the account catalog while Core owns the environments + /// already saved on this iPhone. Join them by server identity so the hub + /// has one row and one switch for each machine. + static func t3ConnectEnvironments( + saved: [FeatureEnvironment], + linked: [T3ConnectCloudEnvironment] + ) -> [T3ConnectEnvironmentPresentation] { + let savedByID = Dictionary( + uniqueKeysWithValues: saved + .filter { $0.source == .t3Connect } + .map { ($0.id, $0) } + ) + let linkedIDs = Set(linked.map(\.id)) + let linkedRows = linked.map { + T3ConnectEnvironmentPresentation( + linkedEnvironment: $0, + savedEnvironment: savedByID[$0.id] + ) + } + let savedOnlyRows: [T3ConnectEnvironmentPresentation] = saved.compactMap { environment in + guard environment.source == .t3Connect, + !linkedIDs.contains(environment.id) else { return nil } + return T3ConnectEnvironmentPresentation( + linkedEnvironment: nil, + savedEnvironment: environment + ) + } + return linkedRows + savedOnlyRows + } +} diff --git a/apps/swift-ios/Features/Settings/ConnectionsView.swift b/apps/swift-ios/Features/Settings/ConnectionsView.swift new file mode 100644 index 000000000000..fda0f40ec26c --- /dev/null +++ b/apps/swift-ios/Features/Settings/ConnectionsView.swift @@ -0,0 +1,684 @@ +import SwiftUI + +struct ConnectionsView: View { + @Bindable var model: FeatureRootModel + + @State private var pendingEnabledValues: [String: Bool] = [:] + @State private var showingAddConnection = false + @State private var showingDevices = false + @State private var showingT3Connect = false + @State private var detailEnvironmentID: String? + @State private var removalTarget: FeatureEnvironment? + @State private var connectingEnvironmentID: String? + @State private var connectionErrorMessage: String? + + var body: some View { + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + directConnectionsSection + t3ConnectSection + accessSection + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + .scrollDismissesKeyboard(.interactively) + } + .background(T3Colors.background) + .navigationTitle("Environments") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .t3NavigationChrome() + .task { + await t3ConnectController?.refresh() + } + .sheet(isPresented: $showingAddConnection) { + ConnectionOnboardingView( + model: model, + showsT3ConnectOption: false, + onConnected: { + showingAddConnection = false + Task { await model.reloadAfterConnection() } + }, + onCancel: { showingAddConnection = false } + ) + } + .sheet(isPresented: $showingDevices) { + NavigationStack { + DevicesView(manager: deviceManager) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showingDevices = false } + } + } + } + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showingT3Connect) { + if let capability = model.client as? any T3ConnectCapable { + NavigationStack { + T3ConnectView( + capability: capability, + model: model, + purpose: .manage, + onUnlinked: { id in + await model.removeEnvironment(id) + } + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showingT3Connect = false } + } + } + } + .presentationDragIndicator(.visible) + } + } + .sheet( + isPresented: Binding( + get: { detailEnvironmentID != nil }, + set: { if !$0 { detailEnvironmentID = nil } } + ) + ) { + if let id = detailEnvironmentID { + NavigationStack { + ConnectionDetailView( + model: model, + environmentID: id, + pendingEnabledValues: $pendingEnabledValues, + onRemove: { + detailEnvironmentID = nil + Task { await model.removeEnvironment(id) } + } + ) + } + .presentationDragIndicator(.visible) + } + } + .alert( + "Remove connection?", + isPresented: Binding( + get: { removalTarget != nil }, + set: { if !$0 { removalTarget = nil } } + ), + presenting: removalTarget + ) { environment in + Button("Remove", role: .destructive) { + Task { + await model.removeEnvironment(environment.id) + removalTarget = nil + } + } + Button("Cancel", role: .cancel) {} + } message: { environment in + Text(removalMessage(for: environment)) + } + .alert( + "T3 Connect", + isPresented: Binding( + get: { connectionErrorMessage != nil }, + set: { if !$0 { connectionErrorMessage = nil } } + ) + ) { + Button("OK") { connectionErrorMessage = nil } + } message: { + Text(connectionErrorMessage ?? "Something went wrong.") + } + } + + private var directConnectionsSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("Direct") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 0) + Button("Add") { showingAddConnection = true } + .font(T3Typography.control) + .accessibilityIdentifier("connections-add-button") + } + + if directEnvironments.isEmpty { + emptyRow("No paired environments") + } else { + VStack(spacing: 0) { + ForEach(directEnvironments) { environment in + directConnectionRow(environment) + } + } + } + } + } + + private func directConnectionRow(_ environment: FeatureEnvironment) -> some View { + HStack(spacing: 12) { + Button { + detailEnvironmentID = environment.id + } label: { + HStack(spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 25) + .accessibilityHidden(true) + + environmentLabel( + name: environment.name, + status: ConnectionHubPresentation.status( + for: environment, + pendingEnabled: pendingEnabledValues[environment.id] + ), + endpoint: directEndpointLabel(for: environment) + ) + + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("Shows connection details") + + Toggle("Enabled", isOn: enabledBinding(for: environment)) + .labelsHidden() + .tint(T3Colors.success) + .disabled(pendingEnabledValues[environment.id] != nil) + .accessibilityLabel( + toggleAccessibilityLabel( + name: environment.name, + endpoint: directEndpointLabel(for: environment) + ) + ) + } + .frame(minHeight: 70) + .contextMenu { + Button(role: .destructive) { + removalTarget = environment + } label: { + Label("Remove connection", systemImage: "trash") + } + } + } + + private var t3ConnectSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("T3 Connect") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 0) + Button(t3ConnectController?.account == nil ? "Sign in" : "Manage") { + showingT3Connect = true + } + .font(T3Typography.control) + .disabled(t3ConnectCapability == nil) + .accessibilityIdentifier("connections-manage-t3-connect-button") + } + + if t3ConnectRows.isEmpty { + if t3ConnectController?.isRefreshing == true { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking T3 Connect") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: 44) + .frame(maxWidth: .infinity, alignment: .leading) + } else if t3ConnectCapability == nil { + emptyRow("T3 Connect unavailable") + } else if t3ConnectController?.account == nil { + emptyRow("Sign in to see your environments") + } else { + emptyRow("No linked environments") + } + } else { + VStack(spacing: 0) { + ForEach(t3ConnectRows) { item in + t3ConnectConnectionRow(item) + } + } + } + } + } + + private func t3ConnectConnectionRow( + _ item: T3ConnectEnvironmentPresentation + ) -> some View { + HStack(spacing: 12) { + Group { + if let environment = item.savedEnvironment { + Button { + detailEnvironmentID = environment.id + } label: { + t3ConnectEnvironmentLabel(item) + } + .buttonStyle(.plain) + .accessibilityHint("Shows connection details") + } else { + t3ConnectEnvironmentLabel(item) + } + } + + Toggle("Enabled", isOn: t3ConnectEnabledBinding(for: item)) + .labelsHidden() + .tint(T3Colors.success) + .disabled(isT3ConnectToggleDisabled(item)) + .accessibilityHint( + item.savedEnvironment == nil && item.status == .offline + ? "Environment is offline" + : "" + ) + .accessibilityLabel( + toggleAccessibilityLabel( + name: item.name, + endpoint: t3ConnectEndpointLabel(for: item) + ) + ) + } + .frame(minHeight: 70) + } + + private func t3ConnectEnvironmentLabel( + _ item: T3ConnectEnvironmentPresentation + ) -> some View { + HStack(spacing: 12) { + Image(systemName: "cloud") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 25) + .accessibilityHidden(true) + + environmentLabel( + name: item.name, + status: item.connectionStatus( + pendingEnabled: pendingEnabledValues[item.id], + isConnecting: connectingEnvironmentID == item.id + || t3ConnectController?.busyEnvironmentID == item.id + ), + endpoint: t3ConnectEndpointLabel(for: item) + ) + Spacer(minLength: 8) + } + .contentShape(Rectangle()) + } + + private func environmentLabel( + name: String, + status: ConnectionHubStatus, + endpoint: String? + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + + HStack(spacing: 7) { + Circle() + .fill(status.color) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + + Text(status.title) + .fixedSize(horizontal: true, vertical: false) + + if let endpoint { + Text(endpoint) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .accessibilityElement(children: .combine) + } + + private func emptyRow(_ message: String) -> some View { + Text(message) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: 38) + } + + private var accessSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Access") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + Button { + showingDevices = true + } label: { + HStack(spacing: 12) { + Image(systemName: "laptopcomputer.and.iphone") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(T3Colors.accent) + .frame(width: 25) + .accessibilityHidden(true) + Text("Devices and sessions") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + } + .frame(minHeight: 54) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private func directEndpointLabel(for environment: FeatureEnvironment) -> String? { + ConnectionHubPresentation.disambiguatingEndpoint( + environment.endpoint, + for: environment.name, + among: directEnvironments.map(\.name) + ) + } + + private func t3ConnectEndpointLabel( + for item: T3ConnectEnvironmentPresentation + ) -> String? { + guard let endpoint = item.endpoint else { return nil } + return ConnectionHubPresentation.disambiguatingEndpoint( + endpoint, + for: item.name, + among: t3ConnectRows.map(\.name) + ) + } + + private func toggleAccessibilityLabel(name: String, endpoint: String?) -> String { + guard let endpoint else { return "Enable \(name)" } + return "Enable \(name), \(endpoint)" + } + + private func enabledBinding(for environment: FeatureEnvironment) -> Binding { + Binding( + get: { pendingEnabledValues[environment.id] ?? environment.isEnabled }, + set: { enabled in + pendingEnabledValues[environment.id] = enabled + Task { + _ = await model.setEnvironmentEnabled(environment.id, enabled: enabled) + pendingEnabledValues[environment.id] = nil + } + } + ) + } + + private func t3ConnectEnabledBinding( + for item: T3ConnectEnvironmentPresentation + ) -> Binding { + Binding( + get: { pendingEnabledValues[item.id] ?? item.isEnabled }, + set: { enabled in + pendingEnabledValues[item.id] = enabled + Task { await setT3ConnectEnvironment(item, enabled: enabled) } + } + ) + } + + private func setT3ConnectEnvironment( + _ item: T3ConnectEnvironmentPresentation, + enabled: Bool + ) async { + defer { + pendingEnabledValues[item.id] = nil + if connectingEnvironmentID == item.id { + connectingEnvironmentID = nil + } + } + + if let savedEnvironment = item.savedEnvironment { + _ = await model.setEnvironmentEnabled(savedEnvironment.id, enabled: enabled) + return + } + + guard enabled, + let linkedEnvironment = item.linkedEnvironment, + let capability = t3ConnectCapability else { return } + + connectingEnvironmentID = item.id + do { + let credential = try await capability.t3ConnectController.credential( + for: linkedEnvironment.environment + ) + try await capability.connectT3Environment(credential) + await model.reloadAfterConnection() + } catch { + connectionErrorMessage = error.localizedDescription + } + } + + private func isT3ConnectToggleDisabled( + _ item: T3ConnectEnvironmentPresentation + ) -> Bool { + pendingEnabledValues[item.id] != nil + || (connectingEnvironmentID != nil && connectingEnvironmentID != item.id) + || t3ConnectController?.busyEnvironmentID != nil + || (item.savedEnvironment == nil && item.status == .offline) + } + + private var directEnvironments: [FeatureEnvironment] { + ConnectionHubPresentation.directEnvironments(in: model.snapshot.environments) + } + + private var t3ConnectRows: [T3ConnectEnvironmentPresentation] { + ConnectionHubPresentation.t3ConnectEnvironments( + saved: model.snapshot.environments, + linked: t3ConnectController?.environments ?? [] + ) + } + + private var t3ConnectCapability: (any T3ConnectCapable)? { + model.client as? any T3ConnectCapable + } + + private var t3ConnectController: T3ConnectController? { + t3ConnectCapability?.t3ConnectController + } + + private func removalMessage(for environment: FeatureEnvironment) -> String { + switch environment.source { + case .direct: + "\(environment.name) will need a new pairing code to be added again." + case .t3Connect: + "\(environment.name) will remain linked to your T3 Connect account." + } + } + + private var deviceManager: any FeatureDeviceManaging { + (model.client as? any FeatureDeviceManaging) ?? EmptyFeatureDeviceManager.shared + } +} + +private struct ConnectionDetailView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + let environmentID: String + @Binding var pendingEnabledValues: [String: Bool] + let onRemove: () -> Void + + @State private var showingRemoval = false + @State private var isUpdatingAutomaticSettlement = false + + var body: some View { + List { + if let environment { + Section { + Toggle("Enabled", isOn: enabledBinding(for: environment)) + .tint(T3Colors.success) + .disabled(pendingEnabledValues[environment.id] != nil) + LabeledContent( + "Status", + value: ConnectionHubPresentation.status( + for: environment, + pendingEnabled: pendingEnabledValues[environment.id] + ).title + ) + LabeledContent("Connection", value: environment.source.title) + } + + Section("Server") { + Text(environment.endpoint) + .textSelection(.enabled) + LabeledContent("Projects", value: "\(projectCount)") + } + + if let automaticSettlement { + Section("Automatic settlement") { + Toggle("When a pull request merges", isOn: mergeBinding) + .tint(T3Colors.success) + Toggle("After inactivity", isOn: inactivityEnabledBinding) + .tint(T3Colors.success) + if automaticSettlement.afterDays != nil { + Stepper( + value: inactivityDaysBinding, + in: 1...90, + step: 1 + ) { + LabeledContent( + "Days", + value: formattedDays(automaticSettlement.afterDays ?? 3) + ) + } + } + } + .disabled(automaticSettlementControlsDisabled) + } + + Section { + Button("Remove connection", role: .destructive) { + showingRemoval = true + } + } + } else { + ContentUnavailableView("Connection removed", systemImage: "network.slash") + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle(environment?.name ?? "Connection") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .confirmationDialog( + "Remove this connection?", + isPresented: $showingRemoval, + titleVisibility: .visible + ) { + Button("Remove", role: .destructive, action: onRemove) + Button("Cancel", role: .cancel) {} + } message: { + Text(removalMessage) + } + } + + private var environment: FeatureEnvironment? { + model.snapshot.environments.first { $0.id == environmentID } + } + + private var projectCount: Int { + model.snapshot.projects.count { $0.environmentID == environmentID } + } + + private var automaticSettlement: FeatureAutomaticSettlementSettings? { + model.snapshot.preferencesByEnvironment?[environmentID]?.automaticSettlement + } + + private var automaticSettlementControlsDisabled: Bool { + isUpdatingAutomaticSettlement + || pendingEnabledValues[environmentID] != nil + || environment?.isEnabled != true + || environment?.connectionState != .connected + } + + private var mergeBinding: Binding { + Binding( + get: { automaticSettlement?.onMerge ?? false }, + set: { updateAutomaticSettlement(.onMerge($0)) } + ) + } + + private var inactivityEnabledBinding: Binding { + Binding( + get: { automaticSettlement?.afterDays != nil }, + set: { enabled in + updateAutomaticSettlement(.afterDays(enabled ? 3 : nil)) + } + ) + } + + private var inactivityDaysBinding: Binding { + Binding( + get: { automaticSettlement?.afterDays ?? 3 }, + set: { updateAutomaticSettlement(.afterDays($0)) } + ) + } + + private func updateAutomaticSettlement(_ change: FeatureAutomaticSettlementChange) { + guard !automaticSettlementControlsDisabled else { return } + isUpdatingAutomaticSettlement = true + Task { + _ = await model.updateAutomaticSettlement( + environmentID: environmentID, + change: change + ) + isUpdatingAutomaticSettlement = false + } + } + + private func formattedDays(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...2))) + } + + private var removalMessage: String { + guard environment?.source == .t3Connect else { + return "A new pairing code will be required to add it again." + } + return "It will remain linked to your T3 Connect account." + } + + private func enabledBinding(for environment: FeatureEnvironment) -> Binding { + Binding( + get: { pendingEnabledValues[environment.id] ?? environment.isEnabled }, + set: { enabled in + pendingEnabledValues[environment.id] = enabled + Task { + _ = await model.setEnvironmentEnabled(environment.id, enabled: enabled) + pendingEnabledValues[environment.id] = nil + } + } + ) + } +} + +private extension FeatureEnvironment.Source { + var title: String { + switch self { + case .direct: "Direct" + case .t3Connect: "T3 Connect" + } + } + +} + +private extension ConnectionHubStatus { + var color: Color { + switch self { + case .disabled, .checking: T3Colors.textTertiary + case .connecting: T3Colors.warning + case .offline: T3Colors.danger + case .online: T3Colors.success + } + } +} diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift new file mode 100644 index 000000000000..ab39c3990cf3 --- /dev/null +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -0,0 +1,441 @@ +import SwiftUI + +public struct SettingsView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable private var model: FeatureRootModel + @State private var settings: FeatureSettings + @State private var isSaving = false + @State private var appearanceSaveTask: Task? + @State private var saveErrorMessage: String? + @State private var showingDiscardConfirmation = false + + public init(model: FeatureRootModel) { + self.model = model + _settings = State(initialValue: model.snapshot.settings) + } + + public var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 36) { + connectionSection + generalSection + preferencesSection + aboutSection + } + .padding(.top, 24) + .padding(.bottom, 36) + } + .scrollDismissesKeyboard(.interactively) + .background(T3Colors.background) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + if hasUnsavedChanges { + showingDiscardConfirmation = true + } else { + dismiss() + } + } + .disabled(isSaving) + .accessibilityHint( + hasUnsavedChanges + ? "Asks before discarding unsaved changes" + : "Closes settings" + ) + } + + ToolbarItem(placement: .confirmationAction) { + Button(isSaving ? "Saving" : "Save", action: save) + .disabled(!canSave) + .accessibilityHint("Saves your preferences") + } + } + .alert( + "Couldn’t save settings", + isPresented: Binding( + get: { saveErrorMessage != nil }, + set: { if !$0 { saveErrorMessage = nil } } + ) + ) { + Button("OK") { saveErrorMessage = nil } + } message: { + Text(saveErrorMessage ?? "Something went wrong.") + } + .confirmationDialog( + "Discard unsaved changes?", + isPresented: $showingDiscardConfirmation, + titleVisibility: .visible + ) { + Button("Discard changes", role: .destructive) { dismiss() } + Button("Keep editing", role: .cancel) {} + } + .onAppear { + model.setConnectionManagementPresented(true) + } + .onDisappear { + model.setConnectionManagementPresented(false) + } + .onChange(of: settings.appearance) { _, appearance in + saveAppearance(appearance) + } + } + .interactiveDismissDisabled(isSaving || hasUnsavedChanges) + .presentationBackground(T3Colors.background) + .presentationDragIndicator(.visible) + } + + private var connectionSection: some View { + SettingsSection(title: "Connection") { + NavigationLink { + ConnectionsView(model: model) + } label: { + SettingsNavigationRow( + title: "Environments", + value: environmentCountLabel, + subtitle: environmentSummary.text, + systemImage: "server.rack", + statusColor: environmentSummary.color + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Environments") + .accessibilityValue(environmentAccessibilityValue) + .accessibilityHint("Manage saved environments") + } + } + + private var generalSection: some View { + SettingsSection(title: "Workspace") { + VStack(spacing: 0) { + NavigationLink { + PullRequestsView(model: model) + } label: { + SettingsNavigationRow( + title: "Pull requests", + systemImage: "arrow.triangle.pull" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Shows pull requests") + settingsDivider + NavigationLink { + UsageView(client: model.client) + } label: { + SettingsNavigationRow( + title: "Usage", + systemImage: "chart.bar.xaxis" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Shows provider usage") + } + } + } + + private var preferencesSection: some View { + SettingsSection(title: "Preferences") { + VStack(spacing: 0) { + HStack(spacing: 12) { + SettingsRowIcon(systemName: "circle.lefthalf.filled") + Text("Theme") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 12) + Picker("Theme", selection: $settings.appearance) { + Text("System").tag(FeatureAppearance.system) + Text("Light").tag(FeatureAppearance.light) + Text("Dark").tag(FeatureAppearance.dark) + } + .labelsHidden() + .pickerStyle(.menu) + .tint(T3Colors.textSecondary) + .accessibilityLabel("Theme") + } + .padding(.horizontal, 20) + .frame(minHeight: 56) + + settingsDivider + SettingsToggleRow( + title: "Haptics", + systemImage: "iphone.radiowaves.left.and.right", + isOn: $settings.hapticsEnabled + ) + settingsDivider + SettingsToggleRow( + title: "Notifications", + systemImage: "bell", + isOn: $settings.notificationsEnabled + ) + settingsDivider + SettingsToggleRow( + title: "Live Activities", + systemImage: "waveform.path.ecg.rectangle", + isOn: $settings.liveActivitiesEnabled + ) + } + } + } + + private var aboutSection: some View { + SettingsSection(title: "About", footer: "Version \(appVersionLabel)") { + Link(destination: URL(string: "https://github.com/pingdotgg/t3code")!) { + SettingsNavigationRow( + title: "Source code", + systemImage: "chevron.left.forwardslash.chevron.right", + trailingSystemImage: "arrow.up.right" + ) + } + .buttonStyle(.plain) + .accessibilityHint("Opens GitHub in your browser") + } + } + + private var settingsDivider: some View { + Divider() + .overlay(T3Colors.separator) + .padding(.leading, 54) + .padding(.trailing, 20) + } + + private var environmentSummary: (text: String, color: Color) { + let environments = model.snapshot.environments + guard !environments.isEmpty else { + return ("Add an environment", T3Colors.textTertiary) + } + + let connected = connectedEnvironments + if connected.count == 1, let environment = connected.first { + return ("\(environment.name) online", T3Colors.success) + } + if connected.count > 1 { + return ("\(connected.count) online", T3Colors.success) + } + + let enabled = environments.filter(\.isEnabled) + guard !enabled.isEmpty else { + let text = environments.count == 1 ? "Off" : "All off" + return (text, T3Colors.textTertiary) + } + + if let connecting = enabled.first(where: { + $0.connectionState == .connecting || $0.connectionState == .reconnecting + }) { + let state = connecting.connectionState == .reconnecting + ? "reconnecting" + : "connecting" + return ("\(connecting.name) \(state)", T3Colors.warning) + } + + if let checking = enabled.first(where: { $0.connectionState == nil }) { + let text = enabled.count == 1 + ? "\(checking.name) checking" + : "Checking environments" + return (text, T3Colors.textTertiary) + } + + let text = enabled.count == 1 ? "\(enabled[0].name) offline" : "All offline" + return (text, T3Colors.danger) + } + + private var connectedEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + ConnectionHubPresentation.status(for: $0) == .online + } + } + + private var environmentCountLabel: String? { + let environments = model.snapshot.environments + guard !environments.isEmpty else { return nil } + return "\(connectedEnvironments.count)/\(environments.count)" + } + + private var environmentAccessibilityValue: String { + let environmentCount = model.snapshot.environments.count + guard environmentCount > 0 else { + return environmentSummary.text + } + + let connectedCount = connectedEnvironments.count + return "\(environmentSummary.text), \(connectedCount) of \(environmentCount) online" + } + + private var appVersionLabel: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + ?? "?" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ?? "?" + return "\(version) (\(build))" + } + + private var hasUnsavedChanges: Bool { + settings != model.snapshot.settings + } + + private var canSave: Bool { + !isSaving && hasUnsavedChanges + } + + @MainActor + private func saveAppearance(_ appearance: FeatureAppearance) { + let previousSave = appearanceSaveTask + appearanceSaveTask = Task { + _ = await previousSave?.value + + let didSave = await model.saveAppearance(appearance) + if !didSave, settings.appearance == appearance { + settings.appearance = model.snapshot.settings.appearance + saveErrorMessage = model.errorMessage + ?? "Theme preference could not be saved." + } + return didSave + } + } + + @MainActor + private func save() { + let pendingAppearanceSave = appearanceSaveTask + isSaving = true + Task { + let appearanceDidSave = await pendingAppearanceSave?.value ?? true + if !appearanceDidSave, !hasUnsavedChanges { + isSaving = false + return + } + + let didSave = await model.saveSettings(settings) + isSaving = false + if didSave { + saveErrorMessage = nil + dismiss() + } else { + saveErrorMessage = model.errorMessage ?? "Settings could not be saved." + } + } + } +} + +private struct SettingsSection: View { + let title: String + let footer: String? + let content: Content + + init( + title: String, + footer: String? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.footer = footer + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 20) + .accessibilityAddTraits(.isHeader) + + content + + if let footer { + Text(footer) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .padding(.horizontal, 20) + } + } + } +} + +private struct SettingsRowIcon: View { + let systemName: String + var color: Color = T3Colors.textSecondary + + var body: some View { + Image(systemName: systemName) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(color) + .frame(width: 22, height: 22) + .accessibilityHidden(true) + } +} + +private struct SettingsNavigationRow: View { + let title: String + var value: String? = nil + var subtitle: String? = nil + let systemImage: String + var statusColor: Color? = nil + var trailingSystemImage = "chevron.right" + + var body: some View { + HStack(spacing: 12) { + SettingsRowIcon(systemName: systemImage) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + + if let subtitle { + HStack(spacing: 6) { + if let statusColor { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .accessibilityHidden(true) + } + + Text(subtitle) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + } + } + + Spacer(minLength: 8) + if let value { + Text(value) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + .layoutPriority(1) + } + Image(systemName: trailingSystemImage) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + } + .padding(.horizontal, 20) + .frame(minHeight: subtitle == nil ? 56 : 68) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + +private struct SettingsToggleRow: View { + let title: String + let systemImage: String + @Binding var isOn: Bool + + var body: some View { + Toggle(isOn: $isOn) { + HStack(spacing: 12) { + SettingsRowIcon(systemName: systemImage) + Text(title) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + } + } + .tint(T3Colors.accent) + .padding(.horizontal, 20) + .frame(minHeight: 56) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift b/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift new file mode 100644 index 000000000000..9cbe18e5a35f --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureActiveSubagentTracker.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Keeps only the small part of subagent state that mobile displays. The +/// server-provided `agentKind` is authoritative; legacy unmarked tasks remain +/// ordinary work-log entries. +struct FeatureActiveSubagentTracker { + private enum Status: String { + case pending + case running + case waiting + case idle + case completed + case failed + case cancelled + case interrupted + + var isActive: Bool { + self == .pending || self == .running || self == .waiting + } + + var isTerminal: Bool { + self == .completed || self == .failed || self == .cancelled || self == .interrupted + } + } + + private var statuses: [String: Status] = [:] + + var activeCount: Int { + statuses.values.count(where: \.isActive) + } + + mutating func reset(with activities: [OrchestrationActivity]) { + statuses.removeAll(keepingCapacity: true) + for activity in activities { + apply(activity) + } + } + + mutating func apply(_ activity: OrchestrationActivity) { + guard activity.kind == "task.started" + || activity.kind == "task.progress" + || activity.kind == "task.updated" + || activity.kind == "task.completed", + let taskID = activity.payload["taskId"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines), + !taskID.isEmpty else { + return + } + + let isKnownAgent = statuses[taskID] != nil + let isExplicitAgent = activity.payload["agentKind"]?.stringValue == "agent" + guard isKnownAgent || isExplicitAgent else { return } + + switch activity.kind { + case "task.started": + if let current = statuses[taskID], current.isTerminal { + return + } + statuses[taskID] = .running + + case "task.progress": + if let status = status(from: activity.payload["status"]) { + statuses[taskID] = status + } else if statuses[taskID] != .idle, + statuses[taskID]?.isTerminal != true { + statuses[taskID] = .running + } + + case "task.updated": + statuses[taskID] = status(from: activity.payload["status"]) + ?? statuses[taskID] + ?? .pending + + case "task.completed": + guard statuses[taskID]?.isTerminal != true else { return } + statuses[taskID] = switch activity.payload["status"]?.stringValue { + case "failed": .failed + case "stopped": .interrupted + default: .completed + } + + default: + break + } + } + + private func status(from value: JSONValue?) -> Status? { + guard let rawValue = value?.stringValue else { return nil } + return Status(rawValue: rawValue) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift b/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift new file mode 100644 index 000000000000..409a816ce570 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureAttachmentUploadCoordinator.swift @@ -0,0 +1,376 @@ +import Foundation +import Observation + +public struct FeatureAttachmentUploadKey: Hashable, Sendable { + public let environmentID: String + public let attachmentID: UUID + + public init(environmentID: String, attachmentID: UUID) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +public enum FeatureAttachmentUploadState: Equatable, Sendable { + case queued + case uploading + case ready(FeatureUploadedAttachmentReference?) + case failed(String) +} + +@MainActor +@Observable +public final class FeatureAttachmentUploadCoordinator { + typealias Upload = @MainActor @Sendable (FeatureUploadAttachment, String) async throws + -> FeatureUploadedAttachmentReference? + typealias Persist = @MainActor @Sendable ( + FeatureUploadedAttachmentReference, + FeatureDraftAttachment, + String + ) async throws -> Bool + + private struct Owner { + var environmentID: String + var attachments: [UUID: FeatureDraftAttachment] + } + + private struct Job { + let attachment: FeatureDraftAttachment + let token: UUID + var state: FeatureAttachmentUploadState + var task: Task? + } + + public private(set) var states: [FeatureAttachmentUploadKey: FeatureAttachmentUploadState] = [:] + private let upload: Upload + private let persist: Persist + private let maximumConcurrentUploads: Int + private var owners: [String: Owner] = [:] + private var outboxOwners: [String: Set] = [:] + private var jobs: [FeatureAttachmentUploadKey: Job] = [:] + // Canceled transfers keep their slots until the upload function returns. + private var runningTokens: Set = [] + + public convenience init( + client: any FeatureClient, + draftStore: FeatureComposerDraftStore = .shared, + maximumConcurrentUploads: Int = 3 + ) { + self.init( + maximumConcurrentUploads: maximumConcurrentUploads, + upload: { attachment, environmentID in + try await client.preuploadAttachment(attachment, environmentID: environmentID) + }, + persist: { reference, attachment, draftKey in + try await draftStore.setUploadedReference( + reference, + attachment: attachment, + for: draftKey + ) + } + ) + } + + init( + maximumConcurrentUploads: Int = 3, + upload: @escaping Upload, + persist: @escaping Persist + ) { + self.maximumConcurrentUploads = max(1, maximumConcurrentUploads) + self.upload = upload + self.persist = persist + } + + public func syncOwner( + draftKey: String, + environmentID: String, + attachments: [FeatureDraftAttachment] + ) { + let previous = owners[draftKey] + owners[draftKey] = Owner( + environmentID: environmentID, + attachments: Dictionary(uniqueKeysWithValues: attachments.map { ($0.id, $0) }) + ) + for attachment in attachments { + enqueueIfNeeded(attachment, environmentID: environmentID) + } + if let previous { + let oldKeys = Set(previous.attachments.keys.map { + FeatureAttachmentUploadKey( + environmentID: previous.environmentID, + attachmentID: $0 + ) + }) + let newKeys = Set(attachments.map { + FeatureAttachmentUploadKey(environmentID: environmentID, attachmentID: $0.id) + }) + cancelUnowned(oldKeys.subtracting(newKeys)) + } + startQueuedJobs() + } + + public func removeOwner(draftKey: String) { + guard let owner = owners.removeValue(forKey: draftKey) else { return } + cancelUnowned(Set(owner.attachments.keys.map { + FeatureAttachmentUploadKey(environmentID: owner.environmentID, attachmentID: $0) + })) + } + + public func syncOutboxOwner( + ownerID: String, + environmentID: String, + attachmentIDs: [UUID] + ) { + let previous = outboxOwners[ownerID] ?? [] + let current = Set(attachmentIDs.map { + FeatureAttachmentUploadKey(environmentID: environmentID, attachmentID: $0) + }) + outboxOwners[ownerID] = current + cancelUnowned(previous.subtracting(current)) + } + + public func removeOutboxOwner(ownerID: String) { + guard let previous = outboxOwners.removeValue(forKey: ownerID) else { return } + cancelUnowned(previous) + } + + public func retry(environmentID: String, attachmentID: UUID) { + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachmentID + ) + guard let job = jobs[key], case .failed = job.state, isOwned(key) else { return } + queue(key: key, attachment: job.attachment) + startQueuedJobs() + } + + public func state( + environmentID: String, + attachmentID: UUID + ) -> FeatureAttachmentUploadState? { + states[FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachmentID + )] + } + + public func attachmentsForSend( + draftKey: String, + environmentID: String, + attachments: [FeatureDraftAttachment] + ) -> [FeatureDraftAttachment] { + guard let owner = owners[draftKey], owner.environmentID == environmentID else { + return attachments + } + return attachments.map { attachment in + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachment.id + ) + guard let owned = owner.attachments[attachment.id], + Self.samePayload(owned, attachment), + let job = jobs[key], Self.samePayload(job.attachment, attachment), + case let .ready(reference) = job.state, + let reference, reference.environmentID == environmentID else { + return attachment + } + var enriched = attachment + enriched.uploadedReference = reference + return enriched + } + } + + private func enqueueIfNeeded(_ attachment: FeatureDraftAttachment, environmentID: String) { + let key = FeatureAttachmentUploadKey( + environmentID: environmentID, + attachmentID: attachment.id + ) + if let job = jobs[key] { + guard !Self.samePayload(job.attachment, attachment) else { return } + job.task?.cancel() + jobs[key] = nil + states[key] = nil + } + queue(key: key, attachment: attachment) + } + + private func queue(key: FeatureAttachmentUploadKey, attachment: FeatureDraftAttachment) { + jobs[key] = Job( + attachment: attachment, + token: UUID(), + state: .queued, + task: nil + ) + states[key] = .queued + } + + private func startQueuedJobs() { + while runningTokens.count < maximumConcurrentUploads, + let key = jobs.first(where: { $0.value.state == .queued && isOwned($0.key) })?.key, + var job = jobs[key] { + let token = job.token + let attachment = job.attachment + job.state = .uploading + runningTokens.insert(token) + states[key] = .uploading + job.task = Task { [weak self, upload] in + let result: Result + do { + result = .success(try await upload( + FeatureUploadAttachment(attachment), + key.environmentID + )) + } catch { + result = .failure(error) + } + await self?.transferReturned( + key: key, + token: token, + attachment: attachment, + result: result + ) + } + jobs[key] = job + } + } + + private func transferReturned( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment, + result: Result + ) async { + runningTokens.remove(token) + guard jobs[key]?.token == token else { + startQueuedJobs() + return + } + switch result { + case let .failure(error): + fail(key: key, token: token, error: error) + case let .success(reference): + await persistThenPublish( + key: key, + token: token, + attachment: attachment, + reference: reference + ) + } + startQueuedJobs() + } + + private func persistThenPublish( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment, + reference: FeatureUploadedAttachmentReference? + ) async { + guard currentAndOwned(key: key, token: token, attachment: attachment) else { return } + if let reference { + guard reference.environmentID == key.environmentID else { + fail(key: key, token: token, error: CoordinatorError.wrongEnvironment) + return + } + let draftKeys = matchingDraftKeys(key: key, attachment: attachment) + do { + for draftKey in draftKeys { + let didPersist = try await persist(reference, attachment, draftKey) + guard currentAndOwned(key: key, token: token, attachment: attachment), + matchingDraftKeys(key: key, attachment: attachment).contains(draftKey) + else { return } + guard didPersist else { + fail(key: key, token: token, error: CoordinatorError.persistenceRejected) + return + } + } + } catch { + fail(key: key, token: token, error: error) + return + } + } + guard var job = jobs[key], job.token == token, + currentAndOwned(key: key, token: token, attachment: attachment) else { return } + job.state = .ready(reference) + job.task = nil + jobs[key] = job + states[key] = job.state + } + + private func matchingDraftKeys( + key: FeatureAttachmentUploadKey, + attachment: FeatureDraftAttachment + ) -> [String] { + owners.compactMap { draftKey, owner in + owner.environmentID == key.environmentID + && owner.attachments[attachment.id].map { + Self.samePayload($0, attachment) + } == true ? draftKey : nil + } + } + + private func fail(key: FeatureAttachmentUploadKey, token: UUID, error: any Error) { + guard var job = jobs[key], job.token == token else { return } + job.state = .failed( + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + ) + job.task = nil + jobs[key] = job + states[key] = job.state + } + + private func cancelUnowned(_ keys: Set) { + for key in keys where !isOwned(key) { + jobs[key]?.task?.cancel() + jobs[key] = nil + states[key] = nil + } + startQueuedJobs() + } + + private func currentAndOwned( + key: FeatureAttachmentUploadKey, + token: UUID, + attachment: FeatureDraftAttachment + ) -> Bool { + guard let job = jobs[key], job.token == token, + Self.samePayload(job.attachment, attachment) else { return false } + return outboxOwners.values.contains(where: { $0.contains(key) }) || !matchingDraftKeys( + key: key, + attachment: attachment + ).isEmpty + } + + private func isOwned(_ key: FeatureAttachmentUploadKey) -> Bool { + outboxOwners.values.contains(where: { $0.contains(key) }) || owners.values.contains { + $0.environmentID == key.environmentID && $0.attachments[key.attachmentID] != nil + } + } + + private static func samePayload( + _ lhs: FeatureDraftAttachment, + _ rhs: FeatureDraftAttachment + ) -> Bool { + guard lhs.id == rhs.id, + lhs.filename == rhs.filename, + lhs.mimeType == rhs.mimeType, + lhs.byteCount == rhs.byteCount else { return false } + if let file = lhs.ownedFile { + return file.fileName == rhs.ownedFile?.fileName + } + return rhs.ownedFile == nil && lhs.data == rhs.data + } +} + +private enum CoordinatorError: LocalizedError { + case wrongEnvironment + case persistenceRejected + + var errorDescription: String? { + switch self { + case .wrongEnvironment: + "The uploaded attachment belongs to a different environment." + case .persistenceRejected: + "The draft changed before the upload could be saved. Retry the upload." + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift new file mode 100644 index 000000000000..bfe1a3bf89f1 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -0,0 +1,576 @@ +import Foundation + +/// The app-owned adapter between the native feature layer and T3's WebSocket/Core runtime. +/// Implementations are main-actor isolated so UI state never depends on locking. +@MainActor +public protocol FeatureClient: AnyObject { + func initialSnapshot() async throws -> FeatureSnapshot + /// Performs one bounded refresh without starting long-lived subscriptions. + /// Background tasks use this instead of the foreground bootstrap path. + func backgroundSnapshot() async throws -> FeatureSnapshot + func events() -> AsyncStream + + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? + + func pair(endpoint: String, token: String?) async throws + func setEnvironmentEnabled(id: String, enabled: Bool) async throws + func removeEnvironment(id: String) async throws + func disconnect() async + + func addProject(path: String) async throws + func createThread(projectID: String, title: String?, selection: FeatureSelection?) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] + func renameThread(id: String, title: String) async throws + func regenerateThreadTitle(id: String) async throws + func setThreadArchived(id: String, archived: Bool) async throws + func setThreadSettled(id: String, settled: Bool) async throws + func setThreadSnoozed(id: String, until: Date?) async throws + func setThreadPinned(id: String, pinned: Bool) async throws + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws + func deleteThread(id: String) async throws + + func loadThread(id: String) async throws -> FeatureThreadDetail + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? + func releaseThread(id: String) + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws + func cancelTurn(threadID: String) async throws + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws + + func saveSettings(_ settings: FeatureSettings) async throws + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings + + func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws + + func cachedProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? + func refreshProjectFavicon( + environmentID: String, + workspaceRoot: String + ) async -> Data? + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] + func readFile(threadID: String, path: String) async throws -> FeatureFileContent + func loadReview(threadID: String) async throws -> FeatureReview + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus + func sourceControlStatusEvents(threadID: String) -> AsyncStream + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus + + func terminalSnapshot(threadID: String, terminalID: String) async throws -> FeatureTerminalSnapshot + func terminalEvents(threadID: String, terminalID: String) -> AsyncStream + func terminalSessions(threadID: String) -> AsyncStream<[FeatureTerminalSnapshot]> + func openTerminal(threadID: String, terminalID: String, columns: Int, rows: Int) async throws + func writeTerminal(threadID: String, terminalID: String, data: String) async throws + func resizeTerminal( + threadID: String, + terminalID: String, + columns: Int, + rows: Int + ) async throws + func clearTerminal(threadID: String, terminalID: String) async throws + func closeTerminal(threadID: String, terminalID: String) async throws +} + +public extension FeatureClient { + func preuploadAttachment( + _ attachment: FeatureUploadAttachment, + environmentID: String + ) async throws -> FeatureUploadedAttachmentReference? { + nil + } +} + +public extension FeatureClient { + func backgroundSnapshot() async throws -> FeatureSnapshot { + try await initialSnapshot() + } + + func regenerateThreadTitle(id _: String) async throws { + throw FeatureCapabilityUnavailable("Thread title regeneration") + } + + func loadEarlierThreadTurns(id _: String) async throws -> FeatureThreadDetail? { + nil + } +} + +public extension FeatureClient { + func events() -> AsyncStream { + AsyncStream { continuation in continuation.finish() } + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws {} + func removeEnvironment(id: String) async throws {} + func disconnect() async {} + func refreshProviders(environmentID _: String) async throws -> [FeatureProvider] { + throw FeatureCapabilityUnavailable("Provider refresh") + } + func updateAutomaticSettlement( + environmentID _: String, + change _: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + throw FeatureCapabilityUnavailable("Automatic settlement settings") + } + func addProject(path: String) async throws {} + func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { + throw FeatureCapabilityUnavailable("Prism") + } + func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { + [] + } + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + [] + } + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + throw FeatureCapabilityUnavailable("Environment-specific pull request pagination") + } + func pullRequestDetail(_ target: FeaturePullRequestTarget) async throws -> PullRequestDetail { + throw FeatureCapabilityUnavailable("Pull requests") + } + func pullRequestActivity(_ target: FeaturePullRequestTarget) async throws + -> PullRequestActivity + { + throw FeatureCapabilityUnavailable("Pull request activity") + } + func pullRequestDiff(_ target: FeaturePullRequestTarget, cursor: String?) async throws + -> PullRequestDiffResult + { + throw FeatureCapabilityUnavailable("Pull request diffs") + } + func runPullRequestAction( + _ target: FeaturePullRequestTarget, + action: PullRequestAction, + mergeMethod: PullRequestMergeMethod?, + updateMethod: PullRequestUpdateMethod? + ) async throws { throw FeatureCapabilityUnavailable("Pull request actions") } + func updatePullRequest( + _ target: FeaturePullRequestTarget, + title: String?, + body: String? + ) async throws { throw FeatureCapabilityUnavailable("Pull request editing") } + func commentOnPullRequest(_ target: FeaturePullRequestTarget, body: String) async throws { + throw FeatureCapabilityUnavailable("Pull request comments") + } + func submitPullRequestReview( + _ target: FeaturePullRequestTarget, + verdict: PullRequestReviewVerdict, + body: String, + comments: [PullRequestReviewCommentDraft] + ) async throws { throw FeatureCapabilityUnavailable("Pull request reviews") } + func replyToPullRequestThread( + _ target: FeaturePullRequestTarget, + threadID: String, + body: String + ) async throws { throw FeatureCapabilityUnavailable("Pull request replies") } + func setPullRequestThreadResolved( + _ target: FeaturePullRequestTarget, + threadID: String, + resolved: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request review threads") } + func setPullRequestReaction( + _ target: FeaturePullRequestTarget, + subjectID: String?, + content: PullRequestReactionContent, + reacted: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request reactions") } + func pullRequestReviewerCandidates(_ target: FeaturePullRequestTarget) async throws + -> PullRequestReviewerCandidateList + { + throw FeatureCapabilityUnavailable("Pull request reviewers") + } + func requestPullRequestReviewers( + _ target: FeaturePullRequestTarget, + reviewers: [PullRequestReviewerCandidate], + requested: Bool + ) async throws { throw FeatureCapabilityUnavailable("Pull request reviewers") } + func invalidatePullRequests(_ target: FeaturePullRequestTarget?) async throws {} + func cachedProjectFavicon(environmentID: String, workspaceRoot: String) async -> Data? { + nil + } + func refreshProjectFavicon(environmentID: String, workspaceRoot: String) async -> Data? { + nil + } + func releaseThread(id: String) {} + func resolveUserInput(id: String, answers: [String: FeatureInputAnswer]) async throws {} + + /// Keeps simple text-only callers source-compatible while the typed API + /// preserves multi-select answers as arrays. + func resolveUserInput(id: String, answers: [String: String]) async throws { + try await resolveUserInput( + id: id, + answers: answers.mapValues(FeatureInputAnswer.text) + ) + } + func setThreadSettled(id: String, settled: Bool) async throws {} + func setThreadSnoozed(id: String, until: Date?) async throws {} + func setThreadPinned(id: String, pinned: Bool) async throws {} + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws {} + func setInteractionMode(id: String, mode: FeatureInteractionMode) async throws {} + func loadReviewFileContents( + threadID: String, + file: FeatureReviewFile + ) async throws -> FeatureReviewFileContents? { + nil + } + + func listWorkspaceBranches( + projectID: String, + refresh: Bool + ) async throws -> [FeatureWorkspaceBranch] { + [] + } + + /// Legacy clients still create in the current checkout. Native clients + /// override this overload to prepare worktrees atomically with the first turn. + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + attachments: attachments + ) + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + let thread = try await createThread( + projectID: projectID, + title: prompt, + selection: selection + ) + try await sendMessage( + threadID: thread.id, + text: prompt, + selection: selection, + attachments: attachments + ) + return thread + } + + /// Clients that understand stable command identities override this method. + /// The compatibility path remains functional but cannot guarantee + /// idempotence across a process death after an ambiguous network failure. + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws -> FeatureThread { + try await createThreadAndSend( + projectID: projectID, + prompt: prompt, + selection: selection, + runtimeMode: runtimeMode, + interactionMode: interactionMode, + workspaceMode: workspaceMode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin, + attachments: attachments + ) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment] + ) async throws { + guard attachments.isEmpty else { + throw FeatureCapabilityUnavailable("Image attachments") + } + try await sendMessage(threadID: threadID, text: text, selection: selection) + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: attachments + ) + } + + /// Durable submissions carry the modes that were active when the user + /// sent them. Older clients can ignore them, while native retries preserve + /// the original permission instead of reading a later thread value. + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments: [FeatureUploadAttachment], + identity: FeatureSubmissionIdentity + ) async throws { + try await sendMessage( + threadID: threadID, + text: text, + selection: selection, + attachments: attachments, + identity: identity + ) + } + + func listFiles(threadID: String, path: String?) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("Files") + } + + func searchProjectFiles( + projectID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("File search") + } + + func searchThreadFiles( + threadID: String, + query: String, + limit: Int + ) async throws -> [FeatureFileEntry] { + throw FeatureCapabilityUnavailable("File search") + } + + func readFile(threadID: String, path: String) async throws -> FeatureFileContent { + throw FeatureCapabilityUnavailable("File preview") + } + + func loadReview(threadID: String) async throws -> FeatureReview { + throw FeatureCapabilityUnavailable("Review") + } + + func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus { + throw FeatureCapabilityUnavailable("Source control") + } + + func sourceControlStatusEvents(threadID: String) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func performSourceControlAction( + threadID: String, + action: FeatureSourceControlAction, + message: String? + ) async throws -> FeatureSourceControlStatus { + throw FeatureCapabilityUnavailable("Source control actions") + } + + func terminalSnapshot(threadID: String, terminalID _: String) async throws -> FeatureTerminalSnapshot { + throw FeatureCapabilityUnavailable("Terminal") + } + + func terminalEvents(threadID: String, terminalID _: String) -> AsyncStream { + AsyncStream { $0.finish() } + } + + func terminalSessions(threadID _: String) -> AsyncStream<[FeatureTerminalSnapshot]> { + AsyncStream { $0.finish() } + } + + func openTerminal( + threadID: String, + terminalID _: String, + columns: Int, + rows: Int + ) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func writeTerminal(threadID: String, terminalID _: String, data: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func resizeTerminal( + threadID: String, + terminalID _: String, + columns: Int, + rows: Int + ) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func clearTerminal(threadID: String, terminalID _: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } + + func closeTerminal(threadID: String, terminalID _: String) async throws { + throw FeatureCapabilityUnavailable("Terminal") + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift new file mode 100644 index 000000000000..96b211ed4b16 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureComposerDraftStore.swift @@ -0,0 +1,387 @@ +import Foundation + +public struct FeatureComposerDraft: Sendable, Equatable { + public var text: String + public var attachments: [FeatureDraftAttachment] + public var selection: FeatureSelection? + public var workspace: FeatureComposerWorkspaceDraft? + + public init( + text: String = "", + attachments: [FeatureDraftAttachment] = [], + selection: FeatureSelection? = nil, + workspace: FeatureComposerWorkspaceDraft? = nil + ) { + self.text = text + self.attachments = attachments + self.selection = selection + self.workspace = workspace + } + + public var isEmpty: Bool { + text.isEmpty && attachments.isEmpty && selection == nil && workspace == nil + } +} + +public struct FeatureComposerWorkspaceDraft: Sendable, Equatable { + public var mode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + + public init( + mode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool + ) { + self.mode = mode + self.branch = branch + self.worktreePath = worktreePath + self.startFromOrigin = startFromOrigin + } +} + +public enum FeatureComposerDraftImportError: LocalizedError, Equatable, Sendable { + case attachmentLimitExceeded(available: Int) + + public var errorDescription: String? { + switch self { + case let .attachmentLimitExceeded(available): + available == 0 + ? "This draft already has eight attachments. Remove one before importing the share." + : "This share needs more attachment slots. The current draft has room for \(available)." + } + } +} + +/// Persists composer state independently of view navigation. Draft writes are +/// atomic, and callers debounce high-frequency text changes before reaching +/// this actor so image data is not repeatedly encoded for every keystroke. +public actor FeatureComposerDraftStore { + public static let shared = FeatureComposerDraftStore() + private static let documentVersion = 2 + + private struct Document: Codable { + let version: Int + var drafts: [String: PersistedDraft] + } + + private struct PersistedDraft: Codable { + var text: String + var attachments: [PersistedAttachment] + var selection: FeatureSelection? + var workspace: PersistedWorkspace? + var importedShareIDs: [String]? + + init(_ draft: FeatureComposerDraft) { + text = draft.text + attachments = draft.attachments.map(PersistedAttachment.init) + selection = draft.selection + workspace = draft.workspace.map(PersistedWorkspace.init) + importedShareIDs = nil + } + + func featureValue(fileStore: ManagedAttachmentFileStore) -> FeatureComposerDraft { + FeatureComposerDraft( + text: text, + attachments: attachments.compactMap { $0.featureValue(fileStore: fileStore) }, + selection: selection, + workspace: workspace?.featureValue + ) + } + } + + private struct PersistedWorkspace: Codable { + var mode: FeatureWorkspaceMode + var branch: String? + var worktreePath: String? + var startFromOrigin: Bool + + init(_ workspace: FeatureComposerWorkspaceDraft) { + mode = workspace.mode + branch = workspace.branch + worktreePath = workspace.worktreePath + startFromOrigin = workspace.startFromOrigin + } + + var featureValue: FeatureComposerWorkspaceDraft { + FeatureComposerWorkspaceDraft( + mode: mode, + branch: branch, + worktreePath: worktreePath, + startFromOrigin: startFromOrigin + ) + } + } + + private struct PersistedAttachment: Codable { + var id: UUID + var data: Data? + var ownedFileName: String? + var byteCount: Int? + var thumbnailData: Data? + var filename: String + var mimeType: String + var uploadedReference: FeatureUploadedAttachmentReference? + + init(_ attachment: FeatureDraftAttachment) { + id = attachment.id + data = attachment.ownedFile == nil ? attachment.data : nil + ownedFileName = attachment.ownedFile?.fileName + byteCount = attachment.byteCount + thumbnailData = attachment.thumbnailData + filename = attachment.filename + mimeType = attachment.mimeType + uploadedReference = attachment.uploadedReference + } + + func featureValue(fileStore: ManagedAttachmentFileStore) -> FeatureDraftAttachment? { + if let ownedFileName, + let ownedFile = try? fileStore.resolvedFile( + fileName: ownedFileName, + byteCount: byteCount ?? 0 + ) { + return FeatureDraftAttachment( + id: id, + ownedFile: ownedFile, + thumbnailData: thumbnailData, + filename: filename, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + guard let data else { return nil } + return FeatureDraftAttachment( + id: id, + data: data, + thumbnailData: thumbnailData, + filename: filename, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + + func hasSameContent(as attachment: FeatureDraftAttachment) -> Bool { + guard id == attachment.id, + filename == attachment.filename, + mimeType == attachment.mimeType, + (byteCount ?? data?.count ?? 0) == attachment.byteCount else { return false } + if let ownedFileName { + return ownedFileName == attachment.ownedFile?.fileName + } + return attachment.ownedFile == nil && data == attachment.data + } + } + + public let fileURL: URL + public let attachmentFileStore: ManagedAttachmentFileStore + private var loadedDrafts: [String: PersistedDraft]? + + public init(fileURL: URL? = nil, attachmentStorageRootURL: URL? = nil) { + attachmentFileStore = ManagedAttachmentFileStore(rootURL: attachmentStorageRootURL) + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("composer-drafts.json", isDirectory: false) + } + } + + public func draft(for key: String) throws -> FeatureComposerDraft? { + guard let draft = try loadIfNeeded()[key]?.featureValue(fileStore: attachmentFileStore), + !draft.isEmpty else { return nil } + return draft + } + + public func setDraft(_ draft: FeatureComposerDraft, for key: String) throws { + var drafts = try loadIfNeeded() + let existingReferences = Dictionary( + uniqueKeysWithValues: (drafts[key]?.attachments ?? []).compactMap { attachment in + attachment.uploadedReference.map { (attachment.id, (attachment, $0)) } + } + ) + var mergedDraft = draft + for index in mergedDraft.attachments.indices + where mergedDraft.attachments[index].uploadedReference == nil { + let incoming = mergedDraft.attachments[index] + if let (persisted, reference) = existingReferences[incoming.id], + persisted.hasSameContent(as: incoming) { + mergedDraft.attachments[index].uploadedReference = reference + } + } + if mergedDraft.isEmpty { + if let importedShareIDs = drafts[key]?.importedShareIDs, + !importedShareIDs.isEmpty { + var persisted = PersistedDraft(mergedDraft) + persisted.importedShareIDs = importedShareIDs + drafts[key] = persisted + } else { + drafts.removeValue(forKey: key) + } + } else { + var persisted = PersistedDraft(mergedDraft) + // Preserve the crash-replay ledger while the composer performs its + // ordinary debounced saves after opening an imported share. + persisted.importedShareIDs = drafts[key]?.importedShareIDs + drafts[key] = persisted + } + try persist(drafts) + loadedDrafts = drafts + } + + /// Saves an upload result only if the attachment still exists with the + /// same immutable content. Text, selection, and workspace stay unchanged. + @discardableResult + public func setUploadedReference( + _ reference: FeatureUploadedAttachmentReference, + attachment: FeatureDraftAttachment, + for key: String + ) throws -> Bool { + var drafts = try loadIfNeeded() + guard var draft = drafts[key], + let index = draft.attachments.firstIndex(where: { $0.id == attachment.id }), + draft.attachments[index].hasSameContent(as: attachment) else { return false } + draft.attachments[index].uploadedReference = reference + drafts[key] = draft + try persist(drafts) + loadedDrafts = drafts + return true + } + + /// Atomically imports one share-extension envelope into the latest stored + /// draft. The share ID is committed with the content, so a host crash after + /// this write but before inbox cleanup cannot duplicate the import. + @discardableResult + public func importSharedContent( + shareID: String, + text: String, + attachments: [FeatureDraftAttachment], + for key: String, + maximumAttachmentCount: Int = 8 + ) throws -> FeatureComposerDraft { + var drafts = try loadIfNeeded() + var persisted = drafts[key] ?? PersistedDraft(FeatureComposerDraft()) + var importedIDs = persisted.importedShareIDs ?? [] + guard !importedIDs.contains(shareID) else { + return persisted.featureValue(fileStore: attachmentFileStore) + } + + let existingIDs = Set(persisted.attachments.map(\.id)) + let uniqueAttachments = attachments.filter { !existingIDs.contains($0.id) } + let availableCount = max(0, maximumAttachmentCount - persisted.attachments.count) + guard uniqueAttachments.count <= availableCount else { + throw FeatureComposerDraftImportError.attachmentLimitExceeded( + available: availableCount + ) + } + + let incomingText = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !incomingText.isEmpty { + persisted.text = persisted.text.trimmingCharacters(in: .whitespacesAndNewlines) + persisted.text = persisted.text.isEmpty + ? incomingText + : "\(persisted.text)\n\n\(incomingText)" + } + + persisted.attachments.append(contentsOf: uniqueAttachments.map(PersistedAttachment.init)) + importedIDs.append(shareID) + persisted.importedShareIDs = Array(importedIDs.suffix(32)) + drafts[key] = persisted + try persist(drafts) + loadedDrafts = drafts + return persisted.featureValue(fileStore: attachmentFileStore) + } + + public func removeDraft(for key: String) throws { + var drafts = try loadIfNeeded() + guard drafts.removeValue(forKey: key) != nil else { return } + try persist(drafts) + loadedDrafts = drafts + } + + public func removeDrafts( + environmentID: String, + logicalProjectIDs: Set = [] + ) throws { + var drafts = try loadIfNeeded() + let environmentPrefix = "environment:\(environmentID):" + let logicalKeys = Set(logicalProjectIDs.map(Self.newTaskKey(logicalProjectID:))) + drafts = drafts.filter { + !$0.key.hasPrefix(environmentPrefix) && !logicalKeys.contains($0.key) + } + try persist(drafts) + loadedDrafts = drafts + } + + public static func threadKey(_ thread: FeatureThread) -> String { + let environment = thread.environmentID ?? "active" + let threadID = thread.wireID ?? thread.id + return "environment:\(environment):thread:\(threadID)" + } + + public static func newTaskKey(project: FeatureProject) -> String { + let projectID = project.wireID ?? project.id + return "environment:\(project.environmentID):new-task:\(projectID)" + } + + static func newTaskKey(project: FeatureProject, in snapshot: FeatureSnapshot) -> String { + guard project.repositoryIdentity != nil else { + return newTaskKey(project: project) + } + return newTaskKey( + logicalProjectID: DailyUXCreationContext.logicalProjectID( + for: project, + in: snapshot + ) + ) + } + + public static func newTaskKey(logicalProjectID: String) -> String { + "logical-project:\(logicalProjectID):new-task" + } + + private func loadIfNeeded() throws -> [String: PersistedDraft] { + if let loadedDrafts { return loadedDrafts } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + let drafts: [String: PersistedDraft] = [:] + loadedDrafts = drafts + return drafts + } + let data = try Data(contentsOf: fileURL) + let document = try JSONDecoder.t3.decode(Document.self, from: data) + guard document.version == 1 || document.version == Self.documentVersion else { + throw CocoaError(.fileReadCorruptFile) + } + var drafts = document.drafts + if document.version == 1 { + // Version 1 wrote resolved project/environment defaults into every + // new-task draft. They were not necessarily user choices, so drop + // only those derived fields while preserving text and attachments. + for key in Array(drafts.keys) where key.contains(":new-task:") { + drafts[key]?.selection = nil + drafts[key]?.workspace = nil + } + drafts = drafts.filter { + !$0.value.featureValue(fileStore: attachmentFileStore).isEmpty + } + try persist(drafts) + } + loadedDrafts = drafts + return drafts + } + + private func persist(_ drafts: [String: PersistedDraft]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let document = Document(version: Self.documentVersion, drafts: drafts) + try JSONEncoder.t3.encode(document).write(to: fileURL, options: .atomic) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureModels.swift b/apps/swift-ios/Features/Shared/FeatureModels.swift new file mode 100644 index 000000000000..a256d6e4e759 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureModels.swift @@ -0,0 +1,1176 @@ +import Foundation + +public struct FeatureConnection: Sendable, Equatable, Codable { + public enum State: String, Sendable, Hashable, Codable { + case disconnected + case connecting + case connected + case reconnecting + } + + public var state: State + public var environmentName: String? + public var endpoint: String? + public var detail: String? + + public init( + state: State = .disconnected, + environmentName: String? = nil, + endpoint: String? = nil, + detail: String? = nil + ) { + self.state = state + self.environmentName = environmentName + self.endpoint = endpoint + self.detail = detail + } +} + +public struct FeatureEnvironment: Identifiable, Sendable, Equatable, Hashable, Codable { + public enum Source: String, Sendable, Equatable, Hashable, Codable { + case direct + case t3Connect + } + + public let id: String + public var name: String + public var endpoint: String + /// Internal stream-leader compatibility. Product routing must use the + /// project or thread environment instead. + public var isActive: Bool + public var isEnabled: Bool + public var source: Source + /// Reachability from the latest aggregate refresh. `nil` means the client + /// has not probed this saved environment yet. + public var connectionState: FeatureConnection.State? + public var connectionDetail: String? + public var prismEnabled: Bool? + + public init( + id: String, + name: String, + endpoint: String, + isActive: Bool = false, + isEnabled: Bool = true, + source: Source = .direct, + connectionState: FeatureConnection.State? = nil, + connectionDetail: String? = nil, + prismEnabled: Bool? = nil + ) { + self.id = id + self.name = name + self.endpoint = endpoint + self.isActive = isActive + self.isEnabled = isEnabled + self.source = source + self.connectionState = connectionState + self.connectionDetail = connectionDetail + self.prismEnabled = prismEnabled + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case endpoint + case isActive + case isEnabled + case source + case connectionState + case connectionDetail + case prismEnabled + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + endpoint = try container.decode(String.self, forKey: .endpoint) + isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? false + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + source = try container.decodeIfPresent(Source.self, forKey: .source) ?? .direct + connectionState = try container.decodeIfPresent( + FeatureConnection.State.self, + forKey: .connectionState + ) + prismEnabled = try container.decodeIfPresent(Bool.self, forKey: .prismEnabled) + connectionDetail = try container.decodeIfPresent(String.self, forKey: .connectionDetail) + } +} + +public struct FeatureRepositoryIdentity: Sendable, Equatable, Hashable, Codable { + public var canonicalKey: String + public var rootPath: String? + public var displayName: String? + public var name: String? + + public init( + canonicalKey: String, + rootPath: String? = nil, + displayName: String? = nil, + name: String? = nil + ) { + self.canonicalKey = canonicalKey + self.rootPath = rootPath + self.displayName = displayName + self.name = name + } +} + +public struct FeatureProject: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The environment-local identifier sent over the wire. Native aggregate + /// snapshots scope `id` by environment so cloned databases remain distinct. + public var wireID: String? + public var environmentID: String + public var name: String + public var path: String + public var threadCount: Int + public var defaultSelection: FeatureSelection? + public var repositoryIdentity: FeatureRepositoryIdentity? + public var createdAt: String? + public var updatedAt: String? + + public init( + id: String, + wireID: String? = nil, + environmentID: String, + name: String, + path: String, + threadCount: Int = 0, + defaultSelection: FeatureSelection? = nil, + repositoryIdentity: FeatureRepositoryIdentity? = nil, + createdAt: String? = nil, + updatedAt: String? = nil + ) { + self.id = id + self.wireID = wireID + self.environmentID = environmentID + self.name = name + self.path = path + self.threadCount = threadCount + self.defaultSelection = defaultSelection + self.repositoryIdentity = repositoryIdentity + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public enum FeatureThreadState: String, Sendable, Codable { + case idle + case queued + case working + case monitoring + case waitingForApproval + case waitingForInput + case failed + case completed +} + +public enum FeatureRuntimeMode: String, CaseIterable, Sendable, Codable { + case approvalRequired + case autoAcceptEdits + case automatic + case fullAccess + + /// Mobile offers the two current modes. Legacy modes remain distinct so + /// existing threads keep their exact server permission. + public static let allCases: [FeatureRuntimeMode] = [.automatic, .fullAccess] +} + +public enum FeatureInteractionMode: String, CaseIterable, Sendable, Codable { + case standard + case plan + + /// Plan remains decodable for existing server state, but is no longer a + /// mobile prompt choice. + public static let allCases: [FeatureInteractionMode] = [.standard] + + public var mobileNormalized: FeatureInteractionMode { .standard } +} + +public enum FeatureThreadSettlementOverride: String, Sendable, Equatable, Hashable, Codable { + case settled + case active +} + +public struct FeatureThreadSettlementFacts: Sendable, Equatable, Hashable, Codable { + public struct LatestTurn: Sendable, Equatable, Hashable, Codable { + public var requestedAt: Date? + public var startedAt: Date? + public var completedAt: Date? + public var requestedAtIsInvalid: Bool + public var startedAtIsInvalid: Bool + public var completedAtIsInvalid: Bool + + public init( + requestedAt: Date? = nil, + startedAt: Date? = nil, + completedAt: Date? = nil, + requestedAtIsInvalid: Bool = false, + startedAtIsInvalid: Bool = false, + completedAtIsInvalid: Bool = false + ) { + self.requestedAt = requestedAt + self.startedAt = startedAt + self.completedAt = completedAt + self.requestedAtIsInvalid = requestedAtIsInvalid + self.startedAtIsInvalid = startedAtIsInvalid + self.completedAtIsInvalid = completedAtIsInvalid + } + } + + public var settlementOverride: FeatureThreadSettlementOverride? + public var sessionStatus: String? + public var hasPendingApprovals: Bool + public var hasPendingUserInput: Bool + public var latestUserMessageAt: Date? + public var latestTurn: LatestTurn? + + public init( + settlementOverride: FeatureThreadSettlementOverride? = nil, + sessionStatus: String? = nil, + hasPendingApprovals: Bool = false, + hasPendingUserInput: Bool = false, + latestUserMessageAt: Date? = nil, + latestTurn: LatestTurn? = nil + ) { + self.settlementOverride = settlementOverride + self.sessionStatus = sessionStatus + self.hasPendingApprovals = hasPendingApprovals + self.hasPendingUserInput = hasPendingUserInput + self.latestUserMessageAt = latestUserMessageAt + self.latestTurn = latestTurn + } +} + +public struct FeatureThread: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The environment-local identifier sent over the wire. + public var wireID: String? + public var projectID: String + public var environmentID: String? + public var environmentName: String? + public var title: String + public var preview: String? + public var branch: String? + public var worktreePath: String? + public var linkedPullRequest: ThreadLinkedPullRequest? + public var createdAt: Date + public var updatedAt: Date + public var state: FeatureThreadState + public var providerID: String? + public var providerName: String? + public var modelID: String? + public var modelOptions: [FeatureModelOptionSelection] + public var isArchived: Bool + public var isSettled: Bool + public var keepsActive: Bool + public var settledAt: Date? + public var unsettledAt: Date? + public var lastActivityAt: Date? + public var snoozedUntil: Date? + public var snoozedAt: Date? + public var pinnedAt: Date? + public var supportsSettlement: Bool? + public var supportsSnooze: Bool? + public var supportsPinning: Bool? + public var supportsTitleRegeneration: Bool? + public var supportsPullRequestLinking: Bool? + public var attentionAt: Date? + public var workingStartedAt: Date? + public var latestTurnCompletedAt: Date? + public var settlementFacts: FeatureThreadSettlementFacts? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + + public init( + id: String, + wireID: String? = nil, + projectID: String, + environmentID: String? = nil, + environmentName: String? = nil, + title: String, + preview: String? = nil, + branch: String? = nil, + worktreePath: String? = nil, + linkedPullRequest: ThreadLinkedPullRequest? = nil, + createdAt: Date = .now, + updatedAt: Date = .now, + state: FeatureThreadState = .idle, + providerID: String? = nil, + providerName: String? = nil, + modelID: String? = nil, + modelOptions: [FeatureModelOptionSelection] = [], + isArchived: Bool = false, + isSettled: Bool = false, + keepsActive: Bool = false, + settledAt: Date? = nil, + unsettledAt: Date? = nil, + lastActivityAt: Date? = nil, + snoozedUntil: Date? = nil, + snoozedAt: Date? = nil, + pinnedAt: Date? = nil, + supportsSettlement: Bool? = nil, + supportsSnooze: Bool? = nil, + supportsPinning: Bool? = nil, + supportsTitleRegeneration: Bool? = nil, + supportsPullRequestLinking: Bool? = nil, + attentionAt: Date? = nil, + workingStartedAt: Date? = nil, + latestTurnCompletedAt: Date? = nil, + settlementFacts: FeatureThreadSettlementFacts? = nil, + runtimeMode: FeatureRuntimeMode = .fullAccess, + interactionMode: FeatureInteractionMode = .standard + ) { + self.id = id + self.wireID = wireID + self.projectID = projectID + self.environmentID = environmentID + self.environmentName = environmentName + self.title = title + self.preview = preview + self.branch = branch + self.worktreePath = worktreePath + self.linkedPullRequest = linkedPullRequest + self.createdAt = createdAt + self.updatedAt = updatedAt + self.state = state + self.providerID = providerID + self.providerName = providerName + self.modelID = modelID + self.modelOptions = modelOptions + self.isArchived = isArchived + self.isSettled = isSettled + self.keepsActive = keepsActive + self.settledAt = settledAt + self.unsettledAt = unsettledAt + self.lastActivityAt = lastActivityAt + self.snoozedUntil = snoozedUntil + self.snoozedAt = snoozedAt + self.pinnedAt = pinnedAt + self.supportsSettlement = supportsSettlement + self.supportsSnooze = supportsSnooze + self.supportsPinning = supportsPinning + self.supportsTitleRegeneration = supportsTitleRegeneration + self.supportsPullRequestLinking = supportsPullRequestLinking + self.attentionAt = attentionAt + self.workingStartedAt = workingStartedAt + self.latestTurnCompletedAt = latestTurnCompletedAt + self.settlementFacts = settlementFacts + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode + } + + /// Missing capabilities mean unsupported. Existing states remain reversible + /// so older cached snapshots cannot trap a thread in its current state. + public var canTogglePin: Bool { + pinnedAt != nil || supportsPinning == true + } + + public var canToggleSettlement: Bool { + isSettled || supportsSettlement == true + } + + public var canToggleSnooze: Bool { + snoozedUntil != nil || supportsSnooze == true + } + +} + +public enum FeatureMessageRole: String, Sendable, Codable { + case user + case assistant + case system + case tool +} + +public enum FeatureMessageState: String, Sendable, Codable { + case queued + case streaming + case complete + case failed +} + +public struct FeatureMessageAttachment: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var mimeType: String + public var sizeBytes: Int + public var url: URL? + /// Small local preview retained only while an optimistic message is replaced + /// by its server-backed attachment URL. + public var previewData: Data? + + public init( + id: String, + name: String, + mimeType: String, + sizeBytes: Int, + url: URL? = nil, + previewData: Data? = nil + ) { + self.id = id + self.name = name + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.url = url + self.previewData = previewData + } +} + +public struct FeatureUploadAttachment: Sendable, Equatable { + public let id: UUID + private var inlineData: Data? + public var ownedFile: FeatureOwnedAttachmentFile? + public var name: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init(_ draft: FeatureDraftAttachment) { + id = draft.id + inlineData = draft.ownedFile == nil ? draft.data : nil + ownedFile = draft.ownedFile + name = draft.filename + mimeType = draft.mimeType + uploadedReference = draft.uploadedReference + } + + public var data: Data { + get { inlineData ?? Data() } + set { + inlineData = newValue + ownedFile = nil + } + } + + public var byteCount: Int { + inlineData?.count ?? ownedFile?.byteCount ?? 0 + } +} + +public struct FeatureMessage: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var role: FeatureMessageRole + public var text: String + public var createdAt: Date + public var state: FeatureMessageState + public var toolName: String? + public var attachments: [FeatureMessageAttachment] + public var workLogImagePaths: [String]? + public var activeWorkLabel: String? + + public init( + id: String, + role: FeatureMessageRole, + text: String, + createdAt: Date = .now, + state: FeatureMessageState = .complete, + toolName: String? = nil, + attachments: [FeatureMessageAttachment] = [], + workLogImagePaths: [String]? = nil, + activeWorkLabel: String? = nil + ) { + self.id = id + self.role = role + self.text = text + self.createdAt = createdAt + self.state = state + self.toolName = toolName + self.attachments = attachments + self.workLogImagePaths = workLogImagePaths + self.activeWorkLabel = activeWorkLabel + } +} + +public enum FeatureApprovalKind: String, Sendable, Codable { + case command + case fileRead + case fileChange + case mcpElicitation + case patch + case other +} + +public struct FeatureApprovalOption: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: FeatureApprovalDecision { decision } + public let decision: FeatureApprovalDecision + public let label: String + + public init(decision: FeatureApprovalDecision, label: String) { + self.decision = decision + self.label = label + } +} + +public struct FeatureApproval: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The provider request identifier sent over the wire. + public var wireID: String? + public var threadID: String + public var kind: FeatureApprovalKind + public var title: String + public var detail: String + public var appName: String? + public var options: [FeatureApprovalOption]? + + public init( + id: String, + wireID: String? = nil, + threadID: String, + kind: FeatureApprovalKind, + title: String, + detail: String, + appName: String? = nil, + options: [FeatureApprovalOption]? = nil + ) { + self.id = id + self.wireID = wireID + self.threadID = threadID + self.kind = kind + self.title = title + self.detail = detail + self.appName = appName + self.options = options + } +} + +public struct FeatureInputOption: Sendable, Equatable, Hashable, Codable { + public var label: String + public var detail: String + + public init(label: String, detail: String) { + self.label = label + self.detail = detail + } +} + +/// A provider answer is either free-form/single-select text or the selected +/// labels for a multi-select question. Its Codable shape intentionally matches +/// the provider wire contract: a JSON string or an array of JSON strings. +public enum FeatureInputAnswer: Sendable, Equatable, Hashable, Codable { + case text(String) + case selections([String]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .text(value) + } else { + self = try .selections(container.decode([String].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .text(value): + try container.encode(value) + case let .selections(values): + try container.encode(values) + } + } +} + +extension FeatureInputAnswer { + var normalized: FeatureInputAnswer? { + switch self { + case let .text(value): + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : .text(normalized) + case let .selections(values): + var seen: Set = [] + let normalized = values.compactMap { value -> String? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + return normalized.isEmpty ? nil : .selections(normalized) + } + } + + func togglingOption(_ label: String, allowsMultiple: Bool) -> FeatureInputAnswer { + guard allowsMultiple else { return .text(label) } + + let current: [String] + if case let .selections(values) = self { + current = values + } else { + current = [] + } + + if current.contains(label) { + return .selections(current.filter { $0 != label }) + } + return .selections(current + [label]) + } +} + +public struct FeatureInputQuestion: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var header: String + public var question: String + public var options: [FeatureInputOption] + public var allowsMultiple: Bool + + public init( + id: String, + header: String, + question: String, + options: [FeatureInputOption] = [], + allowsMultiple: Bool = false + ) { + self.id = id + self.header = header + self.question = question + self.options = options + self.allowsMultiple = allowsMultiple + } +} + +public struct FeatureUserInput: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + /// The provider request identifier sent over the wire. + public var wireID: String? + public var threadID: String + public var questions: [FeatureInputQuestion] + + public init( + id: String, + wireID: String? = nil, + threadID: String, + questions: [FeatureInputQuestion] + ) { + self.id = id + self.wireID = wireID + self.threadID = threadID + self.questions = questions + } +} + +/// Stable UI identity for entities merged from independent environments. +/// Length-prefixing avoids separator collisions without requiring IDs to be parsed. +enum FeatureScopedID { + static func project(environmentID: String, wireID: String) -> String { + make(kind: "project", environmentID: environmentID, wireID: wireID) + } + + static func thread(environmentID: String, wireID: String) -> String { + make(kind: "thread", environmentID: environmentID, wireID: wireID) + } + + static func approval(environmentID: String, wireID: String) -> String { + make(kind: "approval", environmentID: environmentID, wireID: wireID) + } + + static func input(environmentID: String, wireID: String) -> String { + make(kind: "input", environmentID: environmentID, wireID: wireID) + } + + private static func make(kind: String, environmentID: String, wireID: String) -> String { + "\(kind):\(environmentID.utf8.count):\(environmentID)\(wireID)" + } +} + +public struct FeatureThreadDetail: Sendable, Equatable, Codable { + public var thread: FeatureThread + public var messages: [FeatureMessage] + public var approvals: [FeatureApproval] + public var userInputs: [FeatureUserInput] + public var page: FeatureThreadPage? + public var activeSubagentCount: Int + public var backgroundWorkIsActive: Bool + + public init( + thread: FeatureThread, + messages: [FeatureMessage] = [], + approvals: [FeatureApproval] = [], + userInputs: [FeatureUserInput] = [], + page: FeatureThreadPage? = nil, + activeSubagentCount: Int = 0, + backgroundWorkIsActive: Bool = false + ) { + self.thread = thread + self.messages = messages + self.approvals = approvals + self.userInputs = userInputs + self.page = page + self.activeSubagentCount = activeSubagentCount + self.backgroundWorkIsActive = backgroundWorkIsActive + } +} + +public struct FeatureThreadPage: Sendable, Equatable, Codable { + public var beforeCursor: String? + public var hasMore: Bool + public var isLoading: Bool + + public init(beforeCursor: String?, hasMore: Bool, isLoading: Bool = false) { + self.beforeCursor = beforeCursor + self.hasMore = hasMore + self.isLoading = isLoading + } +} + +/// The small rendered-message delta produced by the native thread stream. +/// Keeping this beside the authoritative detail lets recycled transcript rows +/// update in proportion to an event instead of rescanning the full history. +public struct FeatureDetailDelta: Sendable, Equatable { + public var changedMessages: [FeatureMessage] + public var appendedMessageIDs: [String] + + public init( + changedMessages: [FeatureMessage], + appendedMessageIDs: [String] = [] + ) { + self.changedMessages = changedMessages + self.appendedMessageIDs = appendedMessageIDs + } +} + +public struct FeatureModel: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var detail: String? + public var supportsImages: Bool + public var supportsReasoning: Bool + public var isDefault: Bool + public var isLegacy: Bool? + public var options: [FeatureModelOptionDescriptor] + + public init( + id: String, + name: String, + detail: String? = nil, + supportsImages: Bool = false, + supportsReasoning: Bool = false, + isDefault: Bool = false, + isLegacy: Bool? = nil, + options: [FeatureModelOptionDescriptor] = [] + ) { + self.id = id + self.name = name + self.detail = detail + self.supportsImages = supportsImages + self.supportsReasoning = supportsReasoning + self.isDefault = isDefault + self.isLegacy = isLegacy + self.options = options + } +} + +public enum FeatureModelOptionKind: String, Sendable, Equatable, Hashable, Codable { + case select + case boolean +} + +public struct FeatureModelOptionChoice: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var label: String + public var detail: String? + public var isDefault: Bool + + public init( + id: String, + label: String, + detail: String? = nil, + isDefault: Bool = false + ) { + self.id = id + self.label = label + self.detail = detail + self.isDefault = isDefault + } +} + +public struct FeatureModelOptionDescriptor: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var label: String + public var detail: String? + public var kind: FeatureModelOptionKind + public var choices: [FeatureModelOptionChoice] + public var defaultValue: FeatureModelOptionValue? + + public init( + id: String, + label: String, + detail: String? = nil, + kind: FeatureModelOptionKind, + choices: [FeatureModelOptionChoice] = [], + defaultValue: FeatureModelOptionValue? = nil + ) { + self.id = id + self.label = label + self.detail = detail + self.kind = kind + self.choices = choices + self.defaultValue = defaultValue + } +} + +public enum FeatureModelOptionValue: Sendable, Equatable, Hashable, Codable { + case string(String) + case boolean(Bool) + + private enum CodingKeys: String, CodingKey { + case type + case value + } + + private enum ValueType: String, Codable { + case string + case boolean + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(ValueType.self, forKey: .type) { + case .string: + self = try .string(container.decode(String.self, forKey: .value)) + case .boolean: + self = try .boolean(container.decode(Bool.self, forKey: .value)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .string(value): + try container.encode(ValueType.string, forKey: .type) + try container.encode(value, forKey: .value) + case let .boolean(value): + try container.encode(ValueType.boolean, forKey: .type) + try container.encode(value, forKey: .value) + } + } +} + +public struct FeatureModelOptionSelection: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var value: FeatureModelOptionValue + + public init(id: String, value: FeatureModelOptionValue) { + self.id = id + self.value = value + } +} + +public struct FeatureProvider: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var name: String + public var isAvailable: Bool + public var driver: String + public var requiresNewThreadForModelChange: Bool + public var models: [FeatureModel] + public var slashCommands: [FeatureProviderSlashCommand]? + public var skills: [FeatureProviderSkill]? + + public init( + id: String, + name: String, + isAvailable: Bool = true, + driver: String = "", + requiresNewThreadForModelChange: Bool = false, + models: [FeatureModel] = [], + slashCommands: [FeatureProviderSlashCommand] = [], + skills: [FeatureProviderSkill] = [] + ) { + self.id = id + self.name = name + self.isAvailable = isAvailable + self.driver = driver + self.requiresNewThreadForModelChange = requiresNewThreadForModelChange + self.models = models + self.slashCommands = slashCommands + self.skills = skills + } +} + +public struct FeatureSelection: Sendable, Equatable, Hashable, Codable { + public var providerID: String + public var modelID: String + public var options: [FeatureModelOptionSelection] + + public init( + providerID: String, + modelID: String, + options: [FeatureModelOptionSelection] = [] + ) { + self.providerID = providerID + self.modelID = modelID + self.options = options + } +} + +public enum FeatureAppearance: String, CaseIterable, Sendable, Codable { + case system + case light + case dark +} + +public struct FeatureSettings: Sendable, Equatable, Codable { + public var appearance: FeatureAppearance + public var hapticsEnabled: Bool + public var notificationsEnabled: Bool + public var liveActivitiesEnabled: Bool + public var defaultSelection: FeatureSelection? + + public init( + appearance: FeatureAppearance = .system, + hapticsEnabled: Bool = true, + notificationsEnabled: Bool = true, + liveActivitiesEnabled: Bool = true, + defaultSelection: FeatureSelection? = nil + ) { + self.appearance = appearance + self.hapticsEnabled = hapticsEnabled + self.notificationsEnabled = notificationsEnabled + self.liveActivitiesEnabled = liveActivitiesEnabled + self.defaultSelection = defaultSelection + } + + private enum CodingKeys: String, CodingKey { + case appearance + case hapticsEnabled + case notificationsEnabled + case liveActivitiesEnabled + case defaultSelection + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + appearance = try container.decodeIfPresent( + FeatureAppearance.self, + forKey: .appearance + ) ?? .system + hapticsEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .hapticsEnabled + ) ?? true + notificationsEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .notificationsEnabled + ) ?? true + liveActivitiesEnabled = try container.decodeIfPresent( + Bool.self, + forKey: .liveActivitiesEnabled + ) ?? true + defaultSelection = try container.decodeIfPresent( + FeatureSelection.self, + forKey: .defaultSelection + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(appearance, forKey: .appearance) + try container.encode(hapticsEnabled, forKey: .hapticsEnabled) + try container.encode(notificationsEnabled, forKey: .notificationsEnabled) + try container.encode(liveActivitiesEnabled, forKey: .liveActivitiesEnabled) + try container.encodeIfPresent(defaultSelection, forKey: .defaultSelection) + } +} + +public struct FeatureEnvironmentPreferences: Sendable, Equatable, Codable { + public enum ProjectGroupingMode: String, Sendable, Equatable, Codable { + case repository + case repositoryPath = "repository_path" + case separate + } + + public var defaultWorkspaceMode: FeatureWorkspaceMode + public var newWorktreesStartFromOrigin: Bool + public var projectGroupingMode: ProjectGroupingMode + public var projectGroupingOverrides: [String: ProjectGroupingMode] + public var automaticSettlement: FeatureAutomaticSettlementSettings? + public var supportsImageUploads: Bool + public var maxFileAttachmentBytes: Int? + + public init( + defaultWorkspaceMode: FeatureWorkspaceMode = .local, + newWorktreesStartFromOrigin: Bool = true, + projectGroupingMode: ProjectGroupingMode = .repository, + projectGroupingOverrides: [String: ProjectGroupingMode] = [:], + automaticSettlement: FeatureAutomaticSettlementSettings? = nil, + supportsImageUploads: Bool = false, + maxFileAttachmentBytes: Int? = nil + ) { + self.defaultWorkspaceMode = defaultWorkspaceMode + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.projectGroupingMode = projectGroupingMode + self.projectGroupingOverrides = projectGroupingOverrides + self.automaticSettlement = automaticSettlement + self.supportsImageUploads = supportsImageUploads + self.maxFileAttachmentBytes = maxFileAttachmentBytes + } + + private enum CodingKeys: String, CodingKey { + case defaultWorkspaceMode + case newWorktreesStartFromOrigin + case projectGroupingMode + case projectGroupingOverrides + case automaticSettlement + case supportsImageUploads + case maxFileAttachmentBytes + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + defaultWorkspaceMode = try container.decodeIfPresent( + FeatureWorkspaceMode.self, + forKey: .defaultWorkspaceMode + ) ?? .local + newWorktreesStartFromOrigin = try container.decodeIfPresent( + Bool.self, + forKey: .newWorktreesStartFromOrigin + ) ?? true + projectGroupingMode = try container.decodeIfPresent( + ProjectGroupingMode.self, + forKey: .projectGroupingMode + ) ?? .repository + supportsImageUploads = try container.decodeIfPresent( + Bool.self, + forKey: .supportsImageUploads + ) ?? false + maxFileAttachmentBytes = try container.decodeIfPresent( + Int.self, + forKey: .maxFileAttachmentBytes + ) + projectGroupingOverrides = try container.decodeIfPresent( + [String: ProjectGroupingMode].self, + forKey: .projectGroupingOverrides + ) ?? [:] + automaticSettlement = try container.decodeIfPresent( + FeatureAutomaticSettlementSettings.self, + forKey: .automaticSettlement + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(defaultWorkspaceMode, forKey: .defaultWorkspaceMode) + try container.encode(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) + try container.encode(projectGroupingMode, forKey: .projectGroupingMode) + try container.encode(projectGroupingOverrides, forKey: .projectGroupingOverrides) + try container.encodeIfPresent(automaticSettlement, forKey: .automaticSettlement) + try container.encode(supportsImageUploads, forKey: .supportsImageUploads) + try container.encodeIfPresent(maxFileAttachmentBytes, forKey: .maxFileAttachmentBytes) + } +} + +public struct FeatureAutomaticSettlementSettings: Sendable, Equatable, Codable { + public var onMerge: Bool + public var afterDays: Double? + + public init(onMerge: Bool, afterDays: Double?) { + self.onMerge = onMerge + self.afterDays = afterDays + } +} + +public enum FeatureAutomaticSettlementChange: Sendable, Equatable { + case onMerge(Bool) + case afterDays(Double?) +} + +public struct FeatureSnapshot: Sendable, Equatable, Codable { + public var connection: FeatureConnection + public var environments: [FeatureEnvironment] + public var projects: [FeatureProject] + public var threads: [FeatureThread] + public var providers: [FeatureProvider] + /// Provider catalogues are environment-scoped. `providers` remains only + /// for decoding older cached snapshots and must not drive product choices. + public var providersByEnvironment: [String: [FeatureProvider]]? + /// Server-authoritative new-thread defaults keyed by saved environment. + public var preferencesByEnvironment: [String: FeatureEnvironmentPreferences]? + public var settings: FeatureSettings + + public init( + connection: FeatureConnection = .init(), + environments: [FeatureEnvironment] = [], + projects: [FeatureProject] = [], + threads: [FeatureThread] = [], + providers: [FeatureProvider] = [], + providersByEnvironment: [String: [FeatureProvider]]? = nil, + preferencesByEnvironment: [String: FeatureEnvironmentPreferences]? = nil, + settings: FeatureSettings = .init() + ) { + self.connection = connection + self.environments = environments + self.projects = projects + self.threads = threads + self.providers = providers + self.providersByEnvironment = providersByEnvironment + self.preferencesByEnvironment = preferencesByEnvironment + self.settings = settings + } +} + +public enum FeatureApprovalDecision: String, Sendable, Codable { + case allowOnce + case allowForSession + case allowAlways + case deny + case cancel + + init?(wireValue: String) { + switch wireValue { + case "accept": self = .allowOnce + case "acceptForSession": self = .allowForSession + case "acceptAlways": self = .allowAlways + case "decline": self = .deny + case "cancel": self = .cancel + default: return nil + } + } + + var wireValue: String { + switch self { + case .allowOnce: "accept" + case .allowForSession: "acceptForSession" + case .allowAlways: "acceptAlways" + case .deny: "decline" + case .cancel: "cancel" + } + } +} + +public enum FeatureEvent: Sendable { + case snapshot(FeatureSnapshot) + case connection(FeatureConnection) + case thread(FeatureThread) + case threadRemoved(id: String) + case detail(FeatureThreadDetail) + case detailDelta(FeatureThreadDetail, FeatureDetailDelta) + case failure(String) +} diff --git a/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift b/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift new file mode 100644 index 000000000000..39d6efe7720e --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureNativeMediaPreview.swift @@ -0,0 +1,363 @@ +import AVKit +import Foundation +import QuickLook +import SwiftUI +import UIKit + +enum FeatureMediaPreviewSource: Equatable { + case localImage(Data) + case file(URL) + case remote(URL) +} + +struct FeatureTypedMediaPreviewRoute: Equatable { + let path: String + let kind: FeatureFilePreviewKind + + static func parse(_ url: URL) -> Self? { + guard url.scheme?.lowercased() == "t3code", + url.host?.lowercased() == "media-preview", + url.path == "/open", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let path = components.queryItems?.first(where: { $0.name == "path" })?.value, + !path.isEmpty, path.count <= 1_024, + let rawKind = components.queryItems?.first(where: { $0.name == "kind" })?.value + else { return nil } + let kind: FeatureFilePreviewKind + switch rawKind { + case "image": kind = .image + case "video": kind = .video + default: return nil + } + return Self(path: path, kind: kind) + } +} + +struct FeatureMediaPreviewGeneration { + private(set) var value = 0 + mutating func begin() -> Int { + value += 1 + return value + } + mutating func invalidate() { value += 1 } + func isCurrent(_ candidate: Int) -> Bool { value == candidate } +} + +enum FeatureMediaPreviewError: LocalizedError, Equatable { + case invalidResponse + case httpStatus(Int) + case tooLarge + case invalidFileName + + var errorDescription: String? { + switch self { + case .invalidResponse: "The server returned an invalid file." + case let .httpStatus(status): "The server returned HTTP \(status)." + case .tooLarge: "The file is too large to preview." + case .invalidFileName: "The file name is invalid." + } + } +} + +enum FeatureMediaPreviewFiles { + static let maximumBytes: Int64 = 64 * 1_024 * 1_024 + + static func safeFileName(_ proposedName: String) throws -> String { + let name = URL(fileURLWithPath: proposedName).lastPathComponent + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name != ".", name != "..", name.utf8.count <= 255, + !name.contains("/") else { + throw FeatureMediaPreviewError.invalidFileName + } + return name.replacingOccurrences(of: ":", with: "_") + } + + static func ownedDirectory(fileManager: FileManager = .default) throws -> URL { + let directory = fileManager.temporaryDirectory + .appendingPathComponent("T3CodePreviews", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func shareURL( + for source: FeatureMediaPreviewSource, + downloadedURL: URL? + ) -> URL? { + switch source { + case let .file(url): url + case .localImage, .remote: downloadedURL + } + } +} + +@MainActor +final class FeatureMediaPreviewLoader: ObservableObject { + @Published private(set) var fileURL: URL? + @Published private(set) var errorMessage: String? + @Published private(set) var isLoading = false + + private var ownedDirectory: URL? + private var generation = FeatureMediaPreviewGeneration() + + deinit { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + } + + func load(source: FeatureMediaPreviewSource, fileName: String) async { + guard fileURL == nil, !isLoading else { return } + let activeGeneration = generation.begin() + isLoading = true + defer { + if generation.isCurrent(activeGeneration) { isLoading = false } + } + do { + switch source { + case let .file(url): + fileURL = url + case let .localImage(data): + guard Int64(data.count) <= FeatureMediaPreviewFiles.maximumBytes else { + throw FeatureMediaPreviewError.tooLarge + } + let directory = try FeatureMediaPreviewFiles.ownedDirectory() + ownedDirectory = directory + let destination = directory.appendingPathComponent( + try FeatureMediaPreviewFiles.safeFileName(fileName) + ) + try data.write(to: destination, options: .atomic) + fileURL = destination + case let .remote(url): + let (temporaryURL, response) = try await URLSession.shared.download(from: url) + defer { try? FileManager.default.removeItem(at: temporaryURL) } + guard generation.isCurrent(activeGeneration), !Task.isCancelled else { return } + guard let response = response as? HTTPURLResponse else { + throw FeatureMediaPreviewError.invalidResponse + } + guard (200 ... 299).contains(response.statusCode) else { + throw FeatureMediaPreviewError.httpStatus(response.statusCode) + } + if response.expectedContentLength > FeatureMediaPreviewFiles.maximumBytes { + throw FeatureMediaPreviewError.tooLarge + } + let byteCount = try temporaryURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard Int64(byteCount) <= FeatureMediaPreviewFiles.maximumBytes else { + throw FeatureMediaPreviewError.tooLarge + } + let directory = try FeatureMediaPreviewFiles.ownedDirectory() + ownedDirectory = directory + let destination = directory.appendingPathComponent( + try FeatureMediaPreviewFiles.safeFileName(fileName) + ) + try FileManager.default.moveItem(at: temporaryURL, to: destination) + guard generation.isCurrent(activeGeneration), !Task.isCancelled else { + cleanUp() + return + } + fileURL = destination + } + } catch is CancellationError { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + ownedDirectory = nil + return + } catch { + if let ownedDirectory { try? FileManager.default.removeItem(at: ownedDirectory) } + ownedDirectory = nil + guard generation.isCurrent(activeGeneration) else { return } + errorMessage = error.localizedDescription + } + } + + func cleanUp() { + generation.invalidate() + guard let ownedDirectory else { return } + try? FileManager.default.removeItem(at: ownedDirectory) + self.ownedDirectory = nil + fileURL = nil + } +} + +struct FeatureNativeMediaPreviewView: View { + let source: FeatureMediaPreviewSource + let kind: FeatureFilePreviewKind + let fileName: String + + @StateObject private var loader = FeatureMediaPreviewLoader() + @State private var sharedFile: FeatureSharedFile? + + var body: some View { + Group { + if kind == .video, case let .remote(url) = source { + FeatureVideoPlayerView(url: url) + } else if kind == .image, case let .localImage(data) = source, + let image = UIImage(data: data) { + FeatureNativeZoomableImageView(image: image) + } else if let fileURL = loader.fileURL { + preview(fileURL) + } else if let errorMessage = loader.errorMessage { + ContentUnavailableView( + "Preview unavailable", + systemImage: "doc.badge.ellipsis", + description: Text(errorMessage) + ) + } else { + Text("Loading preview…") + .foregroundStyle(T3Colors.textSecondary) + } + } + .background(kind == .image || kind == .video ? Color.black : T3Colors.background) + .task { + if kind != .video || !isRemoteSource { + await loader.load(source: source, fileName: fileName) + } + } + .onDisappear { + loader.cleanUp() + } + .sheet(item: $sharedFile) { file in + FeatureFileActivityView(url: file.url) + } + .toolbar { + if kind == .video, isRemoteSource { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + await loader.load(source: source, fileName: fileName) + sharedFile = FeatureMediaPreviewFiles.shareURL( + for: source, + downloadedURL: loader.fileURL + ).map(FeatureSharedFile.init) + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(loader.isLoading) + .accessibilityLabel("Share file") + } + } else if let fileURL = loader.fileURL { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: fileURL) { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share file") + } + } + } + } + + private var isRemoteSource: Bool { + if case .remote = source { true } else { false } + } + + @ViewBuilder + private func preview(_ url: URL) -> some View { + switch kind { + case .image: + if let image = UIImage(contentsOfFile: url.path) { + FeatureNativeZoomableImageView(image: image) + } else { + ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark") + } + case .video: + FeatureVideoPlayerView(url: url) + case .pdf, .document: + FeatureQuickLookPreview(url: url) + case .markdown, .source, .plainText: + FeatureQuickLookPreview(url: url) + } + } +} + +private struct FeatureVideoPlayerView: View { + let url: URL + @State private var player: AVPlayer + + init(url: URL) { + self.url = url + _player = State(initialValue: AVPlayer(url: url)) + } + + var body: some View { + VideoPlayer(player: player) + .onDisappear { + player.pause() + player.replaceCurrentItem(with: nil) + } + } +} + +private struct FeatureFileActivityView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [url], applicationActivities: nil) + } + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +private struct FeatureSharedFile: Identifiable { + let url: URL + var id: URL { url } +} + +private struct FeatureQuickLookPreview: UIViewControllerRepresentable { + let url: URL + + func makeCoordinator() -> Coordinator { Coordinator(url: url) } + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + context.coordinator.url = url + controller.reloadData() + } + + final class Coordinator: NSObject, QLPreviewControllerDataSource { + var url: URL + init(url: URL) { self.url = url } + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } + func previewController( + _ controller: QLPreviewController, + previewItemAt index: Int + ) -> QLPreviewItem { url as NSURL } + } +} + +private struct FeatureNativeZoomableImageView: UIViewRepresentable { + let image: UIImage + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.backgroundColor = .black + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = 6 + let imageView = context.coordinator.imageView + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.accessibilityLabel = "Image preview" + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + context.coordinator.scrollView = scrollView + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.imageView.image = image + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + weak var scrollView: UIScrollView? + func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift new file mode 100644 index 000000000000..be624358e073 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureOutboxStore.swift @@ -0,0 +1,318 @@ +import Foundation + +/// Stable wire identities make an outbox retry idempotent across app launches, +/// including the ambiguous case where the server committed a command but its +/// response never reached the phone. +public struct FeatureSubmissionIdentity: Sendable, Equatable, Hashable, Codable { + public var threadID: String + public var commandID: String + public var messageID: String + public var createdAt: Date + + public init( + threadID: String = UUID().uuidString, + commandID: String = UUID().uuidString, + messageID: String = UUID().uuidString, + createdAt: Date = .now + ) { + self.threadID = threadID + self.commandID = commandID + self.messageID = messageID + self.createdAt = createdAt + } +} + +public struct FeatureQueuedAttachment: Sendable, Equatable, Codable { + public var id: UUID + public var data: Data? + public var ownedFileName: String? + public var byteCount: Int? + public var name: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + private var resolvedOwnedFile: FeatureOwnedAttachmentFile? + + public init( + id: UUID = UUID(), + data: Data, + name: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + self.data = data + ownedFileName = nil + byteCount = data.count + self.name = name + self.mimeType = mimeType + self.uploadedReference = uploadedReference + resolvedOwnedFile = nil + } + + init(_ attachment: FeatureUploadAttachment) { + id = attachment.id + data = attachment.ownedFile == nil ? attachment.data : nil + ownedFileName = attachment.ownedFile?.fileName + byteCount = attachment.byteCount + name = attachment.name + mimeType = attachment.mimeType + uploadedReference = attachment.uploadedReference + resolvedOwnedFile = attachment.ownedFile + } + + private enum CodingKeys: String, CodingKey { + case id, data, ownedFileName, byteCount, name, mimeType, uploadedReference + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + data = try container.decodeIfPresent(Data.self, forKey: .data) + ownedFileName = try container.decodeIfPresent(String.self, forKey: .ownedFileName) + byteCount = try container.decodeIfPresent(Int.self, forKey: .byteCount) ?? data?.count + name = try container.decode(String.self, forKey: .name) + mimeType = try container.decode(String.self, forKey: .mimeType) + uploadedReference = try container.decodeIfPresent( + FeatureUploadedAttachmentReference.self, + forKey: .uploadedReference + ) + resolvedOwnedFile = nil + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(data, forKey: .data) + try container.encodeIfPresent(ownedFileName, forKey: .ownedFileName) + try container.encodeIfPresent(byteCount, forKey: .byteCount) + try container.encode(name, forKey: .name) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(uploadedReference, forKey: .uploadedReference) + } + + mutating func resolveOwnedFile(using fileStore: ManagedAttachmentFileStore) { + guard let ownedFileName else { return } + resolvedOwnedFile = try? fileStore.resolvedFile( + fileName: ownedFileName, + byteCount: byteCount ?? 0 + ) + } + + var upload: FeatureUploadAttachment? { + if let resolvedOwnedFile { + return FeatureUploadAttachment( + id: id, + ownedFile: resolvedOwnedFile, + name: name, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } + guard let data else { return nil } + return FeatureUploadAttachment( + id: id, + data: data, + name: name, + mimeType: mimeType, + uploadedReference: uploadedReference + ) + } +} + +public struct FeatureQueuedCreation: Sendable, Equatable, Codable { + public var projectID: String + public var projectName: String + public var workspaceMode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + + public init( + projectID: String, + projectName: String, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool + ) { + self.projectID = projectID + self.projectName = projectName + self.workspaceMode = workspaceMode + self.branch = branch + self.worktreePath = worktreePath + self.startFromOrigin = startFromOrigin + } +} + +public struct FeatureQueuedSubmission: Identifiable, Sendable, Equatable, Codable { + public let id: String + public var environmentID: String + public var identity: FeatureSubmissionIdentity + public var threadID: String + public var text: String + public var selection: FeatureSelection? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + public var attachments: [FeatureQueuedAttachment] + public var creation: FeatureQueuedCreation? + + public init( + id: String? = nil, + environmentID: String, + identity: FeatureSubmissionIdentity, + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment], + creation: FeatureQueuedCreation? = nil + ) { + self.id = id ?? identity.messageID + self.environmentID = environmentID + self.identity = identity + self.threadID = threadID + self.text = text + self.selection = selection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode.mobileNormalized + self.attachments = attachments.map(FeatureQueuedAttachment.init) + self.creation = creation + } + + public var uploads: [FeatureUploadAttachment] { + attachments.compactMap(\.upload) + } +} + +public enum FeatureOutboxDeliveryDecision: Equatable { + case discard + case wait + case send +} + +public enum FeatureOutboxPolicy { + /// Delivery waits for the owning environment. Existing threads accept + /// follow-up messages while a turn is running, matching the web queue. + /// A created thread does not prove that its first message was accepted. + /// Retry its stable command identity until the message itself is confirmed. + public static func decision( + for submission: FeatureQueuedSubmission, + snapshot: FeatureSnapshot, + pendingCreationThreadIDs: Set = [] + ) -> FeatureOutboxDeliveryDecision { + let environment = snapshot.environments.first { $0.id == submission.environmentID } + let isConnected = environment?.isEnabled == true + && environment?.connectionState == .connected + let thread = snapshot.threads.first { $0.id == submission.threadID } + + if submission.creation != nil { + if thread != nil { return isConnected ? .send : .wait } + let projectExists = snapshot.projects.contains { + $0.id == submission.creation?.projectID + && $0.environmentID == submission.environmentID + } + if isConnected, !projectExists { return .discard } + return isConnected ? .send : .wait + } + + if pendingCreationThreadIDs.contains(submission.threadID) { + return .wait + } + guard thread != nil else { + // A fully synchronized environment proves the thread was deleted. + return isConnected ? .discard : .wait + } + guard isConnected else { return .wait } + return .send + } +} + +public actor FeatureOutboxStore { + private struct Document: Codable { + var version = 1 + var submissions: [FeatureQueuedSubmission] + } + + public static let shared = FeatureOutboxStore() + + public let fileURL: URL + public let attachmentFileStore: ManagedAttachmentFileStore + private var cached: [FeatureQueuedSubmission]? + + public init(fileURL: URL? = nil, attachmentStorageRootURL: URL? = nil) { + attachmentFileStore = ManagedAttachmentFileStore(rootURL: attachmentStorageRootURL) + if let fileURL { + self.fileURL = fileURL + } else { + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.fileURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("outbox.json", isDirectory: false) + } + } + + public func submissions() throws -> [FeatureQueuedSubmission] { + if let cached { return cached } + guard FileManager.default.fileExists(atPath: fileURL.path) else { + cached = [] + return [] + } + let document: Document + do { + document = try JSONDecoder.t3.decode( + Document.self, + from: Data(contentsOf: fileURL) + ) + } catch { + // Surface the recovery once, but do not permanently brick sending. + // The next enqueue atomically replaces the unreadable document. + cached = [] + throw error + } + cached = document.submissions.map { submission in + var submission = submission + submission.interactionMode = submission.interactionMode.mobileNormalized + for index in submission.attachments.indices { + submission.attachments[index].resolveOwnedFile(using: attachmentFileStore) + } + return submission + }.sorted { + $0.identity.createdAt < $1.identity.createdAt + } + return cached ?? [] + } + + public func enqueue(_ submission: FeatureQueuedSubmission) throws { + var values = try submissions() + values.removeAll { $0.id == submission.id } + values.append(submission) + values.sort { $0.identity.createdAt < $1.identity.createdAt } + try save(values) + } + + public func remove(id: String) throws { + var values = try submissions() + values.removeAll { $0.id == id } + try save(values) + } + + public func removeAll(environmentID: String) throws { + var values = try submissions() + values.removeAll { $0.environmentID == environmentID } + try save(values) + } + + private func save(_ submissions: [FeatureQueuedSubmission]) throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder.t3.encode(Document(submissions: submissions)) + try data.write(to: fileURL, options: .atomic) + cached = submissions + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift b/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift new file mode 100644 index 000000000000..9fd252f6d51d --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureProjectFaviconImageDecoder.swift @@ -0,0 +1,92 @@ +import Foundation +import UIKit +import WebKit + +enum FeatureProjectFaviconImageDecoder { + @MainActor + static func renderableData(from data: Data) async -> Data? { + if UIImage(data: data) != nil { + return data + } + guard isSVG(data) else { return nil } + return await FeatureProjectSVGRenderSession().render(data)?.pngData() + } + + private static func isSVG(_ data: Data) -> Bool { + guard let prefix = String(data: data.prefix(4_096), encoding: .utf8) else { + return false + } + return prefix.range(of: "? + + func render(_ data: Data) async -> UIImage? { + await withCheckedContinuation { continuation in + self.continuation = continuation + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView( + frame: CGRect(x: 0, y: 0, width: 64, height: 64), + configuration: configuration + ) + webView.navigationDelegate = self + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.isScrollEnabled = false + self.webView = webView + + let source = data.base64EncodedString() + webView.loadHTMLString( + """ + + + + + """, + baseURL: nil + ) + } + } + + func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { + let configuration = WKSnapshotConfiguration() + configuration.rect = CGRect(x: 0, y: 0, width: 64, height: 64) + configuration.afterScreenUpdates = true + webView.takeSnapshot(with: configuration) { [weak self] image, _ in + Task { @MainActor in self?.finish(image) } + } + } + + func webView( + _: WKWebView, + didFail _: WKNavigation!, + withError _: Error + ) { + finish(nil) + } + + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation!, + withError _: Error + ) { + finish(nil) + } + + private func finish(_ image: UIImage?) { + guard let continuation else { return } + self.continuation = nil + webView?.navigationDelegate = nil + webView = nil + continuation.resume(returning: image) + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift b/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift new file mode 100644 index 000000000000..b0678549f3b2 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureProjectFaviconStore.swift @@ -0,0 +1,180 @@ +import CryptoKit +import Foundation + +struct FeatureProjectFaviconCacheKey: Codable, Hashable, Sendable { + let environmentID: String + let workspaceRoot: String + + init(environmentID: String, workspaceRoot: String) { + self.environmentID = environmentID + self.workspaceRoot = Self.normalize(workspaceRoot) + } + + private static func normalize(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + return URL(fileURLWithPath: trimmed).standardizedFileURL.path + } + + var fingerprint: String { + let input = Data("\(environmentID)\u{0}\(workspaceRoot)".utf8) + return SHA256.hash(data: input).map { String(format: "%02x", $0) }.joined() + } +} + +struct FeatureProjectFaviconCacheValue: Equatable, Sendable { + let data: Data? + let revision: String? + let lastCheckedAt: Date +} + +enum FeatureProjectFaviconStoreError: Error, Equatable { + case invalidDataSize +} + +/// Persists the last known project icon independently of the server's signed +/// asset URL. A later missing icon or unreachable environment updates the +/// refresh time but does not discard bytes that were already shown to a user. +actor FeatureProjectFaviconStore { + private struct Metadata: Codable { + let key: FeatureProjectFaviconCacheKey + var revision: String? + var dataFileName: String? + var lastCheckedAt: Date + } + + private struct Document: Codable { + var version = 1 + var entries: [String: Metadata] + } + + static let maximumEntryCount = 256 + static let maximumDataSize = 1 * 1_024 * 1_024 + + let directoryURL: URL + private let fileManager: FileManager + private var cachedDocument: Document? + + init(directoryURL: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + if let directoryURL { + self.directoryURL = directoryURL + } else { + let root = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.directoryURL = root + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("project-favicons", isDirectory: true) + } + } + + func value(for key: FeatureProjectFaviconCacheKey) throws + -> FeatureProjectFaviconCacheValue? + { + let document = try loadDocument() + guard let metadata = document.entries[key.fingerprint], metadata.key == key else { + return nil + } + let data = metadata.dataFileName.flatMap { fileName in + try? Data(contentsOf: directoryURL.appendingPathComponent(fileName)) + } + return FeatureProjectFaviconCacheValue( + data: data, + revision: metadata.revision, + lastCheckedAt: metadata.lastCheckedAt + ) + } + + /// Records a refresh attempt. Passing no data preserves the last known + /// icon, including when the server no longer has an icon for the project. + func record( + data: Data?, + revision: String?, + for key: FeatureProjectFaviconCacheKey, + checkedAt: Date = .now + ) throws { + if let data, data.isEmpty || data.count > Self.maximumDataSize { + throw FeatureProjectFaviconStoreError.invalidDataSize + } + + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + var document = try loadDocument() + let fingerprint = key.fingerprint + var metadata = document.entries[fingerprint] ?? Metadata( + key: key, + revision: nil, + dataFileName: nil, + lastCheckedAt: checkedAt + ) + + if let data { + let fileName = "\(fingerprint).icon" + try data.write( + to: directoryURL.appendingPathComponent(fileName), + options: .atomic + ) + metadata.dataFileName = fileName + metadata.revision = revision + } + metadata.lastCheckedAt = checkedAt + document.entries[fingerprint] = metadata + try prune(&document) + try persist(document) + } + + private var manifestURL: URL { + directoryURL.appendingPathComponent("manifest.json") + } + + private func loadDocument() throws -> Document { + if let cachedDocument { return cachedDocument } + guard fileManager.fileExists(atPath: manifestURL.path) else { + let document = Document(entries: [:]) + cachedDocument = document + return document + } + do { + let document = try JSONDecoder.t3.decode( + Document.self, + from: Data(contentsOf: manifestURL) + ) + guard document.version == 1 else { throw CocoaError(.fileReadCorruptFile) } + cachedDocument = document + return document + } catch { + // A disposable cache must recover without blocking the home screen. + let document = Document(entries: [:]) + cachedDocument = document + return document + } + } + + private func persist(_ document: Document) throws { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + try JSONEncoder.t3.encode(document).write(to: manifestURL, options: .atomic) + cachedDocument = document + } + + private func prune(_ document: inout Document) throws { + guard document.entries.count > Self.maximumEntryCount else { return } + let removed = document.entries + .sorted { $0.value.lastCheckedAt > $1.value.lastCheckedAt } + .dropFirst(Self.maximumEntryCount) + for (fingerprint, metadata) in removed { + document.entries.removeValue(forKey: fingerprint) + if let dataFileName = metadata.dataFileName { + try? fileManager.removeItem( + at: directoryURL.appendingPathComponent(dataFileName) + ) + } + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift new file mode 100644 index 000000000000..453add592756 --- /dev/null +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -0,0 +1,963 @@ +import Foundation + +public struct FeatureCapabilityUnavailable: LocalizedError, Sendable, Equatable { + public let capability: String + + public init(_ capability: String) { + self.capability = capability + } + + public var errorDescription: String? { + "\(capability) is not supported by this environment." + } +} + +/// Optional rich-file capability. The base file contract is deliberately text-only, +/// while native clients can resolve the existing signed workspace asset route for images. +@MainActor +public protocol FeatureWorkspaceAssetResolving: AnyObject { + func workspaceAssetURL(threadID: String, path: String) async throws -> URL + func mediaAssetURL(threadID: String, path: String) async throws -> URL +} + +public extension FeatureWorkspaceAssetResolving { + func mediaAssetURL(threadID: String, path: String) async throws -> URL { + try await workspaceAssetURL(threadID: threadID, path: path) + } +} + +@MainActor +public protocol FeatureFeedbackSubmitting: AnyObject { + func submitCodexFeedback(threadID: String, reason: String?) async throws -> String +} + +public enum FeatureFileKind: String, Sendable, Codable { + case file + case directory + case symbolicLink +} + +public struct FeatureFileEntry: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public let path: String + public var name: String + public var kind: FeatureFileKind + public var sizeBytes: Int? + public var isHidden: Bool + + public init( + path: String, + name: String, + kind: FeatureFileKind, + sizeBytes: Int? = nil, + isHidden: Bool = false + ) { + self.path = path + self.name = name + self.kind = kind + self.sizeBytes = sizeBytes + self.isHidden = isHidden + } +} + +public extension Array where Element == FeatureFileEntry { + func featureFiltered(by query: String, includesHidden: Bool) -> [FeatureFileEntry] { + let visible = includesHidden ? self : filter { !$0.isHidden } + let filtered: [FeatureFileEntry] + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + filtered = visible + } else { + filtered = visible.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + return filtered.sorted { + if $0.kind == .directory, $1.kind != .directory { return true } + if $0.kind != .directory, $1.kind == .directory { return false } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } +} + +public struct FeatureFileContent: Sendable, Equatable, Codable { + public var path: String + public var text: String + public var language: String? + public var isTruncated: Bool + public var totalBytes: Int? + + public init( + path: String, + text: String, + language: String? = nil, + isTruncated: Bool = false, + totalBytes: Int? = nil + ) { + self.path = path + self.text = text + self.language = language + self.isTruncated = isTruncated + self.totalBytes = totalBytes + } +} + +public enum FeatureFilePreviewKind: Sendable, Equatable { + case image + case pdf + case video + case document + case markdown + case source + case plainText + + public static func infer(path: String, language: String? = nil) -> Self { + let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + if imageExtensions.contains(fileExtension) { return .image } + if fileExtension == "pdf" { return .pdf } + if videoExtensions.contains(fileExtension) { return .video } + if documentExtensions.contains(fileExtension) { return .document } + if language?.lowercased() == "markdown" || ["md", "mdx"].contains(fileExtension) { + return .markdown + } + if language != nil || sourceExtensions.contains(fileExtension) { return .source } + return .plainText + } + + private static let imageExtensions: Set = [ + "avif", "gif", "ico", "jpeg", "jpg", "png", "webp", + ] + + private static let videoExtensions: Set = [ + "m4v", "mov", "mp4", "mpeg", "mpg", "webm", + ] + + private static let documentExtensions: Set = [ + "doc", "docx", "key", "numbers", "pages", "ppt", "pptx", "rtf", "xls", "xlsx", + ] + + private static let sourceExtensions: Set = [ + "c", "cc", "cpp", "cs", "css", "go", "h", "hpp", "html", "java", "js", "jsx", + "json", "kt", "kts", "m", "mm", "php", "py", "rb", "rs", "scss", "sh", "sql", + "swift", "toml", "ts", "tsx", "vue", "xml", "yaml", "yml", "zsh", + ] +} + +public enum FeatureSourceTokenKind: String, Sendable, Equatable, Hashable, Codable { + case plain + case comment + case keyword + case literal + case number + case property +} + +public struct FeatureSourceSpan: Sendable, Equatable, Hashable, Codable { + public var text: String + public var kind: FeatureSourceTokenKind + + public init(text: String, kind: FeatureSourceTokenKind) { + self.text = text + self.kind = kind + } +} + +public struct FeatureSourceLine: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: Int + public var spans: [FeatureSourceSpan] + + public init(id: Int, spans: [FeatureSourceSpan]) { + self.id = id + self.spans = spans + } + + public var number: Int { id + 1 } + public var text: String { spans.map(\.text).joined() } +} + +/// A bounded, language-aware lexer for file previews. It runs once when a file loads; +/// SwiftUI receives immutable line plans and performs no regex or token work while scrolling. +public enum FeatureSourceHighlighter { + public static func lines(text: String, language: String?) -> [FeatureSourceLine] { + let sourceLines = text.split(separator: "\n", omittingEmptySubsequences: false) + let highlightsContent = text.utf8.count <= 512 * 1_024 + var isInsideBlockComment = false + return sourceLines.enumerated().map { index, line in + guard highlightsContent, line.utf8.count <= 32 * 1_024 else { + return FeatureSourceLine( + id: index, + spans: line.isEmpty + ? [] + : [FeatureSourceSpan(text: String(line), kind: .plain)] + ) + } + return FeatureSourceLine( + id: index, + spans: spans( + in: String(line), + language: language?.lowercased(), + isInsideBlockComment: &isInsideBlockComment + ) + ) + } + } + + private static func spans( + in line: String, + language: String?, + isInsideBlockComment: inout Bool + ) -> [FeatureSourceSpan] { + let characters = Array(line) + var output: [FeatureSourceSpan] = [] + var index = 0 + let lineComment = lineCommentMarker(for: language) + let supportsBlockComments = blockCommentLanguages.contains(language ?? "") + let keywords = keywords(for: language) + + func hasPrefix(_ prefix: [Character], at offset: Int) -> Bool { + guard offset + prefix.count <= characters.count else { return false } + return characters[offset ..< offset + prefix.count].elementsEqual(prefix) + } + + func append(_ range: Range, kind: FeatureSourceTokenKind) { + guard !range.isEmpty else { return } + let text = String(characters[range]) + if output.last?.kind == kind { + output[output.count - 1].text += text + } else { + output.append(FeatureSourceSpan(text: text, kind: kind)) + } + } + + while index < characters.count { + if isInsideBlockComment { + let start = index + while index < characters.count, !hasPrefix(["*", "/"], at: index) { + index += 1 + } + if index < characters.count { + index += 2 + isInsideBlockComment = false + } + append(start ..< index, kind: .comment) + continue + } + + if let lineComment, hasPrefix(Array(lineComment), at: index) { + append(index ..< characters.count, kind: .comment) + break + } + + if supportsBlockComments, hasPrefix(["/", "*"], at: index) { + let start = index + index += 2 + while index < characters.count, !hasPrefix(["*", "/"], at: index) { + index += 1 + } + if index < characters.count { + index += 2 + } else { + isInsideBlockComment = true + } + append(start ..< index, kind: .comment) + continue + } + + if ["\"", "'", "`"].contains(characters[index]) { + let start = index + let delimiter = characters[index] + index += 1 + var isEscaped = false + while index < characters.count { + let character = characters[index] + index += 1 + if character == delimiter, !isEscaped { break } + isEscaped = character == "\\" && !isEscaped + if character != "\\" { isEscaped = false } + } + var next = index + while next < characters.count, characters[next].isWhitespace { next += 1 } + let kind: FeatureSourceTokenKind = next < characters.count + && characters[next] == ":" + && propertyLanguages.contains(language ?? "") + ? .property + : .literal + append(start ..< index, kind: kind) + continue + } + + if characters[index].isNumber { + let start = index + index += 1 + while index < characters.count, + characters[index].isNumber + || [".", "_", "x", "X", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"].contains(characters[index]) { + index += 1 + } + append(start ..< index, kind: .number) + continue + } + + if isIdentifierStart(characters[index]) { + let start = index + index += 1 + while index < characters.count, isIdentifierBody(characters[index]) { + index += 1 + } + let token = String(characters[start ..< index]) + var next = index + while next < characters.count, characters[next].isWhitespace { next += 1 } + let kind: FeatureSourceTokenKind + if ["true", "false", "null", "nil", "undefined"].contains(token) { + kind = .literal + } else if keywords.contains(token) { + kind = .keyword + } else if next < characters.count, + characters[next] == ":", + propertyLanguages.contains(language ?? "") { + kind = .property + } else { + kind = .plain + } + append(start ..< index, kind: kind) + continue + } + + append(index ..< index + 1, kind: .plain) + index += 1 + } + return output + } + + private static func lineCommentMarker(for language: String?) -> String? { + switch language { + case "plain": nil + case "python", "shell", "ruby", "yaml", "toml": "#" + case "sql": "--" + case "html", "xml", "css", "scss": nil + default: "//" + } + } + + private static func keywords(for language: String?) -> Set { + switch language { + case "plain": [] + case "swift": swiftKeywords + case "typescript", "javascript": javascriptKeywords + case "python": pythonKeywords + case "rust": rustKeywords + case "go": goKeywords + case "shell": shellKeywords + default: commonKeywords + } + } + + private static func isIdentifierStart(_ character: Character) -> Bool { + character == "_" || character == "$" || character.isLetter + } + + private static func isIdentifierBody(_ character: Character) -> Bool { + isIdentifierStart(character) || character.isNumber || character == "-" + } + + private static let propertyLanguages: Set = ["json", "typescript", "javascript", "yaml"] + private static let blockCommentLanguages: Set = [ + "css", "go", "java", "javascript", "rust", "scss", "swift", "typescript", + ] + private static let commonKeywords: Set = [ + "class", "const", "else", "enum", "false", "for", "func", "function", "if", "import", + "let", "nil", "null", "private", "public", "return", "struct", "true", "var", "while", + ] + private static let swiftKeywords = commonKeywords.union([ + "actor", "any", "associatedtype", "async", "await", "case", "defer", "extension", "guard", + "in", "init", "internal", "nonisolated", "opaque", "protocol", "self", "some", "switch", + "throws", "try", "typealias", "where", + ]) + private static let javascriptKeywords = commonKeywords.union([ + "as", "break", "case", "catch", "continue", "default", "export", "extends", "from", "interface", + "new", "of", "static", "throw", "type", "typeof", "undefined", + ]) + private static let pythonKeywords = commonKeywords.union([ + "and", "as", "assert", "async", "await", "def", "elif", "except", "finally", "from", "in", + "is", "lambda", "not", "or", "pass", "raise", "with", "yield", + ]) + private static let rustKeywords = commonKeywords.union([ + "as", "async", "await", "crate", "dyn", "impl", "in", "loop", "match", "mod", "move", "mut", + "ref", "self", "trait", "type", "unsafe", "use", "where", + ]) + private static let goKeywords = commonKeywords.union([ + "break", "case", "chan", "continue", "defer", "fallthrough", "go", "goto", "interface", "map", + "package", "range", "select", "type", + ]) + private static let shellKeywords = commonKeywords.union([ + "case", "do", "done", "elif", "esac", "export", "fi", "in", "then", + ]) +} + +public enum FeatureDiffLineKind: String, Sendable, Codable { + case context + case addition + case deletion + case hunk +} + +public struct FeatureDiffLine: Identifiable, Sendable, Equatable, Hashable, Codable { + public let id: String + public var kind: FeatureDiffLineKind + public var oldLine: Int? + public var newLine: Int? + public var text: String + public var spans: [FeatureDiffTextSpan]? + + public init( + id: String, + kind: FeatureDiffLineKind, + oldLine: Int? = nil, + newLine: Int? = nil, + text: String, + spans: [FeatureDiffTextSpan]? = nil + ) { + self.id = id + self.kind = kind + self.oldLine = oldLine + self.newLine = newLine + self.text = text + self.spans = spans + } +} + +public enum FeatureDiffTextSpanKind: String, Sendable, Equatable, Hashable, Codable { + case unchanged + case changed +} + +public struct FeatureDiffTextSpan: Sendable, Equatable, Hashable, Codable { + public var text: String + public var kind: FeatureDiffTextSpanKind + + public init(text: String, kind: FeatureDiffTextSpanKind) { + self.text = text + self.kind = kind + } +} + +public enum FeatureDiffWordHighlighter { + public static func spans( + old: String, + new: String + ) -> (old: [FeatureDiffTextSpan], new: [FeatureDiffTextSpan]) { + guard old != new else { + let unchanged = [FeatureDiffTextSpan(text: old, kind: .unchanged)] + return (unchanged, unchanged) + } + let oldTokens = tokens(in: old) + let newTokens = tokens(in: new) + guard !oldTokens.isEmpty, !newTokens.isEmpty, + oldTokens.count * newTokens.count <= 20_000 else { + return (changed(old), changed(new)) + } + + var lengths = Array( + repeating: Array(repeating: 0, count: newTokens.count + 1), + count: oldTokens.count + 1 + ) + for oldIndex in oldTokens.indices.reversed() { + for newIndex in newTokens.indices.reversed() { + lengths[oldIndex][newIndex] = oldTokens[oldIndex] == newTokens[newIndex] + ? lengths[oldIndex + 1][newIndex + 1] + 1 + : max(lengths[oldIndex + 1][newIndex], lengths[oldIndex][newIndex + 1]) + } + } + + var oldMatches = Array(repeating: false, count: oldTokens.count) + var newMatches = Array(repeating: false, count: newTokens.count) + var oldIndex = 0 + var newIndex = 0 + while oldIndex < oldTokens.count, newIndex < newTokens.count { + if oldTokens[oldIndex] == newTokens[newIndex] { + oldMatches[oldIndex] = true + newMatches[newIndex] = true + oldIndex += 1 + newIndex += 1 + } else if lengths[oldIndex + 1][newIndex] >= lengths[oldIndex][newIndex + 1] { + oldIndex += 1 + } else { + newIndex += 1 + } + } + + return ( + makeSpans(tokens: oldTokens, matches: oldMatches), + makeSpans(tokens: newTokens, matches: newMatches) + ) + } + + private static func tokens(in text: String) -> [String] { + var output: [String] = [] + var current = "" + var currentClass: TokenClass? + for character in text { + let tokenClass: TokenClass = if character.isWhitespace { + .whitespace + } else if character.isLetter || character.isNumber || character == "_" || character == "$" { + .word + } else { + .punctuation + } + if tokenClass == .punctuation { + if !current.isEmpty { output.append(current) } + output.append(String(character)) + current = "" + currentClass = nil + } else if currentClass == tokenClass { + current.append(character) + } else { + if !current.isEmpty { output.append(current) } + current = String(character) + currentClass = tokenClass + } + } + if !current.isEmpty { output.append(current) } + return output + } + + private static func makeSpans( + tokens: [String], + matches: [Bool] + ) -> [FeatureDiffTextSpan] { + var output: [FeatureDiffTextSpan] = [] + for (index, token) in tokens.enumerated() { + let isWhitespace = token.allSatisfy(\.isWhitespace) + let kind: FeatureDiffTextSpanKind = matches[index] || isWhitespace + ? .unchanged + : .changed + if output.last?.kind == kind { + output[output.count - 1].text += token + } else { + output.append(FeatureDiffTextSpan(text: token, kind: kind)) + } + } + return output + } + + private static func changed(_ text: String) -> [FeatureDiffTextSpan] { + [FeatureDiffTextSpan(text: text, kind: .changed)] + } + + private enum TokenClass { + case whitespace + case word + case punctuation + } +} + +public enum FeatureReviewLineSide: String, Sendable, Equatable, Hashable, Codable { + case old + case new +} + +public struct FeatureReviewLineSelection: Sendable, Equatable, Hashable, Codable { + public var side: FeatureReviewLineSide + public var line: Int + + public init(side: FeatureReviewLineSide, line: Int) { + self.side = side + self.line = line + } +} + +public struct FeatureReviewCommentDraft: Sendable, Equatable, Hashable { + public var filePath: String + public var line: FeatureReviewLineSelection? + public var body: String + + public init(filePath: String, line: FeatureReviewLineSelection? = nil, body: String) { + self.filePath = filePath + self.line = line + self.body = body + } + + public var prompt: String { + let location = line.map { " at \($0.side.rawValue) line \($0.line)" } ?? "" + return """ + Address this review comment in `\(filePath)`\(location): + + \(body.trimmingCharacters(in: .whitespacesAndNewlines)) + + Inspect the surrounding code, make the smallest correct change, and report what changed. + """ + } +} + +public enum FeatureReviewChangeKind: String, Sendable, Codable { + case added + case modified + case deleted + case renamed + case binary +} + +public struct FeatureReviewFile: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public var path: String + public var previousPath: String? + public var change: FeatureReviewChangeKind + public var additions: Int + public var deletions: Int + public var lines: [FeatureDiffLine] + public var sourceKind: String? + public var sourceBaseReference: String? + public var sourceHeadReference: String? + + public init( + path: String, + previousPath: String? = nil, + change: FeatureReviewChangeKind, + additions: Int, + deletions: Int, + lines: [FeatureDiffLine] = [], + sourceKind: String? = nil, + sourceBaseReference: String? = nil, + sourceHeadReference: String? = nil + ) { + self.path = path + self.previousPath = previousPath + self.change = change + self.additions = additions + self.deletions = deletions + self.lines = lines + self.sourceKind = sourceKind + self.sourceBaseReference = sourceBaseReference + self.sourceHeadReference = sourceHeadReference + } +} + +public struct FeatureReviewFileContents: Sendable, Equatable { + public var oldContents: String + public var newContents: String + + public init(oldContents: String, newContents: String) { + self.oldContents = oldContents + self.newContents = newContents + } +} + +enum FeatureFullDiffHydrator { + static func lines( + for file: FeatureReviewFile, + contents: FeatureReviewFileContents + ) -> [FeatureDiffLine] { + let oldLines = contentLines(contents.oldContents) + let newLines = contentLines(contents.newContents) + + switch file.change { + case .added: + return wholeFileLines(newLines, kind: .addition, side: .new, path: file.path) + case .deleted: + return wholeFileLines(oldLines, kind: .deletion, side: .old, path: file.path) + case .renamed where file.additions == 0 && file.deletions == 0: + return newLines.enumerated().map { index, text in + FeatureDiffLine( + id: "full-\(file.path)-\(index + 1)", + kind: .context, + oldLine: index + 1, + newLine: index + 1, + text: text + ) + } + case .modified, .renamed, .binary: + break + } + + let patchLines = file.lines.filter { $0.kind != .hunk } + guard !newLines.isEmpty else { return file.lines } + guard !patchLines.isEmpty else { + return newLines.enumerated().map { index, text in + FeatureDiffLine( + id: "full-\(file.path)-\(index + 1)", + kind: .context, + oldLine: oldLines.indices.contains(index) ? index + 1 : nil, + newLine: index + 1, + text: text + ) + } + } + + let anchors = patchLines.compactMap { line -> (new: Int, offset: Int)? in + guard let oldLine = line.oldLine, let newLine = line.newLine else { return nil } + return (newLine, oldLine - newLine) + } + // Anchors ascend by new-line number; binary search keeps hydration + // linear instead of scanning every anchor per emitted context line. + func oldLine(for newLine: Int) -> Int? { + guard !anchors.isEmpty else { return nil } + var low = 0 + var high = anchors.count + while low < high { + let mid = (low + high) / 2 + if anchors[mid].new <= newLine { + low = mid + 1 + } else { + high = mid + } + } + let anchor = low > 0 ? anchors[low - 1] : anchors[0] + return newLine + anchor.offset + } + + var output: [FeatureDiffLine] = [] + var nextNewLine = 1 + var precedingAnchorOffset: Int? + for (index, line) in patchLines.enumerated() { + if let newLine = line.newLine { + if nextNewLine < newLine { + for number in nextNewLine ..< newLine where newLines.indices.contains(number - 1) { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + } + output.append(line) + nextNewLine = max(nextNewLine, newLine + 1) + if let oldLine = line.oldLine { + precedingAnchorOffset = oldLine - newLine + } + } else { + let insertionLine: Int + if let oldLine = line.oldLine, let precedingAnchorOffset { + insertionLine = oldLine - precedingAnchorOffset + } else { + insertionLine = patchLines.dropFirst(index + 1).compactMap(\.newLine).first + ?? (newLines.count + 1) + } + if nextNewLine < insertionLine { + for number in nextNewLine ..< insertionLine + where newLines.indices.contains(number - 1) { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + nextNewLine = insertionLine + } + output.append(line) + } + } + if nextNewLine <= newLines.count { + for number in nextNewLine ... newLines.count { + output.append( + FeatureDiffLine( + id: "full-\(file.path)-\(number)", + kind: .context, + oldLine: oldLine(for: number), + newLine: number, + text: newLines[number - 1] + ) + ) + } + } + return output + } + + private enum Side: Equatable { case old, new } + + private static func wholeFileLines( + _ lines: [String], + kind: FeatureDiffLineKind, + side: Side, + path: String + ) -> [FeatureDiffLine] { + lines.enumerated().map { index, text in + let number = index + 1 + return FeatureDiffLine( + id: "full-\(path)-\(number)", + kind: kind, + oldLine: side == .old ? number : nil, + newLine: side == .new ? number : nil, + text: text + ) + } + } + + private static func contentLines(_ contents: String) -> [String] { + guard !contents.isEmpty else { return [] } + var lines = contents.components(separatedBy: "\n") + if lines.last?.isEmpty == true { lines.removeLast() } + return lines + } +} + +public struct FeatureReview: Sendable, Equatable, Codable { + public var title: String + public var baseReference: String? + public var files: [FeatureReviewFile] + public var isTruncated: Bool + + public init( + title: String = "Working tree", + baseReference: String? = nil, + files: [FeatureReviewFile] = [], + isTruncated: Bool = false + ) { + self.title = title + self.baseReference = baseReference + self.files = files + self.isTruncated = isTruncated + } + + public var additions: Int { files.reduce(0) { $0 + $1.additions } } + public var deletions: Int { files.reduce(0) { $0 + $1.deletions } } +} + +public enum FeatureSourceControlFileState: String, Sendable, Codable { + case added + case modified + case deleted + case renamed + case untracked + case conflicted +} + +public struct FeatureSourceControlFile: Identifiable, Sendable, Equatable, Hashable, Codable { + public var id: String { path } + public var path: String + public var state: FeatureSourceControlFileState + public var isStaged: Bool + + public init(path: String, state: FeatureSourceControlFileState, isStaged: Bool) { + self.path = path + self.state = state + self.isStaged = isStaged + } +} + +public struct FeaturePullRequest: Sendable, Equatable, Hashable, Codable { + public var number: Int + public var title: String + public var state: String + public var url: URL? + public var updatedAt: String? + + public init( + number: Int, + title: String, + state: String, + url: URL? = nil, + updatedAt: String? = nil + ) { + self.number = number + self.title = title + self.state = state + self.url = url + self.updatedAt = updatedAt + } +} + +public enum FeatureSourceControlAction: String, CaseIterable, Sendable, Codable { + case commit + case push + case pull + case createPullRequest + case commitAndPush + case commitPushAndCreatePullRequest +} + +public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { + public var isRepository: Bool + public var branch: String? + public var upstream: String? + public var aheadCount: Int + public var behindCount: Int + public var files: [FeatureSourceControlFile] + public var pullRequest: FeaturePullRequest? + public var isBusy: Bool + + public init( + isRepository: Bool = true, + branch: String? = nil, + upstream: String? = nil, + aheadCount: Int = 0, + behindCount: Int = 0, + files: [FeatureSourceControlFile] = [], + pullRequest: FeaturePullRequest? = nil, + isBusy: Bool = false + ) { + self.isRepository = isRepository + self.branch = branch + self.upstream = upstream + self.aheadCount = aheadCount + self.behindCount = behindCount + self.files = files + self.pullRequest = pullRequest + self.isBusy = isBusy + } + + public var availableActions: [FeatureSourceControlAction] { + guard isRepository, !isBusy else { return [] } + var actions: [FeatureSourceControlAction] = [] + if !files.isEmpty { + actions.append(.commit) + actions.append(.commitAndPush) + if pullRequest == nil { + actions.append(.commitPushAndCreatePullRequest) + } + } + if aheadCount > 0 { actions.append(.push) } + if behindCount > 0 { actions.append(.pull) } + if pullRequest == nil { actions.append(.createPullRequest) } + return actions + } +} + +public enum FeatureTerminalState: String, Sendable, Codable { + case stopped + case starting + case running + case exited + case failed +} + +public struct FeatureTerminalSnapshot: Sendable, Equatable, Codable { + public var threadID: String + public var terminalID: String + public var state: FeatureTerminalState + public var title: String + public var workingDirectory: String? + public var buffer: String + public var exitCode: Int? + public var error: String? + public var hasRunningSubprocess: Bool + public var updatedAt: String? + + public init( + threadID: String, + terminalID: String = "default", + state: FeatureTerminalState = .stopped, + title: String = "Terminal", + workingDirectory: String? = nil, + buffer: String = "", + exitCode: Int? = nil, + error: String? = nil, + hasRunningSubprocess: Bool = false, + updatedAt: String? = nil + ) { + self.threadID = threadID + self.terminalID = terminalID + self.state = state + self.title = title + self.workingDirectory = workingDirectory + self.buffer = buffer + self.exitCode = exitCode + self.error = error + self.hasRunningSubprocess = hasRunningSubprocess + self.updatedAt = updatedAt + } +} diff --git a/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift b/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift new file mode 100644 index 000000000000..4e530c8c1005 --- /dev/null +++ b/apps/swift-ios/Features/Shared/ManagedAttachmentFileStore.swift @@ -0,0 +1,171 @@ +import Foundation + +public struct FeatureUploadedAttachmentReference: Sendable, Equatable, Codable { + public var environmentID: String + public var attachmentID: String + + public init(environmentID: String, attachmentID: String) { + self.environmentID = environmentID + self.attachmentID = attachmentID + } +} + +public struct FeatureOwnedAttachmentFile: Sendable, Equatable { + public let fileName: String + public let url: URL + public let byteCount: Int + + public init(fileName: String, url: URL, byteCount: Int) { + self.fileName = fileName + self.url = url + self.byteCount = byteCount + } +} + +public enum ManagedAttachmentFileError: LocalizedError, Equatable, Sendable { + case invalidSource + case invalidFileName + case empty + case tooLarge(actualBytes: Int, maximumBytes: Int) + case alreadyExists + + public var errorDescription: String? { + switch self { + case .invalidSource: + "The selected attachment is not a local file." + case .invalidFileName: + "The attachment file name is invalid." + case .empty: + "The selected attachment is empty." + case let .tooLarge(actualBytes, maximumBytes): + "The attachment is \(actualBytes) bytes. T3 accepts up to \(maximumBytes) bytes." + case .alreadyExists: + "An owned attachment already exists for this ID." + } + } +} + +/// Owns copies of provider files so drafts and outbox entries do not depend on +/// temporary document-picker URLs. It never removes the provider-owned source. +public struct ManagedAttachmentFileStore: Sendable { + public static let maximumBytes = 50 * 1024 * 1024 + private static let chunkBytes = 256 * 1024 + + public let rootURL: URL + + public init(rootURL: URL? = nil) { + if let rootURL { + self.rootURL = rootURL.standardizedFileURL + } else { + let applicationSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + self.rootURL = applicationSupport + .appendingPathComponent("T3CodeSwift", isDirectory: true) + .appendingPathComponent("attachments", isDirectory: true) + .standardizedFileURL + } + } + + public func copyOwnedFile( + from sourceURL: URL, + attachmentID: UUID, + originalFileName: String, + maximumBytes: Int = Self.maximumBytes + ) throws -> FeatureOwnedAttachmentFile { + guard sourceURL.isFileURL else { throw ManagedAttachmentFileError.invalidSource } + let effectiveMaximumBytes = min(Self.maximumBytes, max(0, maximumBytes)) + let fileName = try Self.ownedFileName( + attachmentID: attachmentID, + originalFileName: originalFileName + ) + let destination = try resolvedFile(fileName: fileName, byteCount: 0).url + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw ManagedAttachmentFileError.alreadyExists + } + + let hasSecurityAccess = sourceURL.startAccessingSecurityScopedResource() + defer { + if hasSecurityAccess { sourceURL.stopAccessingSecurityScopedResource() } + } + + let source = try FileHandle(forReadingFrom: sourceURL) + defer { try? source.close() } + guard FileManager.default.createFile(atPath: destination.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + let output = try FileHandle(forWritingTo: destination) + var copiedBytes = 0 + var completed = false + defer { + try? output.close() + if !completed { try? FileManager.default.removeItem(at: destination) } + } + + while let chunk = try source.read(upToCount: Self.chunkBytes), !chunk.isEmpty { + copiedBytes += chunk.count + guard copiedBytes <= effectiveMaximumBytes else { + throw ManagedAttachmentFileError.tooLarge( + actualBytes: copiedBytes, + maximumBytes: effectiveMaximumBytes + ) + } + try output.write(contentsOf: chunk) + } + guard copiedBytes > 0 else { throw ManagedAttachmentFileError.empty } + completed = true + return FeatureOwnedAttachmentFile( + fileName: fileName, + url: destination, + byteCount: copiedBytes + ) + } + + public func resolvedFile(fileName: String, byteCount: Int) throws + -> FeatureOwnedAttachmentFile + { + guard Self.isValidOwnedFileName(fileName) else { + throw ManagedAttachmentFileError.invalidFileName + } + let url = rootURL.appendingPathComponent(fileName, isDirectory: false).standardizedFileURL + guard url.deletingLastPathComponent() == rootURL else { + throw ManagedAttachmentFileError.invalidFileName + } + return FeatureOwnedAttachmentFile(fileName: fileName, url: url, byteCount: byteCount) + } + + /// Removes one known owned file. Callers must coordinate ownership before + /// they use this helper because drafts and outbox entries can share a file. + public func removeOwnedFile(fileName: String) throws { + let file = try resolvedFile(fileName: fileName, byteCount: 0) + guard FileManager.default.fileExists(atPath: file.url.path) else { return } + try FileManager.default.removeItem(at: file.url) + } + + private static func ownedFileName( + attachmentID: UUID, + originalFileName: String + ) throws -> String { + let pathExtension = URL(fileURLWithPath: originalFileName).pathExtension.lowercased() + let safeExtension = pathExtension.filter { + $0.isASCII && ($0.isLetter || $0.isNumber) + } + guard safeExtension.count <= 16 else { throw ManagedAttachmentFileError.invalidFileName } + return safeExtension.isEmpty + ? attachmentID.uuidString + : "\(attachmentID.uuidString).\(safeExtension)" + } + + private static func isValidOwnedFileName(_ fileName: String) -> Bool { + guard fileName == URL(fileURLWithPath: fileName).lastPathComponent else { return false } + let url = URL(fileURLWithPath: fileName) + guard UUID(uuidString: url.deletingPathExtension().lastPathComponent) != nil else { + return false + } + let pathExtension = url.pathExtension + return pathExtension.count <= 16 + && pathExtension.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber) } + } +} diff --git a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift new file mode 100644 index 000000000000..1182485c2b0a --- /dev/null +++ b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift @@ -0,0 +1,240 @@ +import SwiftUI + +public struct FeatureSourceControlView: View { + let client: any FeatureClient + let threadID: String + + @State private var status: FeatureSourceControlStatus? + @State private var isLoading = true + @State private var isRunningAction = false + @State private var errorMessage: String? + @State private var commitMessage = "" + @State private var pendingCommitAction: FeatureSourceControlAction? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + Group { + if isLoading, status == nil { + ProgressView("Loading repository…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let status, status.isRepository { + statusList(status) + } else { + ContentUnavailableView( + "Source control unavailable", + systemImage: "arrow.triangle.branch", + description: Text( + errorMessage + ?? (status?.isRepository == false + ? "This workspace is not a Git repository." + : "Repository status could not be loaded.") + ) + ) + } + } + .background(T3Colors.background) + .navigationTitle("Source Control") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } + .disabled(isLoading || isRunningAction) + .accessibilityLabel("Reload source control") + } + } + .alert("Commit changes", isPresented: Binding( + get: { pendingCommitAction != nil }, + set: { if !$0 { pendingCommitAction = nil } } + )) { + TextField("Commit message", text: $commitMessage) + Button("Cancel", role: .cancel) { pendingCommitAction = nil } + Button("Commit") { + if let action = pendingCommitAction { + Task { await perform(action, message: commitMessage) } + } + pendingCommitAction = nil + } + .disabled(commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .alert("Source control failed", isPresented: Binding( + get: { status != nil && errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "The source control action could not be completed.") + } + .task { await load() } + } + + private func statusList(_ status: FeatureSourceControlStatus) -> some View { + List { + Section("Repository") { + LabeledContent("Branch", value: status.branch ?? "Detached HEAD") + if let upstream = status.upstream { + LabeledContent("Upstream", value: upstream) + } + HStack { + Label("\(status.aheadCount) ahead", systemImage: "arrow.up") + Spacer() + Label("\(status.behindCount) behind", systemImage: "arrow.down") + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + if let pullRequest = status.pullRequest { + if let url = pullRequest.url { + Link(destination: url) { + Label("PR #\(pullRequest.number) · \(pullRequest.title)", systemImage: "arrow.up.right.square") + } + } else { + LabeledContent("Pull Request", value: "#\(pullRequest.number) · \(pullRequest.state)") + } + } + } + + Section("Actions") { + if status.availableActions.isEmpty { + Text(status.isBusy ? "Source control operation in progress" : "No actions available") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(status.availableActions, id: \.self) { action in + Button { + begin(action) + } label: { + Label(action.title, systemImage: action.icon) + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(isRunningAction) + } + } + + Section("\(status.files.count) changed \(status.files.count == 1 ? "file" : "files")") { + if status.files.isEmpty { + Label("Working tree clean", systemImage: "checkmark.circle") + .foregroundStyle(T3Colors.textSecondary) + } + ForEach(status.files) { file in + HStack(spacing: 10) { + Text(file.state.shortLabel) + .font(.caption2.monospaced().weight(.bold)) + .foregroundStyle(file.state.color) + .frame(width: 18) + Text(file.path) + .font(T3Typography.threadBody) + .lineLimit(1) + Spacer() + if file.isStaged { + Text("STAGED") + .font(T3Typography.eyebrow) + .foregroundStyle(.green) + } + } + .accessibilityElement(children: .combine) + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .refreshable { await load() } + .overlay { + if isRunningAction { + ProgressView() + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func begin(_ action: FeatureSourceControlAction) { + if action.requiresMessage { + commitMessage = "" + pendingCommitAction = action + } else { + Task { await perform(action, message: nil) } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + status = try await client.sourceControlStatus(threadID: threadID) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func perform(_ action: FeatureSourceControlAction, message: String?) async { + isRunningAction = true + defer { isRunningAction = false } + do { + status = try await client.performSourceControlAction( + threadID: threadID, + action: action, + message: message?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +private extension FeatureSourceControlAction { + var requiresMessage: Bool { + switch self { + case .commit, .commitAndPush, .commitPushAndCreatePullRequest: true + case .push, .pull, .createPullRequest: false + } + } + + var title: String { + switch self { + case .commit: "Commit changes" + case .push: "Push" + case .pull: "Pull latest" + case .createPullRequest: "Create pull request" + case .commitAndPush: "Commit and push" + case .commitPushAndCreatePullRequest: "Commit, push, and create PR" + } + } + + var icon: String { + switch self { + case .commit: "checkmark.circle" + case .push: "arrow.up.circle" + case .pull: "arrow.down.circle" + case .createPullRequest: "arrow.triangle.pull" + case .commitAndPush: "arrow.up.circle.fill" + case .commitPushAndCreatePullRequest: "point.3.connected.trianglepath.dotted" + } + } +} + +private extension FeatureSourceControlFileState { + var shortLabel: String { + switch self { + case .added: "A" + case .modified: "M" + case .deleted: "D" + case .renamed: "R" + case .untracked: "?" + case .conflicted: "!" + } + } + + var color: Color { + switch self { + case .added: .green + case .modified: .orange + case .deleted, .conflicted: .red + case .renamed: .blue + case .untracked: .secondary + } + } +} diff --git a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift new file mode 100644 index 000000000000..21d503bd6436 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -0,0 +1,505 @@ +import SwiftUI + +private enum TerminalFontSize { + static let minimum = 6.0 + static let maximum = 32.0 + static let step = 0.5 + static let defaultValue = 10.5 + + static func normalized(_ value: Double) -> Double { + min(maximum, max(minimum, value)) + } +} + +enum TerminalSessionList { + static func initialID(in sessions: [FeatureTerminalSnapshot]) -> String { + let running = sessions.filter { $0.state == .running || $0.state == .starting } + return running.first(where: { $0.terminalID == "default" })?.terminalID + ?? running.first?.terminalID + ?? "default" + } + + static func nextID(occupiedIDs: [String]) -> String { + let occupied = Set(occupiedIDs) + guard occupied.contains("default") else { return "default" } + var index = 2 + while occupied.contains("term-\(index)") { index += 1 } + return "term-\(index)" + } + + static func fallbackID( + in sessions: [FeatureTerminalSnapshot], + excluding terminalID: String + ) -> String? { + sessions.first { + $0.terminalID != terminalID && ($0.state == .running || $0.state == .starting) + }?.terminalID + } + + static func displayTitle(for session: FeatureTerminalSnapshot) -> String { + let number: Int + if session.terminalID == "default" { + number = 1 + } else if session.terminalID.hasPrefix("term-"), + let parsed = Int(session.terminalID.dropFirst("term-".count)) { + number = parsed + } else { + return session.title + } + + let shell = session.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !shell.isEmpty, shell.caseInsensitiveCompare("Terminal") != .orderedSame else { + return "Terminal \(number)" + } + return "Terminal \(number) · \(shell)" + } +} + +public struct FeatureTerminalView: View { + let client: any FeatureClient + let threadID: String + + @SwiftUI.Environment(\.dismiss) private var dismiss + @AppStorage("terminalFontSize") private var storedFontSize = TerminalFontSize.defaultValue + @State private var terminal: FeatureTerminalSnapshot? + @State private var sessions = [FeatureTerminalSnapshot]() + @State private var activeTerminalID = "default" + @State private var sessionsResolved = false + @State private var columns = 80 + @State private var rows = 24 + @State private var focusRequest = 0 + @State private var surfaceGeneration = 0 + @State private var isLoading = true + @State private var isOpening = false + @State private var errorMessage: String? + + public init(client: any FeatureClient, threadID: String) { + self.client = client + self.threadID = threadID + } + + public var body: some View { + ZStack { + T3Colors.background + + GhosttyTerminalSurface( + terminalKey: "\(threadID):\(activeTerminalID)", + buffer: terminal?.buffer ?? "", + fontSize: CGFloat(fontSize), + isRunning: isRunning, + focusRequest: focusRequest, + onInput: { data in + let terminalID = activeTerminalID + Task { await write(data, terminalID: terminalID) } + }, + onResize: { nextColumns, nextRows in + updateGrid(columns: nextColumns, rows: nextRows) + }, + onClear: { + let terminalID = activeTerminalID + Task { await clear(terminalID: terminalID) } + }, + onFontSizeStep: { direction in + stepFontSize(direction) + } + ) + .id("\(terminalTaskID):\(fontSize):\(surfaceGeneration)") + .padding(.top, 48) + + if isLoading, terminal == nil { + ProgressView("Opening terminal…") + .tint(T3Colors.textPrimary) + .foregroundStyle(T3Colors.textPrimary) + } else if let errorMessage, terminal == nil { + ContentUnavailableView( + "Terminal unavailable", + systemImage: "terminal", + description: Text(errorMessage) + ) + .foregroundStyle(T3Colors.textPrimary) + } + + if let errorMessage, terminal != nil { + VStack { + Spacer() + Text(errorMessage) + .font(T3Typography.supporting) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.red.opacity(0.88)) + .accessibilityLabel("Terminal error: \(errorMessage)") + } + } + + terminalHeader + } + .background(T3Colors.background.ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .task { + for await updates in client.terminalSessions(threadID: threadID) { + sessions = updates + if !sessionsResolved { + activeTerminalID = TerminalSessionList.initialID(in: updates) + sessionsResolved = true + } + } + sessionsResolved = true + } + .task(id: terminalTaskID) { + guard sessionsResolved else { return } + await loadAndOpen() + } + .task(id: terminalTaskID) { + guard sessionsResolved else { return } + let terminalID = activeTerminalID + for await update in client.terminalEvents( + threadID: threadID, + terminalID: terminalID + ) { + guard terminalID == activeTerminalID else { break } + let shouldSyncGrid = !isRunning + && (update.state == .running || update.state == .starting) + if let currentBuffer = terminal?.buffer, + !update.buffer.hasPrefix(currentBuffer) { + surfaceGeneration += 1 + } + terminal = update + if shouldSyncGrid { + try? await client.resizeTerminal( + threadID: threadID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + } + if update.state == .running { + errorMessage = nil + } else if update.state == .failed, let error = update.error { + errorMessage = error + } + } + } + } + + private var terminalHeader: some View { + VStack(spacing: 0) { + ZStack { + Text("Terminal") + .font(T3Typography.navigationTitle) + .foregroundStyle(T3Colors.textPrimary) + + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .frame(width: 44, height: 44) + } + .foregroundStyle(T3Colors.textPrimary) + .accessibilityLabel("Close terminal") + + Spacer() + + terminalMenu + .frame(width: 44, height: 44) + } + } + .frame(height: 48) + .background(T3Colors.background) + + Spacer(minLength: 0) + } + } + + private var terminalMenu: some View { + Menu { + Section { + Label(statusLabel, systemImage: statusSymbol) + if let workingDirectory = terminal?.workingDirectory { + Text(workingDirectory) + } + } + + Section("Sessions") { + ForEach(menuSessions, id: \.terminalID) { session in + Button { + selectTerminal(session.terminalID) + } label: { + Label( + TerminalSessionList.displayTitle(for: session), + systemImage: session.terminalID == activeTerminalID + ? "checkmark" + : "terminal" + ) + } + } + + Button { + openNewTerminal() + } label: { + Label("Open new terminal", systemImage: "plus") + } + } + + Section { + Menu { + Button { + stepFontSize(-1) + } label: { + Label( + "Smaller · \(formattedFontSize(fontSize - TerminalFontSize.step)) pt", + systemImage: "textformat.size.smaller" + ) + } + .disabled(fontSize <= TerminalFontSize.minimum) + + Button { + stepFontSize(1) + } label: { + Label( + "Larger · \(formattedFontSize(fontSize + TerminalFontSize.step)) pt", + systemImage: "textformat.size.larger" + ) + } + .disabled(fontSize >= TerminalFontSize.maximum) + } label: { + Label("Text size · \(formattedFontSize(fontSize)) pt", systemImage: "textformat.size") + } + + Button { + let terminalID = activeTerminalID + Task { await clear(terminalID: terminalID) } + } label: { + Label("Clear", systemImage: "eraser") + } + .disabled(terminal == nil) + } + + Section { + if isRunning { + Button(role: .destructive) { + Task { await stop() } + } label: { + Label("Stop terminal", systemImage: "stop.fill") + } + } else { + Button { + Task { await open() } + } label: { + Label("Start terminal", systemImage: "play.fill") + } + .disabled(isLoading || isOpening) + } + } + } label: { + Image(systemName: "terminal") + } + .accessibilityLabel("Terminal options") + } + + private var fontSize: Double { + TerminalFontSize.normalized(storedFontSize) + } + + private var terminalTaskID: String { + "\(sessionsResolved):\(activeTerminalID)" + } + + private var menuSessions: [FeatureTerminalSnapshot] { + var visible = sessions.filter { + $0.state == .running || $0.state == .starting || $0.terminalID == activeTerminalID + } + if let terminal, + !visible.contains(where: { $0.terminalID == terminal.terminalID }) { + visible.append(terminal) + } + return visible.sorted { + $0.terminalID.localizedStandardCompare($1.terminalID) == .orderedAscending + } + } + + private var isRunning: Bool { + terminal?.state == .running || terminal?.state == .starting + } + + private var statusLabel: String { + switch terminal?.state { + case .running: terminal?.hasRunningSubprocess == true ? "Task running" : "Ready" + case .starting: "Starting" + case .failed: "Error" + case .exited: "Exited" + case .stopped, nil: "Not started" + } + } + + private var statusSymbol: String { + switch terminal?.state { + case .running: "checkmark.circle.fill" + case .starting: "clock.fill" + case .failed: "exclamationmark.triangle.fill" + case .exited: "xmark.circle.fill" + case .stopped, nil: "circle" + } + } + + private func formattedFontSize(_ value: Double) -> String { + String(format: "%.1f", TerminalFontSize.normalized(value)) + } + + private func stepFontSize(_ direction: Int) { + storedFontSize = TerminalFontSize.normalized( + fontSize + Double(direction) * TerminalFontSize.step + ) + } + + private func selectTerminal(_ terminalID: String) { + guard terminalID != activeTerminalID else { return } + terminal = nil + errorMessage = nil + activeTerminalID = terminalID + } + + private func openNewTerminal() { + let nextID = TerminalSessionList.nextID( + occupiedIDs: sessions.map(\.terminalID) + [activeTerminalID] + ) + terminal = nil + errorMessage = nil + activeTerminalID = nextID + } + + private func updateGrid(columns nextColumns: Int, rows nextRows: Int) { + guard nextColumns != columns || nextRows != rows else { return } + columns = nextColumns + rows = nextRows + guard isRunning else { return } + let terminalID = activeTerminalID + Task { + do { + try await client.resizeTerminal( + threadID: threadID, + terminalID: terminalID, + columns: nextColumns, + rows: nextRows + ) + } catch { + if terminalID == activeTerminalID { + errorMessage = error.localizedDescription + } + } + } + } + + private func loadAndOpen() async { + let terminalID = activeTerminalID + isLoading = true + defer { isLoading = false } + do { + let snapshot = try await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + guard terminalID == activeTerminalID else { return } + terminal = snapshot + if snapshot.state == .stopped || snapshot.state == .exited { + try await openTerminal(terminalID: terminalID) + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func open() async { + guard !isOpening else { return } + isOpening = true + defer { isOpening = false } + do { + try await openTerminal(terminalID: activeTerminalID) + focusRequest += 1 + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func openTerminal(terminalID: String) async throws { + try await client.openTerminal( + threadID: threadID, + terminalID: terminalID, + columns: columns, + rows: rows + ) + guard terminalID == activeTerminalID else { return } + terminal = try await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + } + + private func stop() async { + let terminalID = activeTerminalID + let fallbackID = TerminalSessionList.fallbackID( + in: sessions, + excluding: terminalID + ) + do { + try await client.closeTerminal(threadID: threadID, terminalID: terminalID) + if let fallbackID { + selectTerminal(fallbackID) + } else { + terminal = try? await client.terminalSnapshot( + threadID: threadID, + terminalID: terminalID + ) + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + private func clear(terminalID: String) async { + let wasRunning = isRunning + do { + if terminalID == activeTerminalID { + terminal?.buffer = "" + surfaceGeneration += 1 + } + try await client.clearTerminal( + threadID: threadID, + terminalID: terminalID + ) + if wasRunning { + try await client.writeTerminal( + threadID: threadID, + terminalID: terminalID, + data: "\u{0C}" + ) + } + if terminalID == activeTerminalID { + errorMessage = nil + } + } catch { + if terminalID == activeTerminalID { + errorMessage = error.localizedDescription + } + } + } + + private func write(_ data: String, terminalID: String) async { + do { + try await client.writeTerminal( + threadID: threadID, + terminalID: terminalID, + data: data + ) + } catch { + if terminalID == activeTerminalID { + errorMessage = error.localizedDescription + } + } + } +} diff --git a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift new file mode 100644 index 000000000000..82b428d83af7 --- /dev/null +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -0,0 +1,1092 @@ +import GhosttyKit +import QuartzCore +import SwiftUI +import UIKit + +struct GhosttyTerminalSurface: UIViewRepresentable { + @SwiftUI.Environment(\.colorScheme) private var colorScheme + let terminalKey: String + let buffer: String + let fontSize: CGFloat + let isRunning: Bool + let focusRequest: Int + let onInput: (String) -> Void + let onResize: (Int, Int) -> Void + let onClear: () -> Void + let onFontSizeStep: (Int) -> Void + + func makeUIView(context _: Context) -> GhosttyTerminalView { + let view = GhosttyTerminalView() + configure(view) + return view + } + + func updateUIView(_ view: GhosttyTerminalView, context _: Context) { + configure(view) + } + + static func dismantleUIView(_ view: GhosttyTerminalView, coordinator _: ()) { + view.tearDown() + } + + private func configure(_ view: GhosttyTerminalView) { + view.isDarkMode = colorScheme == .dark + view.onInput = onInput + view.onResize = onResize + view.onClear = onClear + view.onFontSizeStep = onFontSizeStep + view.terminalKey = terminalKey + view.fontSize = fontSize + view.isRunning = isRunning + view.buffer = buffer + view.focusRequest = focusRequest + } +} + +enum TerminalText { + static func plainText(from value: String) -> String { + let withoutEscapes = value + .replacingOccurrences( + of: "\u{1B}\\][^\u{7}\u{1B}]*(?:\u{7}|\u{1B}\\\\)", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}\\[[0-?]*[ -/]*[@-~]", + with: "", + options: .regularExpression + ) + .replacingOccurrences( + of: "\u{1B}[@-_]", + with: "", + options: .regularExpression + ) + + return withoutEscapes.reduce(into: "") { output, character in + if character == "\u{8}" || character == "\u{7F}" { + if !output.isEmpty { output.removeLast() } + return + } + if character == "\r" { return } + if character.unicodeScalars.count == 1, + let scalar = character.unicodeScalars.first, + scalar.value < 32, + character != "\n", + character != "\t" { + return + } + output.append(character) + } + } +} + +private enum GhosttyRuntime { + private static let lock = NSLock() + nonisolated(unsafe) private static var initialized = false + + static func ensureInitialized() -> Bool { + lock.lock() + defer { lock.unlock() } + + if initialized { return true } + initialized = ghostty_init(0, nil) == GHOSTTY_SUCCESS + return initialized + } +} + +@MainActor +private enum TerminalHardwareKeyEncoder { + private static let controlInputs = "abcdefghijklmnopqrstuvwxyz@[\\]^_-? " + + static func makeKeyCommands(action: Selector) -> [UIKeyCommand] { + var commands = [UIKeyCommand]() + let specialInputs = [ + UIKeyCommand.inputEscape, + UIKeyCommand.inputUpArrow, + UIKeyCommand.inputDownArrow, + UIKeyCommand.inputLeftArrow, + UIKeyCommand.inputRightArrow, + "\t", + ] + + for input in specialInputs { + commands.append(makeCommand(input: input, modifierFlags: [], action: action)) + } + commands.append(makeCommand(input: "\t", modifierFlags: .shift, action: action)) + + for character in controlInputs { + commands.append( + makeCommand( + input: String(character), + modifierFlags: .control, + action: action + ) + ) + commands.append( + makeCommand( + input: String(character), + modifierFlags: [.control, .shift], + action: action + ) + ) + } + + commands.append(makeCommand(input: "c", modifierFlags: .command, action: action)) + commands.append(makeCommand(input: "v", modifierFlags: .command, action: action)) + return commands + } + + private static func makeCommand( + input: String, + modifierFlags: UIKeyModifierFlags, + action: Selector + ) -> UIKeyCommand { + let command = UIKeyCommand(input: input, modifierFlags: modifierFlags, action: action) + command.wantsPriorityOverSystemBehavior = true + return command + } + + static func sequence(input: String, modifiers: UIKeyModifierFlags) -> String? { + if modifiers == .command { + return input.lowercased() == "c" ? "copy" : input.lowercased() == "v" ? "paste" : nil + } + + switch input { + case UIKeyCommand.inputEscape: return "\u{1B}" + case UIKeyCommand.inputUpArrow: return "\u{1B}[A" + case UIKeyCommand.inputDownArrow: return "\u{1B}[B" + case UIKeyCommand.inputRightArrow: return "\u{1B}[C" + case UIKeyCommand.inputLeftArrow: return "\u{1B}[D" + case "\t": return modifiers.contains(.shift) ? "\u{1B}[Z" : "\t" + default: break + } + + guard modifiers.contains(.control), + let scalar = input.lowercased().unicodeScalars.first else { + return nil + } + return controlSequence(for: scalar) + } + + static func applyingControl(to input: String) -> String { + guard let scalar = input.lowercased().unicodeScalars.first else { return input } + return controlSequence(for: scalar) ?? input + } + + private static func controlSequence(for scalar: Unicode.Scalar) -> String? { + switch scalar { + case "a"..."z": return UnicodeScalar(scalar.value - 96).map(String.init) + case " ", "@": return "\u{00}" + case "[": return "\u{1B}" + case "\\": return "\u{1C}" + case "]": return "\u{1D}" + case "^": return "\u{1E}" + case "_", "-": return "\u{1F}" + case "?": return "\u{7F}" + default: return nil + } + } +} + +private final class TerminalInputField: UITextField { + var onDeleteBackward: (() -> Void)? + var onInsert: ((String) -> Void)? + var onCopyOutput: (() -> Void)? + var onPasteText: (() -> Void)? + + private static let terminalKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( + action: #selector(handleHardwareKeyCommand(_:)) + ) + + override var keyCommands: [UIKeyCommand]? { Self.terminalKeyCommands } + + override func deleteBackward() { + onDeleteBackward?() + super.deleteBackward() + } + + @objc private func handleHardwareKeyCommand(_ command: UIKeyCommand) { + guard let input = command.input, + let sequence = TerminalHardwareKeyEncoder.sequence( + input: input, + modifiers: command.modifierFlags + ) else { + return + } + + if sequence == "copy" { + onCopyOutput?() + } else if sequence == "paste" { + onPasteText?() + } else { + onInsert?(sequence) + } + } +} + +private enum TerminalAccessoryAction: String { + case escape + case command + case control + case tab + case clear + case up + case down + case left + case right + case tilde + case pipe + case slash + case dash + case dismiss + + var label: String { + switch self { + case .escape: "esc" + case .command: "cmd" + case .control: "ctrl" + case .tab: "tab" + case .clear: "clear" + case .up: "↑" + case .down: "↓" + case .left: "←" + case .right: "→" + case .tilde: "~" + case .pipe: "|" + case .slash: "/" + case .dash: "-" + case .dismiss: "" + } + } + + var sequence: String? { + switch self { + case .escape: "\u{1B}" + case .tab: "\t" + case .up: "\u{1B}[A" + case .down: "\u{1B}[B" + case .left: "\u{1B}[D" + case .right: "\u{1B}[C" + case .tilde: "~" + case .pipe: "|" + case .slash: "/" + case .dash: "-" + case .command, .control, .clear, .dismiss: nil + } + } + + var width: CGFloat { + switch self { + case .escape, .tab: 44 + case .command: 48 + case .control, .clear: 50 + case .up, .down, .left, .right, .tilde, .pipe, .slash, .dash: 38 + case .dismiss: 36 + } + } +} + +private final class TerminalAccessoryButton: UIButton { + let terminalAction: TerminalAccessoryAction + + init(action: TerminalAccessoryAction) { + terminalAction = action + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } +} + +private final class TerminalAccessoryView: UIInputView { + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private let dismissButton = TerminalAccessoryButton(action: .dismiss) + private var actionButtons = [TerminalAccessoryAction: TerminalAccessoryButton]() + private var activeModifier: TerminalAccessoryAction? + var onAction: ((TerminalAccessoryAction) -> Void)? + + init() { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 50), inputViewStyle: .keyboard) + allowsSelfSizing = true + backgroundColor = T3Colors.uiBackground + + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 7 + stackView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(scrollView) + addSubview(dismissButton) + scrollView.addSubview(stackView) + + let actions: [TerminalAccessoryAction] = [ + .escape, .command, .control, .tab, .clear, + .up, .down, .left, .right, .tilde, .pipe, .slash, .dash, + ] + for action in actions { + let button = TerminalAccessoryButton(action: action) + configure(button, label: action.label) + actionButtons[action] = button + stackView.addArrangedSubview(button) + } + + dismissButton.accessibilityLabel = "Dismiss keyboard" + dismissButton.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside) + dismissButton.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 50), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.trailingAnchor.constraint(equalTo: dismissButton.leadingAnchor), + dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), + dismissButton.centerYAnchor.constraint(equalTo: centerYAnchor), + dismissButton.widthAnchor.constraint(equalToConstant: TerminalAccessoryAction.dismiss.width), + dismissButton.heightAnchor.constraint(equalToConstant: 42), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 8), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -8), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + refreshAppearance() + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { + (self: Self, _: UITraitCollection) in + self.refreshAppearance() + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } + + func setRunning(_ running: Bool) { + for (action, button) in actionButtons { + button.isEnabled = running || action == .clear + } + } + + func setActiveModifier(_ action: TerminalAccessoryAction?) { + activeModifier = action + for modifier in [TerminalAccessoryAction.command, .control] { + guard let button = actionButtons[modifier] else { continue } + applyStyle(to: button, active: modifier == action) + } + } + + func refreshAppearance() { + backgroundColor = T3Colors.uiBackground + for (action, button) in actionButtons { + applyStyle(to: button, active: action == activeModifier) + } + + var dismissConfiguration = UIButton.Configuration.plain() + dismissConfiguration.image = UIImage(systemName: "keyboard.chevron.compact.down") + dismissConfiguration.baseForegroundColor = .secondaryLabel + dismissConfiguration.contentInsets = .zero + dismissButton.configuration = dismissConfiguration + } + + private func configure(_ button: TerminalAccessoryButton, label: String) { + button.setTitle(label.uppercased(), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 10, weight: .semibold) + button.accessibilityLabel = label + button.addTarget(self, action: #selector(handleButton(_:)), for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 34).isActive = true + button.widthAnchor.constraint(equalToConstant: button.terminalAction.width).isActive = true + applyStyle(to: button, active: false) + } + + private func applyStyle(to button: UIButton, active: Bool) { + var configuration = UIButton.Configuration.plain() + if let terminalButton = button as? TerminalAccessoryButton, + terminalButton.terminalAction != .dismiss { + configuration.title = terminalButton.terminalAction.label.uppercased() + } + let isDark = traitCollection.userInterfaceStyle == .dark + configuration.baseForegroundColor = if active { + isDark ? UIColor(white: 0.04, alpha: 1) : .white + } else { + isDark ? UIColor(white: 0.88, alpha: 1) : T3Colors.uiTextPrimary + } + configuration.background.backgroundColor = if active { + isDark ? UIColor(white: 0.94, alpha: 1) : T3Colors.uiTextPrimary + } else { + isDark ? UIColor(white: 0.08, alpha: 1) : .white + } + configuration.background.cornerRadius = 7 + configuration.background.strokeColor = isDark + ? UIColor(white: active ? 0.55 : 0.20, alpha: 1) + : UIColor(white: 0, alpha: active ? 0.18 : 0.10) + configuration.background.strokeWidth = 1 + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .systemFont(ofSize: 10, weight: .semibold) + return attributes + } + configuration.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: 4, + bottom: 0, + trailing: 4 + ) + button.configuration = configuration + } + + @objc private func handleButton(_ sender: TerminalAccessoryButton) { + onAction?(sender.terminalAction) + } +} + +final class GhosttyTerminalView: UIView, UITextFieldDelegate, UIContextMenuInteractionDelegate { + private static let minimumVerticalScrollStepPoints: CGFloat = 18 + private static let verticalScrollStepMultiplier: CGFloat = 1.15 + private static let darkThemeConfig = """ + background = #0a0a0a + foreground = #adadb1 + cursor-color = #009fff + cursor-text = #0a0a0a + cursor-style-blink = false + palette = 0=#141415 + palette = 1=#ff2e3f + palette = 2=#0dbe4e + palette = 3=#ffca00 + palette = 4=#009fff + palette = 5=#c635e4 + palette = 6=#08c0ef + palette = 7=#c6c6c8 + palette = 8=#141415 + palette = 9=#ff2e3f + palette = 10=#0dbe4e + palette = 11=#ffca00 + palette = 12=#009fff + palette = 13=#c635e4 + palette = 14=#08c0ef + palette = 15=#c6c6c8 + """ + private static let lightThemeConfig = """ + background = #f2f2f7 + foreground = #6c6c71 + cursor-color = #009fff + cursor-text = #f2f2f7 + cursor-style-blink = false + palette = 0=#1f1f21 + palette = 1=#ff2e3f + palette = 2=#0dbe4e + palette = 3=#ffca00 + palette = 4=#009fff + palette = 5=#c635e4 + palette = 6=#08c0ef + palette = 7=#c6c6c8 + palette = 8=#1f1f21 + palette = 9=#ff2e3f + palette = 10=#0dbe4e + palette = 11=#ffca00 + palette = 12=#009fff + palette = 13=#c635e4 + palette = 14=#08c0ef + palette = 15=#c6c6c8 + """ + + var onInput: ((String) -> Void)? + var onResize: ((Int, Int) -> Void)? + var onClear: (() -> Void)? + var onFontSizeStep: ((Int) -> Void)? + + var isDarkMode = true { + didSet { + guard oldValue != isDarkMode else { return } + applyChromeAppearance() + refreshSurface() + } + } + + var terminalKey = "" { + didSet { + accessibilityIdentifier = "t3-terminal-\(terminalKey)" + inputField.accessibilityIdentifier = "t3-terminal-input-\(terminalKey)" + guard oldValue != terminalKey else { return } + pendingModifier = nil + resetSurface() + } + } + + var buffer = "" { + didSet { + guard oldValue != buffer else { return } + applyRemoteBuffer(buffer) + if UIAccessibility.isVoiceOverRunning { + terminalViewport.accessibilityValue = TerminalText.plainText( + from: String(buffer.suffix(8_192)) + ) + } + } + } + + var fontSize: CGFloat = 10.5 { + didSet { + guard oldValue != fontSize else { return } + inputField.font = .monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) + refreshSurface() + } + } + + var isRunning = false { + didSet { + guard oldValue != isRunning else { return } + inputField.isEnabled = isRunning + accessoryView.setRunning(isRunning) + keyboardButton.isHidden = !isRunning || inputField.isFirstResponder + if isRunning, window != nil, !hasAutoFocused { + hasAutoFocused = true + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } else if !isRunning { + pendingModifier = nil + } + } + } + + var focusRequest = 0 { + didSet { + guard oldValue != focusRequest else { return } + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } + } + + private let terminalViewport = UIView() + private let inputField = TerminalInputField() + private let accessoryView = TerminalAccessoryView() + private let keyboardButton = UIButton(type: .system) + private let focusTapGesture = UITapGestureRecognizer() + private let scrollPanGesture = UIPanGestureRecognizer() + private let fontPinchGesture = UIPinchGestureRecognizer() + private var pendingModifier: TerminalAccessoryAction? { + didSet { accessoryView.setActiveModifier(pendingModifier) } + } + private var lastViewportSize: CGSize = .zero + private var lastContentScale: CGFloat = 0 + private var lastReportedGrid: (columns: Int, rows: Int)? + private var lastAppliedBuffer = "" + private var isReplayingBuffer = false + private var pendingVerticalScrollPoints: CGFloat = 0 + private var hasAutoFocused = false + private var app: ghostty_app_t? + private var surface: ghostty_surface_t? + private var isCreatingSurface = false + private var surfaceCreationFailed = false + + init() { + super.init(frame: .zero) + clipsToBounds = true + contentScaleFactor = UIScreen.main.scale + accessibilityLabel = "Terminal" + + terminalViewport.clipsToBounds = true + terminalViewport.contentScaleFactor = contentScaleFactor + terminalViewport.translatesAutoresizingMaskIntoConstraints = false + terminalViewport.isUserInteractionEnabled = true + terminalViewport.isAccessibilityElement = true + terminalViewport.accessibilityLabel = "Terminal output" + terminalViewport.accessibilityTraits = .staticText + + inputField.delegate = self + inputField.inputAccessoryView = accessoryView + inputField.backgroundColor = .clear + inputField.textColor = .clear + inputField.tintColor = .clear + inputField.font = .monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) + inputField.placeholder = "" + inputField.autocorrectionType = .no + inputField.autocapitalizationType = .none + inputField.spellCheckingType = .no + inputField.smartDashesType = .no + inputField.smartQuotesType = .no + inputField.returnKeyType = .send + inputField.keyboardType = .asciiCapable + inputField.enablesReturnKeyAutomatically = false + inputField.translatesAutoresizingMaskIntoConstraints = false + inputField.alpha = 0.02 + inputField.isAccessibilityElement = true + inputField.accessibilityLabel = "Terminal input" + inputField.addTarget(self, action: #selector(inputDidBegin), for: .editingDidBegin) + inputField.addTarget(self, action: #selector(inputDidEnd), for: .editingDidEnd) + inputField.onDeleteBackward = { [weak self] in self?.sendInput("\u{7F}") } + inputField.onInsert = { [weak self] in self?.sendInput($0) } + inputField.onCopyOutput = { [weak self] in self?.copyOutput() } + inputField.onPasteText = { [weak self] in self?.pasteText() } + + keyboardButton.accessibilityLabel = "Show keyboard" + keyboardButton.isHidden = true + keyboardButton.translatesAutoresizingMaskIntoConstraints = false + keyboardButton.addTarget(self, action: #selector(showKeyboard), for: .touchUpInside) + + accessoryView.onAction = { [weak self] in self?.handleAccessoryAction($0) } + + focusTapGesture.addTarget(self, action: #selector(viewportTapped)) + terminalViewport.addGestureRecognizer(focusTapGesture) + + scrollPanGesture.addTarget(self, action: #selector(viewportPanned(_:))) + scrollPanGesture.maximumNumberOfTouches = 1 + scrollPanGesture.cancelsTouchesInView = false + terminalViewport.addGestureRecognizer(scrollPanGesture) + + fontPinchGesture.addTarget(self, action: #selector(viewportPinched(_:))) + terminalViewport.addGestureRecognizer(fontPinchGesture) + terminalViewport.addInteraction(UIContextMenuInteraction(delegate: self)) + + addSubview(terminalViewport) + addSubview(inputField) + addSubview(keyboardButton) + + NSLayoutConstraint.activate([ + terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), + terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + terminalViewport.topAnchor.constraint(equalTo: topAnchor, constant: 6), + terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), + inputField.trailingAnchor.constraint(equalTo: trailingAnchor), + inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8), + inputField.widthAnchor.constraint(equalToConstant: 1), + inputField.heightAnchor.constraint(equalToConstant: 1), + keyboardButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + keyboardButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -16), + keyboardButton.widthAnchor.constraint(equalToConstant: 48), + keyboardButton.heightAnchor.constraint(equalToConstant: 48), + ]) + applyChromeAppearance() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { nil } + + override func layoutSubviews() { + super.layoutSubviews() + updateContentScale() + if surface == nil { createSurfaceIfPossible() } + + let viewportSize = terminalViewport.bounds.size + guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { + return + } + lastViewportSize = viewportSize + lastContentScale = contentScaleFactor + resizeSurface() + inputField.accessibilityFrame = terminalViewport.convert(terminalViewport.bounds, to: nil) + } + + override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, isRunning, !hasAutoFocused else { return } + hasAutoFocused = true + DispatchQueue.main.async { [weak self] in self?.requestKeyboardFocus() } + } + + func textField( + _: UITextField, + shouldChangeCharactersIn _: NSRange, + replacementString string: String + ) -> Bool { + if !string.isEmpty { + sendInput(string == "\n" || string == "\r\n" ? "\r" : string) + } + return false + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + sendInput("\r") + textField.text = "" + return false + } + + func contextMenuInteraction( + _: UIContextMenuInteraction, + configurationForMenuAtLocation _: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return UIMenu() } + let copy = UIAction(title: "Copy output", image: UIImage(systemName: "doc.on.doc")) { [weak self] _ in + self?.copyOutput() + } + let paste = UIAction( + title: "Paste", + image: UIImage(systemName: "doc.on.clipboard"), + attributes: self.isRunning && UIPasteboard.general.hasStrings ? [] : .disabled + ) { [weak self] _ in + self?.pasteText() + } + let clear = UIAction(title: "Clear", image: UIImage(systemName: "eraser")) { [weak self] _ in + self?.onClear?() + } + return UIMenu(children: [copy, paste, clear]) + } + } + + private func createSurfaceIfPossible() { + guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return } + guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return } + guard GhosttyRuntime.ensureInitialized() else { + surfaceCreationFailed = true + return + } + + isCreatingSurface = true + defer { isCreatingSurface = false } + + var runtimeConfig = ghostty_runtime_config_s( + userdata: Unmanaged.passUnretained(self).toOpaque(), + supports_selection_clipboard: false, + wakeup_cb: { _ in }, + action_cb: { _, _, _ in false }, + read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, + confirm_read_clipboard_cb: { _, _, _, _ in }, + write_clipboard_cb: { _, _, _, _, _ in }, + close_surface_cb: { _, _ in } + ) + + guard let config = ghostty_config_new() else { + surfaceCreationFailed = true + return + } + loadThemeConfig(into: config) + ghostty_config_finalize(config) + defer { ghostty_config_free(config) } + + guard let createdApp = ghostty_app_new(&runtimeConfig, config) else { + surfaceCreationFailed = true + return + } + + var surfaceConfig = ghostty_surface_config_new() + surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS + surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque() + surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque() + surfaceConfig.scale_factor = Double(contentScaleFactor) + surfaceConfig.font_size = Float(fontSize) + surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW + surfaceConfig.use_custom_io = true + + guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else { + ghostty_app_free(createdApp) + surfaceCreationFailed = true + return + } + + app = createdApp + surface = createdSurface + let ghosttyColorScheme = + isDarkMode + ? GHOSTTY_COLOR_SCHEME_DARK + : GHOSTTY_COLOR_SCHEME_LIGHT + ghostty_app_set_color_scheme(createdApp, ghosttyColorScheme) + ghostty_surface_set_color_scheme(createdSurface, ghosttyColorScheme) + setupWriteCallback() + resizeSurface() + feedBuffer(buffer) + } + + private func resetSurface() { + destroySurface() + lastAppliedBuffer = "" + lastViewportSize = .zero + lastContentScale = 0 + lastReportedGrid = nil + surfaceCreationFailed = false + setNeedsLayout() + } + + private func applyChromeAppearance() { + let background = + isDarkMode + ? UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1) + : UIColor(red: 242 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1) + backgroundColor = background + terminalViewport.backgroundColor = background + accessoryView.overrideUserInterfaceStyle = isDarkMode ? .dark : .light + accessoryView.refreshAppearance() + + var keyboardConfiguration = UIButton.Configuration.filled() + keyboardConfiguration.image = UIImage(systemName: "keyboard") + keyboardConfiguration.baseForegroundColor = isDarkMode ? .white : T3Colors.uiTextPrimary + keyboardConfiguration.baseBackgroundColor = + isDarkMode ? UIColor(white: 0.10, alpha: 0.96) : .white + keyboardConfiguration.background.cornerRadius = 24 + keyboardConfiguration.background.strokeColor = + isDarkMode + ? UIColor(white: 0.25, alpha: 1) + : UIColor(white: 0, alpha: 0.10) + keyboardConfiguration.background.strokeWidth = 1 + keyboardButton.configuration = keyboardConfiguration + } + + private func refreshSurface() { + resetSurface() + createSurfaceIfPossible() + } + + func tearDown() { + onInput = nil + onResize = nil + onClear = nil + onFontSizeStep = nil + destroySurface() + } + + private func destroySurface() { + if let surface { + ghostty_surface_set_write_callback(surface, nil, nil) + ghostty_surface_free(surface) + } + if let app { ghostty_app_free(app) } + terminalViewport.layer.sublayers?.forEach { $0.removeFromSuperlayer() } + surface = nil + app = nil + } + + private func applyRemoteBuffer(_ newBuffer: String) { + guard surface != nil else { + createSurfaceIfPossible() + return + } + guard newBuffer != lastAppliedBuffer else { return } + + if newBuffer.isEmpty { + feedData(Data("\u{1B}[2J\u{1B}[H".utf8)) + lastAppliedBuffer = "" + return + } + + if newBuffer.hasPrefix(lastAppliedBuffer) { + feedData(Data(newBuffer.dropFirst(lastAppliedBuffer.count).utf8)) + lastAppliedBuffer = newBuffer + return + } + + resetSurface() + createSurfaceIfPossible() + } + + private func feedBuffer(_ value: String) { + guard !value.isEmpty else { return } + isReplayingBuffer = true + defer { isReplayingBuffer = false } + feedData(Data(value.utf8)) + lastAppliedBuffer = value + } + + private func feedData(_ data: Data) { + guard let surface, !data.isEmpty else { return } + data.withUnsafeBytes { bytes in + guard let pointer = bytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + ghostty_surface_feed_data(surface, pointer, bytes.count) + } + redrawSurface() + } + + private func setupWriteCallback() { + guard let surface else { return } + let userdata = Unmanaged.passUnretained(self).toOpaque() + ghostty_surface_set_write_callback(surface, { userdata, data, length in + guard let userdata, let data, length > 0 else { return } + let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard !view.isReplayingBuffer else { return } + let bytes = Data(bytes: data, count: length) + guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } + DispatchQueue.main.async { view.onInput?(input) } + }, userdata) + } + + private func resizeSurface() { + guard let surface else { + emitEstimatedResize() + return + } + + let scale = contentScaleFactor + let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1)) + let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1)) + terminalViewport.contentScaleFactor = scale + ghostty_surface_set_content_scale(surface, Double(scale), Double(scale)) + ghostty_surface_set_size(surface, width, height) + ghostty_surface_set_occlusion(surface, window != nil) + configureIOSurfaceLayers() + redrawSurface() + emitGhosttyResize() + } + + private func redrawSurface() { + guard let surface else { return } + ghostty_surface_refresh(surface) + ghostty_surface_draw(surface) + markIOSurfaceLayersForDisplay() + emitGhosttyResize() + } + + private func emitGhosttyResize() { + guard let surface else { + emitEstimatedResize() + return + } + let size = ghostty_surface_size(surface) + emitResize(columns: max(1, Int(size.columns)), rows: max(1, Int(size.rows))) + } + + private func emitEstimatedResize() { + guard bounds.width > 0, bounds.height > 0 else { return } + let columns = max(20, min(400, Int(bounds.width / max(fontSize * 0.62, 1)))) + let rows = max(5, min(200, Int(bounds.height / max(fontSize * 1.35, 1)))) + emitResize(columns: columns, rows: rows) + } + + private func emitResize(columns: Int, rows: Int) { + guard lastReportedGrid?.columns != columns || lastReportedGrid?.rows != rows else { return } + lastReportedGrid = (columns, rows) + onResize?(columns, rows) + } + + private func updateContentScale() { + let scale = window?.screen.scale ?? UIScreen.main.scale + if contentScaleFactor != scale { contentScaleFactor = scale } + } + + private func requestKeyboardFocus() { + guard window != nil, isRunning else { return } + inputField.becomeFirstResponder() + if let surface { ghostty_surface_set_focus(surface, true) } + if let app { ghostty_app_keyboard_changed(app) } + } + + private func sendInput(_ data: String) { + guard isRunning, !data.isEmpty else { return } + let resolved: String + if pendingModifier == .control { + resolved = TerminalHardwareKeyEncoder.applyingControl(to: data) + } else if pendingModifier == .command { + resolved = "\u{1B}\(data)" + } else { + resolved = data + } + pendingModifier = nil + onInput?(resolved) + } + + private func copyOutput() { + UIPasteboard.general.string = TerminalText.plainText(from: buffer) + } + + private func pasteText() { + guard let value = UIPasteboard.general.string, !value.isEmpty else { return } + sendInput(value) + } + + private func handleAccessoryAction(_ action: TerminalAccessoryAction) { + switch action { + case .command, .control: + pendingModifier = pendingModifier == action ? nil : action + case .clear: + pendingModifier = nil + onClear?() + case .dismiss: + pendingModifier = nil + inputField.resignFirstResponder() + default: + if let sequence = action.sequence { sendInput(sequence) } + } + } + + private func configureIOSurfaceLayers() { + let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size) + CATransaction.begin() + CATransaction.setDisableActions(true) + terminalViewport.layer.sublayers?.forEach { layer in + layer.frame = targetBounds + layer.contentsScale = contentScaleFactor + } + CATransaction.commit() + } + + private func markIOSurfaceLayersForDisplay() { + terminalViewport.layer.setNeedsDisplay() + terminalViewport.layer.sublayers?.forEach { $0.setNeedsDisplay() } + } + + private func loadThemeConfig(into config: ghostty_config_t) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swiftui-terminal.ghostty") + do { + let themeConfig = isDarkMode ? Self.darkThemeConfig : Self.lightThemeConfig + if (try? String(contentsOf: url, encoding: .utf8)) != themeConfig { + try themeConfig.write(to: url, atomically: true, encoding: .utf8) + } + url.path.withCString { ghostty_config_load_file(config, $0) } + } catch { + // The default Ghostty configuration is still usable if the theme file cannot be staged. + } + } + + @objc private func viewportTapped() { + requestKeyboardFocus() + } + + @objc private func viewportPanned(_ gesture: UIPanGestureRecognizer) { + guard let surface else { return } + let location = gesture.location(in: terminalViewport) + ghostty_surface_mouse_pos( + surface, + Double(location.x * contentScaleFactor), + Double(location.y * contentScaleFactor), + GHOSTTY_MODS_NONE + ) + + switch gesture.state { + case .began: + pendingVerticalScrollPoints = 0 + gesture.setTranslation(.zero, in: terminalViewport) + case .changed: + let translation = gesture.translation(in: terminalViewport) + let stepSize = max( + fontSize * Self.verticalScrollStepMultiplier, + Self.minimumVerticalScrollStepPoints + ) + let total = pendingVerticalScrollPoints + translation.y + let steps = Int(total / stepSize) + pendingVerticalScrollPoints = total - CGFloat(steps) * stepSize + if steps != 0 { + ghostty_surface_mouse_scroll(surface, 0, Double(steps), 0) + redrawSurface() + } + gesture.setTranslation(.zero, in: terminalViewport) + default: + pendingVerticalScrollPoints = 0 + gesture.setTranslation(.zero, in: terminalViewport) + } + } + + @objc private func viewportPinched(_ gesture: UIPinchGestureRecognizer) { + guard gesture.state == .ended else { return } + if gesture.scale >= 1.08 { + onFontSizeStep?(1) + } else if gesture.scale <= 0.92 { + onFontSizeStep?(-1) + } + } + + @objc private func inputDidBegin() { + keyboardButton.isHidden = true + if let surface { ghostty_surface_set_focus(surface, true) } + if let app { ghostty_app_keyboard_changed(app) } + } + + @objc private func inputDidEnd() { + pendingModifier = nil + keyboardButton.isHidden = !isRunning + if let surface { ghostty_surface_set_focus(surface, false) } + } + + @objc private func showKeyboard() { + requestKeyboardFocus() + } +} diff --git a/apps/swift-ios/Features/Usage/UsageModels.swift b/apps/swift-ios/Features/Usage/UsageModels.swift new file mode 100644 index 000000000000..7829f6f2323b --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageModels.swift @@ -0,0 +1,529 @@ +import Foundation + +public struct FeatureEnvironmentUsage: Identifiable, Equatable, Sendable { + public let environmentID: String + public let label: String + public let summary: UsageSummary? + public let errorMessage: String? + + public var id: String { environmentID } + + public init( + environmentID: String, + label: String, + summary: UsageSummary?, + errorMessage: String? + ) { + self.environmentID = environmentID + self.label = label + self.summary = summary + self.errorMessage = errorMessage + } +} + +struct UsageProviderTotals: Identifiable, Equatable { + let provider: UsageProviderKind + let costUsd: Double + let totalTokens: Int + let records: Int + let costShare: Double + let tokenShare: Double + + var id: UsageProviderKind { provider } +} + +struct UsageModelTotals: Identifiable, Equatable { + let model: String + let provider: UsageProviderKind + let costUsd: Double + let totalTokens: Int + let records: Int + let costShare: Double + + var id: String { "\(provider.rawValue):\(model)" } +} + +struct UsageProviderValue: Equatable { + var costUsd = 0.0 + var totalTokens = 0 +} + +struct UsageDailyTotals: Identifiable, Equatable { + let day: String + let costUsd: Double + let totalTokens: Int + let byProvider: [UsageProviderKind: UsageProviderValue] + + var id: String { day } +} + +struct UsageHourlyTotals: Identifiable, Equatable { + let hourStart: String + let costUsd: Double + let totalTokens: Int + let byProvider: [UsageProviderKind: UsageProviderValue] + + var id: String { hourStart } +} + +struct UsageCostQuality: Equatable { + let providerReportedShare: Double + let modelPricedShare: Double + let unpricedShare: Double + let cacheSavingsUsd: Double +} + +struct MergedUsage: Equatable { + var costUsd = 0.0 + var uncachedInputTokens = 0 + var cachedInputTokens = 0 + var cacheCreationTokens = 0 + var outputTokens = 0 + var reasoningTokens = 0 + var totalTokens = 0 + var records = 0 + var sessions = 0 + var providers: [UsageProviderTotals] = [] + var models: [UsageModelTotals] = [] + var daily: [UsageDailyTotals] = [] + var hourly: [UsageHourlyTotals] = [] + var costQuality = UsageCostQuality( + providerReportedShare: 0, + modelPricedShare: 0, + unpricedShare: 0, + cacheSavingsUsd: 0 + ) + var duplicateSources: [String] = [] + var contributingEnvironments: [String] = [] + var staleEnvironments: [String] = [] +} + +struct UsageLoadRequest: Equatable { + let id: UUID + let days: Int + let input: UsageSummaryInput +} + +struct UsageLoadState: Equatable { + private(set) var windowDays: Int + private(set) var windowInput: UsageSummaryInput + private(set) var environments: [FeatureEnvironmentUsage] = [] + private(set) var merged = MergedUsage() + private(set) var isLoading = true + private(set) var errorMessage: String? + private var activeLoadID: UUID? + + init( + days: Int = 30, + now: Date = Date(), + timeZone: TimeZone = .current + ) { + windowDays = days + windowInput = UsageWindow.make(days: days, now: now, timeZone: timeZone) + } + + mutating func begin( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) -> UsageLoadRequest { + selectWindow(days: days, now: now, timeZone: timeZone) + let request = UsageLoadRequest( + id: UUID(), + days: days, + input: UsageWindow.make(days: days, now: now, timeZone: timeZone) + ) + activeLoadID = request.id + isLoading = true + errorMessage = nil + return request + } + + mutating func selectWindow( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) { + guard days != windowDays else { return } + windowDays = days + windowInput = UsageWindow.make(days: days, now: now, timeZone: timeZone) + environments = [] + merged = MergedUsage() + isLoading = true + errorMessage = nil + } + + @discardableResult + mutating func receive( + _ result: [FeatureEnvironmentUsage], + for request: UsageLoadRequest + ) -> Bool { + guard activeLoadID == request.id, request.days == windowDays else { return false } + windowInput = request.input + environments = result + merged = UsageMerger.merge(result) + errorMessage = nil + return true + } + + @discardableResult + mutating func fail( + _ error: any Error, + for request: UsageLoadRequest + ) -> Bool { + guard activeLoadID == request.id, request.days == windowDays else { return false } + errorMessage = error.localizedDescription + return true + } + + mutating func finish(_ request: UsageLoadRequest) { + guard activeLoadID == request.id, request.days == windowDays else { return } + activeLoadID = nil + isLoading = false + } +} + +enum UsageMerger { + private struct OwnedContribution { + let buckets: [UsageBucket] + let sessions: Int + } + + private struct ProviderAccumulator { + var costUsd = 0.0 + var totalTokens = 0 + var records = 0 + } + + private struct ModelAccumulator { + let provider: UsageProviderKind + var costUsd = 0.0 + var totalTokens = 0 + var records = 0 + } + + private struct DailyAccumulator { + var costUsd = 0.0 + var totalTokens = 0 + var byProvider: [UsageProviderKind: UsageProviderValue] = [:] + } + + static func merge(_ environments: [FeatureEnvironmentUsage]) -> MergedUsage { + let available = environments.compactMap { environment -> (FeatureEnvironmentUsage, UsageSummary)? in + guard let summary = environment.summary else { return nil } + return (environment, summary) + } + let current = available.filter { + isCompatibleUsageContractVersion($0.1.contractVersion) + } + let staleEnvironmentIDs = available.compactMap { environment, summary in + isCompatibleUsageContractVersion(summary.contractVersion) + ? nil + : environment.environmentID + } + let claims = claimSources(current) + + var result = MergedUsage() + result.duplicateSources = claims.duplicates + result.staleEnvironments = staleEnvironmentIDs + + var cacheSavingsUsd = 0.0 + var providerReportedRecords = 0 + var unpricedRecords = 0 + var providers: [UsageProviderKind: ProviderAccumulator] = [:] + var models: [String: ModelAccumulator] = [:] + var daily: [String: DailyAccumulator] = [:] + var hourly: [String: DailyAccumulator] = [:] + + for (environment, summary) in current { + let contribution = ownedContribution( + environment: environment, + summary: summary, + ownerByFingerprint: claims.ownerByFingerprint + ) + if !contribution.buckets.isEmpty { + result.contributingEnvironments.append(environment.environmentID) + } + result.sessions += contribution.sessions + + for bucket in contribution.buckets { + let tokens = totalTokens(bucket) + result.costUsd += bucket.costUsd + result.uncachedInputTokens += bucket.totals.uncachedInputTokens + result.cachedInputTokens += bucket.totals.cachedInputTokens + result.cacheCreationTokens += bucket.totals.cacheCreationTokens + result.outputTokens += bucket.totals.outputTokens + result.reasoningTokens += bucket.totals.reasoningTokens + result.records += bucket.records + cacheSavingsUsd += bucket.cacheSavingsUsd + unpricedRecords += bucket.unpricedRecords + if bucket.costSource == .providerReported { + providerReportedRecords += bucket.records + } + + var provider = providers[bucket.provider] ?? ProviderAccumulator() + provider.costUsd += bucket.costUsd + provider.totalTokens += tokens + provider.records += bucket.records + providers[bucket.provider] = provider + + let modelKey = "\(bucket.provider.rawValue) \(bucket.model)" + var model = models[modelKey] ?? ModelAccumulator(provider: bucket.provider) + model.costUsd += bucket.costUsd + model.totalTokens += tokens + model.records += bucket.records + models[modelKey] = model + + var day = daily[bucket.day] ?? DailyAccumulator() + day.costUsd += bucket.costUsd + day.totalTokens += tokens + var dayProvider = day.byProvider[bucket.provider] ?? UsageProviderValue() + dayProvider.costUsd += bucket.costUsd + dayProvider.totalTokens += tokens + day.byProvider[bucket.provider] = dayProvider + daily[bucket.day] = day + + if let hourStart = bucket.hourStart { + var hour = hourly[hourStart] ?? DailyAccumulator() + hour.costUsd += bucket.costUsd + hour.totalTokens += tokens + var hourProvider = hour.byProvider[bucket.provider] ?? UsageProviderValue() + hourProvider.costUsd += bucket.costUsd + hourProvider.totalTokens += tokens + hour.byProvider[bucket.provider] = hourProvider + hourly[hourStart] = hour + } + } + } + + result.totalTokens = result.uncachedInputTokens + + result.cachedInputTokens + + result.cacheCreationTokens + + result.outputTokens + result.providers = providers.map { provider, totals in + UsageProviderTotals( + provider: provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: result.costUsd == 0 ? 0 : totals.costUsd / result.costUsd, + tokenShare: result.totalTokens == 0 + ? 0 + : Double(totals.totalTokens) / Double(result.totalTokens) + ) + } + .sorted { $0.costUsd > $1.costUsd } + result.models = models.map { key, totals in + UsageModelTotals( + model: String(key.split(separator: " ", maxSplits: 1).last ?? ""), + provider: totals.provider, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + records: totals.records, + costShare: result.costUsd == 0 ? 0 : totals.costUsd / result.costUsd + ) + } + .sorted { + $0.costUsd == $1.costUsd + ? $0.totalTokens > $1.totalTokens + : $0.costUsd > $1.costUsd + } + result.daily = daily.map { day, totals in + UsageDailyTotals( + day: day, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider + ) + } + .sorted { $0.day < $1.day } + result.hourly = hourly.map { hourStart, totals in + UsageHourlyTotals( + hourStart: hourStart, + costUsd: totals.costUsd, + totalTokens: totals.totalTokens, + byProvider: totals.byProvider + ) + } + .sorted { $0.hourStart < $1.hourStart } + result.costQuality = UsageCostQuality( + providerReportedShare: result.records == 0 + ? 0 + : Double(providerReportedRecords) / Double(result.records), + modelPricedShare: result.records == 0 + ? 0 + : Double(result.records - providerReportedRecords - unpricedRecords) + / Double(result.records), + unpricedShare: result.records == 0 + ? 0 + : Double(unpricedRecords) / Double(result.records), + cacheSavingsUsd: cacheSavingsUsd + ) + return result + } + + private static func claimSources( + _ environments: [(FeatureEnvironmentUsage, UsageSummary)] + ) -> (ownerByFingerprint: [UsageSourceFingerprint: String], duplicates: [String]) { + var selected: [ + UsageSourceFingerprint: (environmentID: String, status: UsageSourceStatus) + ] = [:] + var duplicates: [String] = [] + let ordered = environments.sorted { $0.0.environmentID < $1.0.environmentID } + + for (environment, summary) in ordered { + for source in summary.sources where source.status != .missing { + if let current = selected[source.fingerprint] { + if source.status.ownershipPriority > current.status.ownershipPriority { + selected[source.fingerprint] = (environment.environmentID, source.status) + } + } else { + selected[source.fingerprint] = (environment.environmentID, source.status) + } + } + } + + let owners = selected.mapValues(\.environmentID) + for (environment, summary) in ordered { + for source in summary.sources where source.status != .missing { + if owners[source.fingerprint] != environment.environmentID { + duplicates.append( + "\(environment.label): \(source.fingerprint.resolvedHomePath)" + ) + } + } + } + return (owners, duplicates) + } + + private static func ownedContribution( + environment: FeatureEnvironmentUsage, + summary: UsageSummary, + ownerByFingerprint: [UsageSourceFingerprint: String] + ) -> OwnedContribution { + var providers: Set = [] + var sessions = 0 + for source in summary.sources where source.status != .missing { + if ownerByFingerprint[source.fingerprint] == environment.environmentID { + providers.insert(source.fingerprint.provider) + sessions += source.distinctSessions + } + } + return OwnedContribution( + buckets: summary.buckets.filter { providers.contains($0.provider) }, + sessions: sessions + ) + } + + private static func totalTokens(_ bucket: UsageBucket) -> Int { + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens + } +} + +private extension UsageSourceStatus { + var ownershipPriority: Int { + switch self { + case .missing: 0 + case .failed: 1 + case .partial: 2 + case .ok: 3 + } + } +} + +enum UsageWindow { + static func make( + days: Int, + now: Date = Date(), + timeZone: TimeZone = .current + ) -> UsageSummaryInput { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let until = calendar.startOfDay(for: now) + let since = calendar.date(byAdding: .day, value: -(days - 1), to: until) ?? until + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd" + if days == 1 { + let untilTimeInterval = floor(now.timeIntervalSince1970 / 60) * 60 + let untilTime = Date(timeIntervalSince1970: untilTimeInterval) + let sinceTime = untilTime.addingTimeInterval(-24 * 60 * 60) + return UsageSummaryInput( + sinceDay: formatter.string(from: sinceTime), + untilDay: formatter.string(from: untilTime), + timeZone: timeZone.identifier, + resolution: .hour, + sinceTime: isoString(sinceTime), + untilTime: isoString(untilTime) + ) + } + return UsageSummaryInput( + sinceDay: formatter.string(from: since), + untilDay: formatter.string(from: until), + timeZone: timeZone.identifier, + resolution: .day + ) + } + + static func hours(in input: UsageSummaryInput) -> [String] { + guard let sinceValue = input.sinceTime, + let untilValue = input.untilTime, + let since = isoDate(sinceValue), + let until = isoDate(untilValue), + since < until else { + return [] + } + var result: [String] = [] + var cursor = since + while cursor < until { + result.append(isoString(cursor)) + cursor = cursor.addingTimeInterval(60 * 60) + } + return result + } + + private static func isoString(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + private static func isoDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } + + static func days(in input: UsageSummaryInput) -> [String] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let parser = DateFormatter() + parser.calendar = calendar + parser.locale = Locale(identifier: "en_US_POSIX") + parser.timeZone = TimeZone(secondsFromGMT: 0) + parser.dateFormat = "yyyy-MM-dd" + guard let start = parser.date(from: input.sinceDay), + let end = parser.date(from: input.untilDay), + start <= end else { + return [] + } + + var result: [String] = [] + var cursor = start + while cursor <= end { + result.append(parser.string(from: cursor)) + guard let next = calendar.date( + byAdding: .day, + value: 1, + to: cursor + ) else { break } + cursor = next + } + return result + } +} diff --git a/apps/swift-ios/Features/Usage/UsageView.swift b/apps/swift-ios/Features/Usage/UsageView.swift new file mode 100644 index 000000000000..c363539e8877 --- /dev/null +++ b/apps/swift-ios/Features/Usage/UsageView.swift @@ -0,0 +1,678 @@ +import Charts +import SwiftUI + +private enum UsageMetric: String, CaseIterable, Identifiable { + case cost + case tokens + + var id: Self { self } + var label: String { rawValue.uppercased() } +} + +private enum UsageBreakdown: String, CaseIterable, Identifiable { + case model + case time + + var id: Self { self } +} + +public struct UsageView: View { + private let client: any FeatureClient + + @State private var loadState = UsageLoadState() + @State private var metric = UsageMetric.cost + @State private var breakdown = UsageBreakdown.model + + public init(client: any FeatureClient) { + self.client = client + } + + private var windowInput: UsageSummaryInput { loadState.windowInput } + private var environments: [FeatureEnvironmentUsage] { loadState.environments } + private var merged: MergedUsage { loadState.merged } + private var isLoading: Bool { loadState.isLoading } + private var errorMessage: String? { loadState.errorMessage } + private var windowDays: Binding { + Binding( + get: { loadState.windowDays }, + set: { loadState.selectWindow(days: $0) } + ) + } + + public var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + Picker("Usage window", selection: windowDays) { + Text("24h").tag(1) + Text("7d").tag(7) + Text("30d").tag(30) + Text("90d").tag(90) + } + .pickerStyle(.segmented) + .tint(T3Colors.textPrimary) + + coverageNotice + + if isLoading, environments.isEmpty { + Text("Scanning provider transcripts…") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 64) + } else if let errorMessage, environments.isEmpty { + ContentUnavailableView { + Label("Couldn’t load usage", systemImage: "exclamationmark.circle") + } description: { + Text(errorMessage) + } actions: { + Button("Try again") { Task { await load() } } + } + } else if environments.isEmpty { + ContentUnavailableView { + Label("No environments", systemImage: "chart.bar.xaxis") + } description: { + Text("Connect an environment to see usage.") + } + } else if !hasCompatibleSummary { + ContentUnavailableView { + Label("Couldn’t load usage", systemImage: "exclamationmark.circle") + } description: { + Text("No compatible usage data is available.") + } actions: { + Button("Try again") { Task { await load() } } + } + } else { + chartCard + providersSection + totalsSection + breakdownSection + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .refreshable { await load() } + .background(T3Colors.background) + .navigationTitle("Usage") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .t3NavigationChrome() + .task(id: loadState.windowDays) { + await load() + } + } + + @ViewBuilder + private var coverageNotice: some View { + let failed = environments.filter { $0.errorMessage != nil } + let stale = environments.filter { merged.staleEnvironments.contains($0.environmentID) } + let hasRefreshError = errorMessage != nil && hasCompatibleSummary + if hasRefreshError + || !failed.isEmpty + || !stale.isEmpty + || !merged.duplicateSources.isEmpty { + VStack(alignment: .leading, spacing: 6) { + if hasRefreshError { + Text("Couldn’t refresh usage. The totals below are from the last successful scan.") + } + ForEach(failed) { environment in + Text("\(environment.label) could not report usage.") + } + ForEach(stale) { environment in + Text("\(environment.label) uses an unsupported usage format and is excluded from totals.") + } + if !merged.duplicateSources.isEmpty { + Text( + "Counted once across environments sharing a transcript directory: " + + merged.duplicateSources.joined(separator: ", ") + ) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 16)) + } + } + + private var chartCard: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(metric == .cost ? "Raw token cost" : "Processed tokens") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text( + metric == .cost + ? "\(UsageFormat.usd(merged.costUsd))*" + : UsageFormat.tokens(merged.totalTokens) + ) + .font(.system(.largeTitle, design: .default, weight: .bold)) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text( + metric == .cost + ? "* if billed at full API rate" + : "Across \(UsageFormat.count(merged.sessions)) sessions" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("Chart metric", selection: $metric) { + ForEach(UsageMetric.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + .fixedSize() + .tint(T3Colors.textPrimary) + } + + if merged.daily.contains(where: { + metric == .cost ? $0.costUsd > 0 : $0.totalTokens > 0 + }) { + UsagePeriodChart( + input: windowInput, + daily: merged.daily, + hourly: merged.hourly, + metric: metric + ) + .frame(height: 180) + } else { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 180) + } + + HStack(spacing: 8) { + Text(UsageFormat.dayShort(windowInput.sinceDay)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 14) { + ForEach(merged.providers) { provider in + HStack(spacing: 5) { + Circle() + .fill(provider.provider.color) + .frame(width: 8, height: 8) + Text(provider.provider.displayName) + } + } + } + .fixedSize() + + Text(UsageFormat.dayShort(windowInput.untilDay)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(16) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 24)) + } + + @ViewBuilder + private var providersSection: some View { + if !merged.providers.isEmpty { + UsageSection(title: "Providers") { + let ordered = merged.providers.sorted { + metric == .cost + ? $0.costUsd > $1.costUsd + : $0.totalTokens > $1.totalTokens + } + VStack(spacing: 0) { + ForEach(Array(ordered.enumerated()), id: \.element.id) { index, provider in + if index > 0 { usageDivider } + let share = metric == .cost ? provider.costShare : provider.tokenShare + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Circle() + .fill(provider.provider.color) + .frame(width: 10, height: 10) + Text(provider.provider.displayName) + .font(.title3) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + Text( + metric == .cost + ? UsageFormat.usd(provider.costUsd) + : UsageFormat.tokens(provider.totalTokens) + ) + .font(.title3) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + } + UsageProgressBar(value: share, color: provider.provider.color) + Text( + metric == .cost + ? "\(UsageFormat.percent(share)) of cost · " + + "\(UsageFormat.tokens(provider.totalTokens)) tokens" + : "\(UsageFormat.percent(share)) of tokens · \(UsageFormat.usd(provider.costUsd))" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .padding(16) + } + } + .usageCard() + } + } + } + + private var totalsSection: some View { + UsageSection(title: "Totals") { + let isHourly = windowInput.resolution == .hour + let activePeriods = isHourly + ? merged.hourly.filter { $0.totalTokens > 0 }.count + : merged.daily.filter { $0.totalTokens > 0 }.count + let periodAverage = activePeriods == 0 ? 0 : merged.totalTokens / activePeriods + let observedInput = merged.uncachedInputTokens + merged.cachedInputTokens + let cachedShare = observedInput == 0 + ? 0 + : Double(merged.cachedInputTokens) / Double(observedInput) + LazyVGrid( + columns: [GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading)], + alignment: .leading, + spacing: 0 + ) { + UsageMetricCell( + label: "Processed tokens", + value: UsageFormat.tokens(merged.totalTokens), + detail: "\(UsageFormat.tokens(periodAverage)) per active \(isHourly ? "hour" : "day")" + ) + UsageMetricCell( + label: "Cache savings", + value: UsageFormat.usd(merged.costQuality.cacheSavingsUsd), + detail: merged.costUsd > 0 + ? String(format: "%.1fx the raw cost", merged.costQuality.cacheSavingsUsd / merged.costUsd) + : "vs full input rates" + ) + UsageMetricCell( + label: "Cached input", + value: UsageFormat.tokens(merged.cachedInputTokens), + detail: "\(UsageFormat.percent(cachedShare)) of observed input" + ) + UsageMetricCell( + label: "Uncached input", + value: UsageFormat.tokens(merged.uncachedInputTokens), + detail: "\(UsageFormat.tokens(merged.cacheCreationTokens)) cache writes" + ) + UsageMetricCell( + label: "Output", + value: UsageFormat.tokens(merged.outputTokens), + detail: "incl. \(UsageFormat.tokens(merged.reasoningTokens)) reasoning" + ) + UsageMetricCell( + label: "Unpriced", + value: UsageFormat.percent(merged.costQuality.unpricedShare), + detail: "of records, excluded from cost" + ) + } + .usageCard() + } + } + + private var breakdownSection: some View { + UsageSection(title: "Breakdown") { + VStack(spacing: 12) { + Picker("Breakdown", selection: $breakdown) { + Text("Model").tag(UsageBreakdown.model) + Text(windowInput.resolution == .hour ? "Hour" : "Day") + .tag(UsageBreakdown.time) + } + .pickerStyle(.segmented) + + if breakdown == .model { + modelBreakdown + } else { + timeBreakdown + } + } + } + } + + @ViewBuilder + private var modelBreakdown: some View { + if merged.models.isEmpty { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(24) + .usageCard() + } else { + VStack(spacing: 0) { + ForEach(Array(merged.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { usageDivider } + HStack(spacing: 12) { + Circle() + .fill(model.provider.color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text(model.model) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Text( + "\(UsageFormat.percent(model.costShare)) of cost · " + + "\(UsageFormat.tokens(model.totalTokens)) tokens" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + Spacer(minLength: 8) + Text(UsageFormat.usd(model.costUsd)) + .font(T3Typography.threadBody) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + } + .padding(16) + } + } + .usageCard() + } + } + + @ViewBuilder + private var timeBreakdown: some View { + let periods = usagePeriods + if periods.isEmpty { + Text("No activity in this window.") + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity) + .padding(24) + .usageCard() + } else { + LazyVStack(spacing: 0) { + ForEach(Array(periods.enumerated()), id: \.element.id) { index, period in + if index > 0 { usageDivider } + HStack(spacing: 12) { + Text(period.label) + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textPrimary) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(UsageFormat.usd(period.costUsd)) + .font(T3Typography.threadBody) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + Text(UsageFormat.tokens(period.totalTokens)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + .padding(16) + } + } + .usageCard() + } + } + + private var usagePeriods: [UsagePeriodPresentation] { + if windowInput.resolution == .hour { + return merged.hourly.reversed().map { + UsagePeriodPresentation( + id: $0.hourStart, + label: UsageFormat.hourShort($0.hourStart, timeZone: windowInput.timeZone), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + } + return merged.daily.reversed().map { + UsagePeriodPresentation( + id: $0.day, + label: UsageFormat.dayShort($0.day), + costUsd: $0.costUsd, + totalTokens: $0.totalTokens + ) + } + } + + private var usageDivider: some View { + Divider().overlay(T3Colors.separator) + } + + private var hasCompatibleSummary: Bool { + environments.contains { + $0.summary.map { isCompatibleUsageContractVersion($0.contractVersion) } == true + } + } + + private func load() async { + let request = loadState.begin(days: loadState.windowDays) + defer { loadState.finish(request) } + do { + let result = try await client.usageSummaries(request.input) + try Task.checkCancellation() + loadState.receive(result, for: request) + } catch is CancellationError { + return + } catch { + loadState.fail(error, for: request) + } + } +} + +private struct UsagePeriodChart: View { + let input: UsageSummaryInput + let daily: [UsageDailyTotals] + let hourly: [UsageHourlyTotals] + let metric: UsageMetric + + private var segments: [UsageChartSegment] { + if input.resolution == .hour { + let hourlyByStart = Dictionary(uniqueKeysWithValues: hourly.map { ($0.hourStart, $0) }) + return UsageWindow.hours(in: input).flatMap { hourStart in + chartSegments( + period: hourStart, + byProvider: hourlyByStart[hourStart]?.byProvider ?? [:] + ) + } + } + let dailyByDay = Dictionary(uniqueKeysWithValues: daily.map { ($0.day, $0) }) + return UsageWindow.days(in: input).flatMap { day in + chartSegments(period: day, byProvider: dailyByDay[day]?.byProvider ?? [:]) + } + } + + private func chartSegments( + period: String, + byProvider: [UsageProviderKind: UsageProviderValue] + ) -> [UsageChartSegment] { + var start = 0.0 + return UsageProviderKind.allCases.map { provider in + let totals = byProvider[provider] + let value = metric == .cost + ? totals?.costUsd ?? 0 + : Double(totals?.totalTokens ?? 0) + defer { start += value } + return UsageChartSegment( + period: period, + provider: provider, + start: start, + end: start + value + ) + } + } + + var body: some View { + Chart(segments) { segment in + BarMark( + x: .value("Period", segment.period), + yStart: .value("Start", segment.start), + yEnd: .value("End", segment.end) + ) + .foregroundStyle(segment.provider.color) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartLegend(.hidden) + } +} + +private struct UsageChartSegment: Identifiable { + let period: String + let provider: UsageProviderKind + let start: Double + let end: Double + + var id: String { "\(period):\(provider.rawValue)" } +} + +private struct UsagePeriodPresentation: Identifiable { + let id: String + let label: String + let costUsd: Double + let totalTokens: Int +} + +private struct UsageSection: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 14) + content + } + } +} + +private struct UsageMetricCell: View { + let label: String + let value: String + let detail: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text(value) + .font(.title3.weight(.medium)) + .monospacedDigit() + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(detail) + .font(.caption) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(2) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct UsageProgressBar: View { + let value: Double + let color: Color + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(T3Colors.subtle) + Capsule() + .fill(color) + .frame(width: geometry.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 4) + .accessibilityHidden(true) + } +} + +private extension View { + func usageCard() -> some View { + background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 24)) + } +} + +private extension UsageProviderKind { + var color: Color { + switch self { + case .codex: T3Colors.textPrimary + case .claude: Color(red: 0.851, green: 0.467, blue: 0.341) + case .grok: T3Colors.textSecondary + } + } +} + +private enum UsageFormat { + static func usd(_ value: Double) -> String { + value.formatted( + .currency(code: "USD") + .locale(Locale(identifier: "en_US")) + .precision(.fractionLength(2)) + ) + } + + static func count(_ value: Int) -> String { + value.formatted(.number.locale(Locale(identifier: "en_US"))) + } + + static func tokens(_ value: Int) -> String { + let magnitude = abs(Double(value)) + if magnitude >= 1_000_000_000_000 { return compact(Double(value) / 1_000_000_000_000, suffix: "T") } + if magnitude >= 1_000_000_000 { return compact(Double(value) / 1_000_000_000, suffix: "B") } + if magnitude >= 1_000_000 { return compact(Double(value) / 1_000_000, suffix: "M") } + if magnitude >= 1_000 { return compact(Double(value) / 1_000, suffix: "K") } + return count(value) + } + + static func percent(_ value: Double) -> String { + String(format: "%.1f%%", value * 100) + } + + static func dayShort(_ day: String) -> String { + let components = day.split(separator: "-") + guard components.count == 3, + let month = Int(components[1]), + let dayOfMonth = Int(components[2]), + (1...12).contains(month) else { + return day + } + let months = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ] + return "\(months[month - 1]) \(dayOfMonth)" + } + + static func hourShort(_ value: String, timeZone: String) -> String { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = withFractional.date(from: value) + ?? ISO8601DateFormatter().date(from: value) else { + return value + } + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(identifier: timeZone) ?? .current + formatter.setLocalizedDateFormatFromTemplate("EEEha") + return formatter.string(from: date) + } + + private static func compact(_ value: Double, suffix: String) -> String { + let digits = abs(value) >= 100 ? 0 : abs(value) >= 10 ? 1 : 2 + var formatted = String(format: "%.*f", digits, value) + while formatted.hasSuffix("0"), formatted.contains(".") { + formatted.removeLast() + } + if formatted.hasSuffix(".") { formatted.removeLast() } + return formatted + suffix + } +} diff --git a/apps/swift-ios/Features/Workspace/DailyUXModels.swift b/apps/swift-ios/Features/Workspace/DailyUXModels.swift new file mode 100644 index 000000000000..a715405140c2 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/DailyUXModels.swift @@ -0,0 +1,1261 @@ +import Foundation + +public struct FeatureDraftAttachment: Identifiable, Sendable, Equatable { + public let id: UUID + private var inlineData: Data? + public var ownedFile: FeatureOwnedAttachmentFile? + public var thumbnailData: Data? + public var filename: String + public var mimeType: String + public var uploadedReference: FeatureUploadedAttachmentReference? + + public init( + id: UUID = UUID(), + data: Data, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = data + ownedFile = nil + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + public init( + id: UUID = UUID(), + ownedFile: FeatureOwnedAttachmentFile, + thumbnailData: Data? = nil, + filename: String, + mimeType: String, + uploadedReference: FeatureUploadedAttachmentReference? = nil + ) { + self.id = id + inlineData = nil + self.ownedFile = ownedFile + self.thumbnailData = thumbnailData + self.filename = filename + self.mimeType = mimeType + self.uploadedReference = uploadedReference + } + + /// Kept for image-only callers. File-backed attachments return empty data + /// instead of loading up to 50 MB into a UI property. + public var data: Data { + get { inlineData ?? Data() } + set { + inlineData = newValue + ownedFile = nil + } + } + + public var byteCount: Int { + inlineData?.count ?? ownedFile?.byteCount ?? 0 + } +} + +public struct NewTaskRequest: Sendable, Equatable { + public var projectID: String + public var prompt: String + public var selection: FeatureSelection? + public var runtimeMode: FeatureRuntimeMode + public var interactionMode: FeatureInteractionMode + public var workspaceMode: FeatureWorkspaceMode + public var branch: String? + public var worktreePath: String? + public var startFromOrigin: Bool + public var attachments: [FeatureDraftAttachment] + + public init( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode = .fullAccess, + interactionMode: FeatureInteractionMode = .standard, + workspaceMode: FeatureWorkspaceMode = .local, + branch: String? = nil, + worktreePath: String? = nil, + startFromOrigin: Bool = true, + attachments: [FeatureDraftAttachment] = [] + ) { + self.projectID = projectID + self.prompt = prompt + self.selection = selection + self.runtimeMode = runtimeMode + self.interactionMode = interactionMode.mobileNormalized + self.workspaceMode = workspaceMode + self.branch = Self.nonEmpty(branch) + self.worktreePath = workspaceMode == .local ? Self.nonEmpty(worktreePath) : nil + self.startFromOrigin = workspaceMode == .worktree && startFromOrigin + self.attachments = attachments + } + + public var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} + +public struct FeatureMessageSubmission: Sendable, Equatable { + public var threadID: String + public var text: String + public var selection: FeatureSelection? + public var attachments: [FeatureDraftAttachment] + + public init( + threadID: String, + text: String, + selection: FeatureSelection?, + attachments: [FeatureDraftAttachment] = [] + ) { + self.threadID = threadID + self.text = text + self.selection = selection + self.attachments = attachments + } +} + +struct DailyUXSnoozePreset: Identifiable, Equatable { + enum ID: String { + case hour + case threeHours + case evening + case tomorrow + case nextWeek + } + + let id: ID + let label: String + let until: Date +} + +enum DailyUXSnoozePresets { + static func resolve(now: Date, calendar: Calendar = .current) -> [DailyUXSnoozePreset] { + var result = [ + DailyUXSnoozePreset( + id: .hour, + label: "In 1 hour", + until: now.addingTimeInterval(60 * 60) + ), + DailyUXSnoozePreset( + id: .threeHours, + label: "In 3 hours", + until: now.addingTimeInterval(3 * 60 * 60) + ), + ] + + if let evening = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: now), + evening.timeIntervalSince(now) > 60 * 60 { + result.append(.init(id: .evening, label: "This evening", until: evening)) + } + + let tomorrow = calendar.date( + bySettingHour: 9, + minute: 0, + second: 0, + of: calendar.date(byAdding: .day, value: 1, to: now) ?? now + ) + if let tomorrow { + result.append(.init(id: .tomorrow, label: "Tomorrow", until: tomorrow)) + } + + let weekday = calendar.component(.weekday, from: now) + let daysUntilMonday = (2 - weekday + 7) % 7 + let nextMondayOffset = daysUntilMonday == 0 ? 7 : daysUntilMonday + if let monday = calendar.date(byAdding: .day, value: nextMondayOffset, to: now), + let nextWeek = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: monday), + nextWeek != tomorrow { + result.append(.init(id: .nextWeek, label: "Next week", until: nextWeek)) + } + + return result + } +} + +enum DailyUXCreationContext { + static func projects(in snapshot: FeatureSnapshot) -> [FeatureProject] { + guard !snapshot.environments.isEmpty else { return snapshot.projects } + let availableEnvironmentIDs = Set( + snapshot.environments.filter(\.isEnabled).map(\.id) + ) + return snapshot.projects.filter { + availableEnvironmentIDs.contains($0.environmentID) + } + } + + static func projectGroups(in snapshot: FeatureSnapshot) -> [DailyUXProjectGroup] { + return DailyUXProjectGrouping.groups( + projects: projects(in: snapshot), + preferencesByEnvironment: snapshot.preferencesByEnvironment ?? [:] + ) + } + + static func recentProjects(in snapshot: FeatureSnapshot) -> [DailyUXRecentProject] { + let groups = projectGroups(in: snapshot) + let availableProjectByID = projects(in: snapshot).reduce( + into: [String: FeatureProject]() + ) { $0[$1.id] = $1 } + let groupByProjectID = groups.reduce(into: [String: DailyUXProjectGroup]()) { + result, group in + for projectID in group.memberProjectIDs { + result[projectID] = group + } + } + var seenGroupIDs = Set() + + return snapshot.threads + .sorted(by: recentUseOrder) + .compactMap { thread in + guard let group = groupByProjectID[thread.projectID], + let sourceProject = availableProjectByID[thread.projectID], + let project = DailyUXProjectGrouping.physicalRepresentative( + for: sourceProject, + in: group + ), + seenGroupIDs.insert(group.id).inserted else { + return nil + } + return DailyUXRecentProject(group: group, project: project) + } + } + + static func initialProject( + in snapshot: FeatureSnapshot, + requestedProjectID: String? + ) -> FeatureProject? { + let availableProjects = projects(in: snapshot) + if let requestedProjectID, + let requestedProject = availableProjects.first(where: { $0.id == requestedProjectID }), + let group = DailyUXProjectGrouping.group( + containing: requestedProjectID, + in: projectGroups(in: snapshot) + ), + let representative = DailyUXProjectGrouping.physicalRepresentative( + for: requestedProject, + in: group + ) { + return representative + } + + return recentProjects(in: snapshot).first?.project + ?? projectGroups(in: snapshot).first?.projects.first + } + + static func logicalProjectID( + for project: FeatureProject, + in snapshot: FeatureSnapshot + ) -> String { + let groups = DailyUXProjectGrouping.groups( + projects: snapshot.projects, + preferencesByEnvironment: snapshot.preferencesByEnvironment ?? [:] + ) + return DailyUXProjectGrouping.group(containing: project.id, in: groups)?.id + ?? DailyUXProjectGrouping.logicalProjectID( + for: project, + mode: snapshot.preferencesByEnvironment?[project.environmentID]? + .projectGroupingMode ?? .repository, + overrides: snapshot.preferencesByEnvironment?[project.environmentID]? + .projectGroupingOverrides ?? [:] + ) + } + + private static func recentUseOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + let lhsDate = lhs.lastActivityAt ?? lhs.updatedAt + let rhsDate = rhs.lastActivityAt ?? rhs.updatedAt + if lhsDate != rhsDate { return lhsDate > rhsDate } + return lhs.id < rhs.id + } + + static func shouldAdoptAutomaticProject( + currentProjectID: String, + nextRecentProjectID: String?, + isAwaitingRecentActivity: Bool, + projectSelectionIsExplicit: Bool, + modelSelectionIsExplicit: Bool, + workspaceSelectionIsExplicit: Bool, + hasDraftContent: Bool, + draftRestoreIsComplete: Bool + ) -> Bool { + guard isAwaitingRecentActivity, + let nextRecentProjectID, + nextRecentProjectID != currentProjectID else { + return false + } + return !projectSelectionIsExplicit + && !modelSelectionIsExplicit + && !workspaceSelectionIsExplicit + && !hasDraftContent + && draftRestoreIsComplete + } + + static func providers( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> [FeatureProvider] { + if let project, + let providers = snapshot.providersByEnvironment?[project.environmentID] { + return providers + } + guard let project else { return [] } + guard let selection = project.defaultSelection else { return [] } + return [ + FeatureProvider( + id: selection.providerID, + name: selection.providerID, + driver: selection.providerID, + models: [ + FeatureModel( + id: selection.modelID, + name: selection.modelID, + isDefault: true + ), + ] + ), + ] + } + + static func initialSelection( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureSelection? { + let providers = providers(for: project, in: snapshot) + return DailyUXModelOptions.validated(project?.defaultSelection, in: providers) + ?? DailyUXModelOptions.preferredSelection(in: providers) + } + + static func selection( + carrying preferredSelection: FeatureSelection?, + to project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureSelection? { + let providers = providers(for: project, in: snapshot) + return DailyUXModelOptions.validated(preferredSelection, in: providers) + ?? initialSelection(for: project, in: snapshot) + } + + static func environmentPreferences( + for project: FeatureProject?, + in snapshot: FeatureSnapshot + ) -> FeatureEnvironmentPreferences { + guard let environmentID = project?.environmentID else { + return FeatureEnvironmentPreferences() + } + return snapshot.preferencesByEnvironment?[environmentID] + ?? FeatureEnvironmentPreferences() + } +} + +struct DailyUXRecentProject: Equatable { + let group: DailyUXProjectGroup + let project: FeatureProject +} + +/// The project picker leads with the projects the account actually worked in +/// most recently and keeps every remaining project below in the usual +/// alphabetical order. A group appears in exactly one section so the list never +/// repeats itself on the small project counts this picker normally shows. +struct DailyUXProjectPickerSections: Equatable { + static let recentLimit = 3 + + let recents: [DailyUXProjectGroup] + let others: [DailyUXProjectGroup] + + init( + groups: [DailyUXProjectGroup], + recentGroupIDs: [String], + limit: Int = DailyUXProjectPickerSections.recentLimit + ) { + let groupsByID = groups.reduce(into: [String: DailyUXProjectGroup]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + var seenGroupIDs = Set() + var ranked: [DailyUXProjectGroup] = [] + for groupID in recentGroupIDs where ranked.count < max(0, limit) { + guard let group = groupsByID[groupID], + seenGroupIDs.insert(group.id).inserted else { + continue + } + ranked.append(group) + } + + recents = ranked + others = groups.filter { !seenGroupIDs.contains($0.id) } + } +} + +struct DailyUXProjectGroup: Identifiable, Equatable { + let id: String + let name: String + let projects: [FeatureProject] + let memberProjectIDs: Set + + func project(in environmentID: String) -> FeatureProject? { + projects.first { $0.environmentID == environmentID } + } + + func preferredProject(environmentID: String?) -> FeatureProject? { + environmentID.flatMap(project(in:)) ?? projects.first + } +} + +enum DailyUXProjectGrouping { + static func logicalProjectID( + for project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode = .repository, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] = [:] + ) -> String { + logicalKey(project, mode: resolvedMode(project, mode: mode, overrides: overrides)) + } + + static func groups( + projects: [FeatureProject], + mode: FeatureEnvironmentPreferences.ProjectGroupingMode = .repository, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] = [:], + preferencesByEnvironment: [String: FeatureEnvironmentPreferences] = [:] + ) -> [DailyUXProjectGroup] { + var projectsByLogicalKey: [String: [FeatureProject]] = [:] + var memberIDsByLogicalKey: [String: Set] = [:] + for physicalProjects in Dictionary(grouping: projects, by: physicalKey).values { + guard let winner = physicalWinner(physicalProjects) else { continue } + let identitySource = identitySource(projects: physicalProjects, winner: winner) + let preferences = preferencesByEnvironment[winner.environmentID] + let groupingMode = resolvedMode( + winner, + mode: preferences?.projectGroupingMode ?? mode, + overrides: preferences?.projectGroupingOverrides ?? overrides + ) + let key = logicalKey(identitySource, mode: groupingMode) + projectsByLogicalKey[key, default: []].append(winner) + memberIDsByLogicalKey[key, default: []].formUnion(physicalProjects.map(\.id)) + } + + return projectsByLogicalKey + .map { key, members in + let sorted = members.sorted(by: projectOrder) + return DailyUXProjectGroup( + id: key, + name: groupName(projects: sorted), + projects: sorted, + memberProjectIDs: memberIDsByLogicalKey[key] ?? [] + ) + } + .sorted { lhs, rhs in + let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + return comparison == .orderedSame ? lhs.id < rhs.id : comparison == .orderedAscending + } + } + + static func group(containing projectID: String, in groups: [DailyUXProjectGroup]) + -> DailyUXProjectGroup? + { + groups.first { $0.memberProjectIDs.contains(projectID) } + } + + static func selectionTarget( + groupID: String, + preferredEnvironmentID: String?, + in groups: [DailyUXProjectGroup] + ) -> FeatureProject? { + groups.first { $0.id == groupID }? + .preferredProject(environmentID: preferredEnvironmentID) + } + + static func physicalRepresentative( + for project: FeatureProject, + in group: DailyUXProjectGroup + ) -> FeatureProject? { + let path = normalizedPath(project.path) + return group.projects.first { + $0.environmentID == project.environmentID + && normalizedPath($0.path) == path + } + } + + private static func physicalKey(_ project: FeatureProject) -> String { + "\(project.environmentID):\(normalizedPath(project.path))" + } + + private static func logicalKey( + _ project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode + ) -> String { + if mode == .separate { return physicalKey(project) } + guard let key = project.repositoryIdentity?.canonicalKey.trimmingCharacters( + in: .whitespacesAndNewlines + ), !key.isEmpty else { + return physicalKey(project) + } + if mode == .repositoryPath, + let relativePath = repositoryRelativePath(project), + !relativePath.isEmpty { + return "\(key)::\(relativePath)" + } + return key + } + + private static func resolvedMode( + _ project: FeatureProject, + mode: FeatureEnvironmentPreferences.ProjectGroupingMode, + overrides: [String: FeatureEnvironmentPreferences.ProjectGroupingMode] + ) -> FeatureEnvironmentPreferences.ProjectGroupingMode { + overrides[physicalKey(project)] ?? mode + } + + private static func repositoryRelativePath(_ project: FeatureProject) -> String? { + guard let rootPath = project.repositoryIdentity?.rootPath else { return nil } + let projectPath = normalizedPath(project.path) + let repositoryPath = normalizedPath(rootPath) + guard !projectPath.isEmpty, !repositoryPath.isEmpty else { return nil } + if projectPath == repositoryPath { return "" } + let separator = repositoryPath.contains("\\") ? "\\" : "/" + let prefix = repositoryPath + separator + guard projectPath.hasPrefix(prefix) else { return nil } + return String(projectPath.dropFirst(prefix.count)).replacingOccurrences(of: "\\", with: "/") + } + + private static func physicalWinner(_ projects: [FeatureProject]) -> FeatureProject? { + projects.max { lhs, rhs in + let lhsFreshness = freshness(lhs) + let rhsFreshness = freshness(rhs) + if lhsFreshness != rhsFreshness { return lhsFreshness < rhsFreshness } + return lhs.id < rhs.id + } + } + + private static func identitySource( + projects: [FeatureProject], + winner: FeatureProject + ) -> FeatureProject { + guard winner.repositoryIdentity == nil else { return winner } + return physicalWinner(projects.filter { $0.repositoryIdentity != nil }) ?? winner + } + + private static func freshness(_ project: FeatureProject) -> String { + project.updatedAt ?? project.createdAt ?? "" + } + + private static func groupName(projects: [FeatureProject]) -> String { + let displayNames = uniqueNonEmpty(projects.compactMap(\.repositoryIdentity?.displayName)) + if displayNames.count == 1, let name = displayNames.first { return name } + let repositoryNames = uniqueNonEmpty(projects.compactMap(\.repositoryIdentity?.name)) + if repositoryNames.count == 1, let name = repositoryNames.first { return name } + return projects.first?.name ?? "Project" + } + + private static func uniqueNonEmpty(_ values: [String]) -> [String] { + var seen = Set() + return values.compactMap { value in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { return nil } + return trimmed + } + } + + private static func normalizedPath(_ path: String) -> String { + var normalized = path.trimmingCharacters(in: .whitespacesAndNewlines) + let isWindowsPath = normalized.range( + of: #"^[a-zA-Z]:([/\\]|$)"#, + options: .regularExpression + ) != nil || normalized.hasPrefix("\\\\") + let separators = isWindowsPath + ? CharacterSet(charactersIn: "/\\") + : CharacterSet(charactersIn: "/") + while normalized.count > 1, + let scalar = normalized.unicodeScalars.last, + separators.contains(scalar) { + normalized.removeLast() + } + if isWindowsPath { + return normalized.replacingOccurrences(of: "/", with: "\\").lowercased() + } + return normalized + } + + private static func projectOrder(_ lhs: FeatureProject, _ rhs: FeatureProject) -> Bool { + if lhs.environmentID != rhs.environmentID { return lhs.environmentID < rhs.environmentID } + return lhs.id < rhs.id + } +} + +struct DailyUXSidebarIndex { + let pinned: [FeatureThread] + let active: [FeatureThread] + let snoozed: [FeatureThread] + let settled: [FeatureThread] + let searchResults: [FeatureThread] + + var needsInput: [FeatureThread] { + active.filter { + $0.state == .waitingForApproval || $0.state == .waitingForInput + } + } + + var failed: [FeatureThread] { + active.filter { $0.state == .failed } + } + + init( + snapshot: FeatureSnapshot, + query: String, + projectID: String? = nil, + now: Date = .now, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + ) { + let visible = snapshot.threads.filter { thread in + guard !thread.isArchived else { return false } + return projectID == nil || thread.projectID == projectID + } + let available = visible.filter { !$0.isEffectivelySnoozed(at: now) } + + pinned = available + .filter { + $0.pinnedAt != nil + && !($0.supportsSettlement == true && $0.isEffectivelySettled()) + } + .sorted(by: Self.creationOrder) + + active = available + .filter { + $0.pinnedAt == nil + && !($0.supportsSettlement == true && $0.isEffectivelySettled()) + } + .sorted { lhs, rhs in + let leftAnchor = max(lhs.createdAt, lhs.unsettledAt ?? lhs.createdAt) + let rightAnchor = max(rhs.createdAt, rhs.unsettledAt ?? rhs.createdAt) + return leftAnchor == rightAnchor ? lhs.id < rhs.id : leftAnchor > rightAnchor + } + + snoozed = visible + .filter { $0.isEffectivelySnoozed(at: now) } + .sorted { lhs, rhs in + let lhsUntil = lhs.snoozedUntil ?? .distantFuture + let rhsUntil = rhs.snoozedUntil ?? .distantFuture + if lhsUntil != rhsUntil { + return lhsUntil < rhsUntil + } + return lhs.id < rhs.id + } + + settled = available + .filter { + $0.supportsSettlement == true + && $0.isEffectivelySettled() + } + .sorted { lhs, rhs in + if lhs.settledSortDate != rhs.settledSortDate { + return lhs.settledSortDate > rhs.settledSortDate + } + return lhs.id < rhs.id + } + + searchResults = Self.matchingThreads( + pinned + active + snoozed + settled, + snapshot: snapshot, + query: query + ) + } + + private static func creationOrder(_ lhs: FeatureThread, _ rhs: FeatureThread) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt > rhs.createdAt + } + return lhs.id < rhs.id + } + + static func matchingThreads( + _ candidates: [FeatureThread], + snapshot: FeatureSnapshot, + query: String + ) -> [FeatureThread] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedQuery.isEmpty else { return [] } + // Aggregate snapshots can include legacy fixtures with duplicate raw IDs. + // Native projects are environment-scoped, while this defensive reduce + // keeps search non-crashing for older callers during migration. + let projectByID = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $1 + } + return candidates.filter { thread in + let project = projectByID[thread.projectID] + return [ + thread.title, + thread.preview ?? "", + project?.name ?? "", + project?.path ?? "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + } +} + +/// The Home list only needs a parent-level refresh when a thread crosses a shelf boundary. +/// Working timers and relative ages are rendered by each visible row instead. +enum DailyUXSidebarRefresh { + static func nextBoundary( + for threads: [FeatureThread], + after now: Date, + settings _: FeatureSettings = .init(), + pullRequestsByThreadID _: [String: HomeThreadPullRequestPresentation] = [:] + ) -> Date? { + threads.reduce(nil as Date?) { earliest, thread in + let snoozeBoundary = thread.isEffectivelySnoozed(at: now) + ? thread.snoozedUntil + : nil + let queuedBoundary = thread.isArchived + ? nil + : thread.queuedSettlementBoundary(after: now) + let threadBoundary = [snoozeBoundary, queuedBoundary] + .compactMap { $0 } + .min() + + guard let threadBoundary else { return earliest } + return min(earliest ?? threadBoundary, threadBoundary) + } + } +} + +enum SidebarRelativeAge { + static func compact(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + switch seconds { + case ..<60: + return "now" + case ..<3_600: + return "\(seconds / 60)m" + case ..<86_400: + return "\(seconds / 3_600)h" + case ..<604_800: + return "\(seconds / 86_400)d" + case ..<31_536_000: + return "\(seconds / 604_800)w" + default: + return "\(seconds / 31_536_000)y" + } + } + + static func accessibility(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + switch seconds { + case ..<60: + return "Updated just now" + case ..<3_600: + return "Updated \(unit(seconds / 60, singular: "minute")) ago" + case ..<86_400: + return "Updated \(unit(seconds / 3_600, singular: "hour")) ago" + case ..<604_800: + return "Updated \(unit(seconds / 86_400, singular: "day")) ago" + case ..<31_536_000: + return "Updated \(unit(seconds / 604_800, singular: "week")) ago" + default: + return "Updated \(unit(seconds / 31_536_000, singular: "year")) ago" + } + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + +enum HomeThreadStatus: String, Sendable, Equatable { + case approval + case input + case working + case monitoring + case failed + case done + case ready +} + +enum HomeWorkingDuration { + static func compact(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + guard seconds >= 60 else { return "\(seconds)s" } + let minutes = seconds / 60 + guard minutes >= 60 else { return "\(minutes)m" } + return "\(minutes / 60)h \(minutes % 60)m" + } + + static func accessibility(since date: Date, now: Date) -> String { + let seconds = max(0, Int(now.timeIntervalSince(date))) + guard seconds >= 60 else { return unit(seconds, singular: "second") } + let minutes = seconds / 60 + guard minutes >= 60 else { return unit(minutes, singular: "minute") } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + guard remainingMinutes > 0 else { return unit(hours, singular: "hour") } + return "\(unit(hours, singular: "hour")), \(unit(remainingMinutes, singular: "minute"))" + } + + private static func unit(_ value: Int, singular: String) -> String { + "\(value) \(singular)\(value == 1 ? "" : "s")" + } +} + +extension FeatureThread { + var homeStatus: HomeThreadStatus { + switch state { + case .queued, .working: + .working + case .monitoring: + .monitoring + case .waitingForApproval: + .approval + case .waitingForInput: + .input + case .failed: + .failed + case .completed: + .done + case .idle: + .ready + } + } + + var homeStatusLabel: String? { + switch homeStatus { + case .approval: "Approval" + case .input: "Input" + case .working: "Working" + case .monitoring: "Monitoring" + case .failed: "Failed" + case .done: "Done" + case .ready: nil + } + } + + var detailHeaderStatusLabel: String? { + switch homeStatus { + case .done: + nil + case .ready: + "Ready" + case .approval, .input, .working, .monitoring, .failed: + homeStatusLabel + } + } + + var detailHeaderStatusIcon: String? { + switch homeStatus { + case .working: + "circle.dotted" + case .failed: + "exclamationmark.circle" + case .done, .approval, .input, .monitoring, .ready: + nil + } + } + + func homeRowStatusLabel(at now: Date) -> String { + switch homeStatus { + case .done, .ready: + SidebarRelativeAge.compact(since: updatedAt, now: now) + case .approval, .input, .working, .monitoring, .failed: + homeStatusLabel ?? SidebarRelativeAge.compact(since: updatedAt, now: now) + } + } + + func homeWorkingDuration(at now: Date) -> String? { + guard homeStatus == .working, let workingStartedAt else { return nil } + return HomeWorkingDuration.compact(since: workingStartedAt, now: now) + } + + var hasLiveWorkingDuration: Bool { + homeStatus == .working && workingStartedAt != nil + } + + func homeStatusAccessibilityLabel(at now: Date) -> String { + guard homeStatus == .working else { + return homeStatusLabel ?? "Ready" + } + guard let workingStartedAt else { + return "Agent is working" + } + return "Agent is working for \(HomeWorkingDuration.accessibility(since: workingStartedAt, now: now))" + } + + func homeEnvironmentLabel(in snapshot: FeatureSnapshot) -> String? { + let projectEnvironmentID = snapshot.projects + .first(where: { $0.id == projectID })? + .environmentID + if let resolvedID = environmentID ?? projectEnvironmentID, + let currentName = snapshot.environments.first(where: { $0.id == resolvedID })?.name, + !currentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return currentName + } + guard let environmentName = environmentName? + .trimmingCharacters(in: .whitespacesAndNewlines), + !environmentName.isEmpty else { + return nil + } + return environmentName + } + + func homeProviderLabel(in snapshot: FeatureSnapshot) -> String? { + if let providerName = providerName?.trimmingCharacters(in: .whitespacesAndNewlines), + !providerName.isEmpty { + return providerName + } + guard let providerID else { return nil } + let projectEnvironmentID = snapshot.projects + .first(where: { $0.id == projectID })? + .environmentID + let resolvedEnvironmentID = environmentID ?? projectEnvironmentID + let providers = resolvedEnvironmentID.flatMap { + snapshot.providersByEnvironment?[$0] + } ?? [] + return providers.first(where: { $0.id == providerID })?.name ?? providerID + } + + var needsAttention: Bool { + state == .waitingForApproval || state == .waitingForInput || state == .failed + } + + func isEffectivelySettled() -> Bool { + effectiveSettlementOverride == .settled + } + + func canSettleNow(at now: Date = .now) -> Bool { + guard canToggleSettlement else { return false } + return !hasSettlementActivityBlock(at: now) + } + + var effectiveSettlementOverride: FeatureThreadSettlementOverride? { + if let settlementFacts { return settlementFacts.settlementOverride } + if keepsActive { return .active } + if isSettled { return .settled } + return nil + } + + func hasSettlementActivityBlock(at now: Date) -> Bool { + guard settlementFacts != nil else { + return [.queued, .working, .monitoring, .waitingForApproval, .waitingForInput] + .contains(state) + } + if hasHardSettlementActivityBlock { return true } + return hasQueuedTurnStart(at: now) + } + + var hasHardSettlementActivityBlock: Bool { + guard let facts = settlementFacts else { + return [ + .queued, + .working, + .monitoring, + .waitingForApproval, + .waitingForInput, + ].contains(state) + } + return facts.hasPendingApprovals + || facts.hasPendingUserInput + || facts.sessionStatus == "starting" + || facts.sessionStatus == "running" + } + + func hasQueuedTurnStart(at now: Date) -> Bool { + guard let facts = settlementFacts, + facts.sessionStatus != "error", + let messageAt = facts.latestUserMessageAt, + abs(now.timeIntervalSince(messageAt)) <= 2 * 60 else { + return false + } + guard let turn = facts.latestTurn else { return true } + if turn.requestedAtIsInvalid || turn.startedAtIsInvalid || turn.completedAtIsInvalid { + return false + } + return [turn.requestedAt, turn.startedAt, turn.completedAt].allSatisfy { + $0 == nil || $0! < messageAt + } + } + + func queuedSettlementBoundary(after now: Date) -> Date? { + guard hasQueuedTurnStart(at: now), + let messageAt = settlementFacts?.latestUserMessageAt else { + return nil + } + let boundary = messageAt.addingTimeInterval(2 * 60 + 0.001) + return boundary > now ? boundary : nil + } + + func isEffectivelySnoozed(at now: Date) -> Bool { + guard let snoozedUntil, snoozedUntil > now else { return false } + if state == .waitingForApproval || state == .waitingForInput { + return false + } + if state == .failed, + let snoozedAt, + let attentionAt, + attentionAt > snoozedAt { + return false + } + if let snoozedAt, + let latestTurnCompletedAt, + latestTurnCompletedAt > snoozedAt { + return false + } + return true + } + + var settledSortDate: Date { + settledAt ?? lastActivityAt ?? updatedAt + } +} + +struct DailyUXModelOption: Identifiable, Equatable, Hashable { + let provider: FeatureProvider + let model: FeatureModel + + var id: String { Self.key(providerID: provider.id, modelID: model.id) } + + static func key(providerID: String, modelID: String) -> String { + "\(providerID)::\(modelID)" + } +} + +struct DailyUXModelCatalog { + let all: [DailyUXModelOption] + let favorites: [DailyUXModelOption] + let recents: [DailyUXModelOption] + let providerGroups: [(provider: FeatureProvider, models: [DailyUXModelOption])] + + init( + providers: [FeatureProvider], + query: String, + favoriteIDs: Set, + recentIDs: [String] + ) { + let available = providers.filter(\.isAvailable) + let rawOptions = available.flatMap { provider in + provider.models.map { DailyUXModelOption(provider: provider, model: $0) } + } + var seenOptionIDs = Set() + let unfiltered = rawOptions.filter { seenOptionIDs.insert($0.id).inserted } + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let matches = normalizedQuery.isEmpty + ? unfiltered + : unfiltered.filter { option in + [ + option.provider.name, + option.model.name, + option.model.id, + option.model.detail ?? "", + option.model.supportsImages ? "images vision" : "", + ].contains { $0.localizedCaseInsensitiveContains(normalizedQuery) } + } + + all = matches + favorites = matches.filter { favoriteIDs.contains($0.id) } + + // Provider catalogs can repeat an ID (see matchingThreads above); keep + // the first occurrence instead of trapping on duplicate keys. + let byID = matches.reduce(into: [String: DailyUXModelOption]()) { + $0[$1.id] = $0[$1.id] ?? $1 + } + recents = recentIDs.compactMap { byID[$0] }.filter { !favoriteIDs.contains($0.id) } + + var seenProviderIDs = Set() + let uniqueProviders = available.filter { seenProviderIDs.insert($0.id).inserted } + providerGroups = uniqueProviders.compactMap { provider in + let options = matches.filter { $0.provider.id == provider.id } + return options.isEmpty ? nil : (provider, options) + } + } +} + +enum DailyUXModelOptions { + static func reasoningDescriptor( + for model: FeatureModel + ) -> FeatureModelOptionDescriptor? { + model.options.first(where: isReasoningDescriptor) + } + + static func advancedDescriptors( + for model: FeatureModel + ) -> [FeatureModelOptionDescriptor] { + model.options.filter { !isReasoningDescriptor($0) } + } + + static func undescribedSelections( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> [FeatureModelOptionSelection] { + let describedIDs = Set(model.options.map(\.id)) + return selections.filter { !describedIDs.contains($0.id) } + } + + static func isSupportedValue( + _ value: FeatureModelOptionValue, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + switch (descriptor.kind, value) { + case let (.select, .string(choiceID)): + return descriptor.choices.contains { $0.id == choiceID } + case (.boolean, .boolean): + return true + case (.select, .boolean), (.boolean, .string): + return false + } + } + + static func initialSelection( + projectDefault: FeatureSelection?, + appDefault: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + validated(projectDefault, in: providers) + ?? validated(appDefault, in: providers) + ?? preferredSelection(in: providers) + } + + static func validated( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard let selection, + let provider = providers.first(where: { + $0.id == selection.providerID && $0.isAvailable + }), + provider.models.contains(where: { $0.id == selection.modelID }) else { + return nil + } + return selection + } + + static func preferredSelection(in providers: [FeatureProvider]) -> FeatureSelection? { + let available = providers.filter(\.isAvailable) + let preferred = available.lazy.compactMap { provider in + provider.models.first(where: \.isDefault).map { (provider, $0) } + }.first + ?? available.first.flatMap { provider in + provider.models.first.map { (provider, $0) } + } + guard let (provider, model) = preferred else { return nil } + return FeatureSelection( + providerID: provider.id, + modelID: model.id, + options: defaults(for: model) + ) + } + + static func defaults(for model: FeatureModel) -> [FeatureModelOptionSelection] { + model.options.compactMap { descriptor in + if let defaultValue = descriptor.defaultValue { + return FeatureModelOptionSelection(id: descriptor.id, value: defaultValue) + } + switch descriptor.kind { + case .select: + guard let choice = descriptor.choices.first(where: \.isDefault) + ?? descriptor.choices.first else { + return nil + } + return FeatureModelOptionSelection(id: descriptor.id, value: .string(choice.id)) + case .boolean: + return FeatureModelOptionSelection(id: descriptor.id, value: .boolean(false)) + } + } + } + + static func value( + for descriptor: FeatureModelOptionDescriptor, + in selections: [FeatureModelOptionSelection] + ) -> FeatureModelOptionValue? { + if let selected = selections.first(where: { $0.id == descriptor.id })?.value { + return selected + } + if let defaultValue = descriptor.defaultValue { + return defaultValue + } + switch descriptor.kind { + case .select: + let choice = descriptor.choices.first(where: \.isDefault) + ?? descriptor.choices.first + return choice.map { .string($0.id) } + case .boolean: + return .boolean(false) + } + } + + static func updating( + _ selections: [FeatureModelOptionSelection], + id: String, + value: FeatureModelOptionValue + ) -> [FeatureModelOptionSelection] { + var next = selections.filter { $0.id != id } + next.append(FeatureModelOptionSelection(id: id, value: value)) + return next + } + + static func summary( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> String? { + let labels = model.options.compactMap { descriptor -> String? in + guard let value = value(for: descriptor, in: selections) else { return nil } + switch value { + case let .string(choiceID): + return descriptor.choices.first(where: { $0.id == choiceID })?.label + ?? choiceID + case let .boolean(isEnabled): + return isEnabled ? descriptor.label : nil + } + } + return labels.isEmpty ? nil : labels.joined(separator: " · ") + } + + /// The compact composer gives reasoning its own non-compressible label so + /// a long model name cannot hide the setting users change most often. + static func reasoningSummary( + for model: FeatureModel, + selections: [FeatureModelOptionSelection] + ) -> String? { + guard let descriptor = reasoningDescriptor(for: model), + let value = value(for: descriptor, in: selections) else { + return nil + } + + switch value { + case let .string(choiceID): + return descriptor.choices.first(where: { $0.id == choiceID })?.label + ?? choiceID + case let .boolean(isEnabled): + return isEnabled ? descriptor.label : nil + } + } + + private static func isReasoningDescriptor( + _ descriptor: FeatureModelOptionDescriptor + ) -> Bool { + let searchable = "\(descriptor.id) \(descriptor.label)".lowercased() + return searchable.contains("reason") + || searchable.contains("effort") + || searchable.contains("thinking") + || searchable.contains("thought") + } + + static func supportsImages( + selection: FeatureSelection?, + providers: [FeatureProvider] + ) -> Bool { + // Older environments do not advertise image capability. In that case the + // server remains the source of truth instead of hiding attachments entirely. + guard providers.lazy.flatMap(\.models).contains(where: \.supportsImages) else { + return true + } + guard let selection, + let provider = providers.first(where: { $0.id == selection.providerID }), + let model = provider.models.first(where: { $0.id == selection.modelID }) else { + return true + } + return model.supportsImages + } +} diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift new file mode 100644 index 000000000000..0d38354cd2ec --- /dev/null +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -0,0 +1,1088 @@ +import SwiftUI +import UIKit + +/// A recycled, diffable Home surface. SwiftUI still owns the surrounding shell, +/// while UIKit keeps row creation and updates proportional to visible threads. +struct HomeThreadCollectionView: UIViewRepresentable { + let presentation: HomePresentation + let projectFaviconClient: any FeatureClient + let query: String + let selectedThreadID: String? + let forceRichRows: Bool + let hapticsEnabled: Bool + let settings: FeatureSettings + let pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] + let isSnoozedExpanded: Bool + let isSettledExpanded: Bool + let isArchiveExpanded: Bool + let settledLimit: Int + let onOpen: (String) -> Void + let onToggleSnoozed: () -> Void + let onToggleSettled: () -> Void + let onToggleArchive: () -> Void + let onShowMoreSettled: () -> Void + let onRename: (FeatureThread) -> Void + let onRegenerateTitle: (FeatureThread) -> Void + let onArchive: (FeatureThread, Bool) -> Void + let onSettle: (FeatureThread, Bool, @escaping (Bool) -> Void) -> Void + let onSnooze: (FeatureThread, Date?) -> Void + let onPin: (FeatureThread, Bool) -> Void + let onDelete: (FeatureThread) -> Void + let onPullRequestChange: (String, String, HomeThreadPullRequestPresentation?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UICollectionView { + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.backgroundColor = T3Colors.uiBackground + configuration.showsSeparators = false + configuration.headerMode = .none + configuration.footerMode = .none + configuration.trailingSwipeActionsConfigurationProvider = { [weak coordinator = context.coordinator] indexPath in + coordinator?.trailingSwipeActions(at: indexPath) + } + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout.list(using: configuration) + ) + collectionView.backgroundColor = T3Colors.uiBackground + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .interactive + collectionView.contentInset = UIEdgeInsets(top: 4, left: 0, bottom: 74, right: 0) + collectionView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 4, left: 0, bottom: 74, right: 0) + collectionView.delegate = context.coordinator + context.coordinator.configure(collectionView) + return collectionView + } + + func updateUIView(_ collectionView: UICollectionView, context: Context) { + context.coordinator.update(parent: self, collectionView: collectionView) + } + + static func dismantleUIView(_ collectionView: UICollectionView, coordinator: Coordinator) { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + collectionView.delegate = nil + } + + @MainActor + final class Coordinator: NSObject, UICollectionViewDelegate { + private enum Section: Hashable { + case main + } + + private struct PendingSwipeCompletion { + let id: UUID + let settled: Bool + let finish: (Bool) -> Void + } + + private var parent: HomeThreadCollectionView + private var dataSource: UICollectionViewDiffableDataSource? + private var registration: UICollectionView.CellRegistration? + private var itemsByID: [HomeCollectionItem.ID: HomeCollectionItem] = [:] + private var pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + private var selectedThreadID: String? + private weak var collectionView: UICollectionView? + private var timer: Timer? + private var timerTick = 0 + private var timerInterval: TimeInterval = 0 + private var pendingSwipeCompletions: [String: PendingSwipeCompletion] = [:] + + init(parent: HomeThreadCollectionView) { + self.parent = parent + selectedThreadID = parent.selectedThreadID + } + + func configure(_ collectionView: UICollectionView) { + self.collectionView = collectionView + + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, identifier in + self?.configure(cell, identifier: identifier, now: .now) + } + self.registration = registration + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, identifier in + guard let self, let registration = self.registration else { return nil } + return collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: identifier + ) + } + + update(parent: parent, collectionView: collectionView) + } + + func update(parent: HomeThreadCollectionView, collectionView: UICollectionView) { + let previousItems = itemsByID + let previousSelection = selectedThreadID + self.parent = parent + selectedThreadID = parent.selectedThreadID + + var seenIdentifiers = Set() + let items = parent.collectionItems.filter { item in + seenIdentifiers.insert(item.id).inserted + } + itemsByID = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + pullRequestsByThreadID = pullRequestsByThreadID.filter { + itemsByID[.thread($0.key)] != nil + } + // After items land: picks 1 Hz when a working thread is present, + // 60s otherwise, and is a no-op when the interval is unchanged. + startTimer() + + guard let dataSource else { return } + let currentIdentifiers = dataSource.snapshot().itemIdentifiers + let newIdentifiers = items.map(\.id) + let resolvedSwipeIDs = pendingSwipeCompletions.keys.filter { threadID in + guard let pending = pendingSwipeCompletions[threadID] else { return false } + guard case let .thread(thread, _, _, _, _) = itemsByID[.thread(threadID)] else { + return true + } + return thread.isSettled == pending.settled + } + let resolvedSwipeCompletions = resolvedSwipeIDs.compactMap { + pendingSwipeCompletions.removeValue(forKey: $0) + } + let finishSwipes = { + resolvedSwipeCompletions.forEach { $0.finish(true) } + } + + if currentIdentifiers == newIdentifiers { + let changed = newIdentifiers.filter { previousItems[$0] != itemsByID[$0] } + let selectionChanged = (previousSelection != selectedThreadID + ? [previousSelection, selectedThreadID] : []) + .compactMap { $0.map(HomeCollectionItem.ID.thread) } + .filter { newIdentifiers.contains($0) } + let identifiers = Array(Set(changed + selectionChanged)) + if !identifiers.isEmpty { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems(identifiers) + dataSource.apply( + snapshot, + animatingDifferences: false, + completion: finishSwipes + ) + } else { + finishSwipes() + } + } else { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newIdentifiers, toSection: .main) + // Retained rows also need fresh content when another row moves, + // arrives, or leaves in the same update. + let retained = Set(currentIdentifiers) + let selectionChanged = previousSelection != selectedThreadID + ? Set([previousSelection, selectedThreadID].compactMap { $0 }) : [] + snapshot.reconfigureItems(newIdentifiers.filter { identifier in + retained.contains(identifier) + && (previousItems[identifier] != itemsByID[identifier] + || identifier.threadID.map(selectionChanged.contains) == true) + }) + let shouldAnimate = !resolvedSwipeCompletions.isEmpty + && !currentIdentifiers.isEmpty + && collectionView.window != nil + dataSource.apply( + snapshot, + animatingDifferences: shouldAnimate, + completion: { [weak self, weak collectionView] in + if let collectionView { + self?.synchronizeSelection(in: collectionView) + } + finishSwipes() + } + ) + } + + synchronizeSelection(in: collectionView) + } + + func invalidateTimer() { + timer?.invalidate() + timer = nil + } + + func cancelPendingSwipeActions() { + pendingSwipeCompletions.values.forEach { $0.finish(false) } + pendingSwipeCompletions.removeAll() + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = item(at: indexPath) else { return } + switch item { + case let .thread(thread, _, _, _, _): + let previousSelection = selectedThreadID + selectedThreadID = thread.id + parent.onOpen(thread.id) + refreshSelection( + in: collectionView, + ids: [previousSelection, thread.id].compactMap { $0 } + ) + case let .shelfHeader(shelf, _, _): + collectionView.deselectItem(at: indexPath, animated: false) + toggle(shelf) + case .showMoreSettled: + collectionView.deselectItem(at: indexPath, animated: false) + parent.onShowMoreSettled() + case .empty, .searchEmpty, .pinnedDivider: + collectionView.deselectItem(at: indexPath, animated: false) + } + } + + func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard case let .thread(thread, _, _, isArchived, _) = item(at: indexPath) else { + return nil + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return nil } + return UIMenu(children: self.menuActions(for: thread, isArchived: isArchived)) + } + } + + func trailingSwipeActions(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard case let .thread(thread, _, _, isArchived, _) = item(at: indexPath) else { + return nil + } + + let actions = HomeThreadSwipeAction + .trailingActions( + for: thread, + isArchived: isArchived, + at: .now + ) + let configuration = UISwipeActionsConfiguration( + actions: actions.map { contextualAction($0, for: thread) } + ) + // A full swipe runs the edge action, which is only ever settlement. + // Delete can never reach that slot, so the gesture cannot destroy a + // thread; rows with nothing to settle keep the full swipe disabled. + configuration.performsFirstActionWithFullSwipe = + HomeThreadSwipeAction.performsFullSwipe(with: actions) + return configuration + } + + private func contextualAction( + _ action: HomeThreadSwipeAction, + for thread: FeatureThread + ) -> UIContextualAction { + let contextualAction = UIContextualAction( + style: action.style, + title: action.title + ) { [weak self] _, _, finish in + guard let self else { + finish(false) + return + } + self.performSwipe(action, for: thread, finish: finish) + } + contextualAction.image = UIImage(systemName: action.systemImage) + if let backgroundColor = action.backgroundColor { + contextualAction.backgroundColor = backgroundColor + } + return contextualAction + } + + func performSwipe( + _ action: HomeThreadSwipeAction, + for thread: FeatureThread, + finish: @escaping (Bool) -> Void + ) { + if case let .setSettled(settled) = action.intent { + pendingSwipeCompletions.removeValue(forKey: thread.id)?.finish(false) + let completionID = UUID() + pendingSwipeCompletions[thread.id] = PendingSwipeCompletion( + id: completionID, + settled: settled, + finish: finish + ) + PlatformHapticEngine.shared.selection(enabled: parent.hapticsEnabled) + parent.onSettle(thread, settled) { [weak self] succeeded in + guard !succeeded, + let self, + self.pendingSwipeCompletions[thread.id]?.id == completionID else { + return + } + self.pendingSwipeCompletions.removeValue(forKey: thread.id)?.finish(false) + } + } else { + perform(action.intent, for: thread) + finish(true) + } + } + + /// Swipe actions reuse the same closures the context menu does, so a + /// settle from either surface takes the one real settlement path. + private func perform(_ intent: HomeThreadSwipeAction.Intent, for thread: FeatureThread) { + switch intent { + case .delete: + parent.onDelete(thread) + case let .setArchived(archived): + parent.onArchive(thread, archived) + case let .setPinned(pinned): + parent.onPin(thread, pinned) + case let .setSettled(settled): + parent.onSettle(thread, settled) { _ in } + } + } + + private func configure( + _ cell: HomeCollectionCell, + identifier: HomeCollectionItem.ID, + now: Date + ) { + guard let item = itemsByID[identifier] else { return } + let pullRequestObservationIdentity: String? + if case let .thread(thread, _, _, _, _) = item { + pullRequestObservationIdentity = thread.pullRequestObservationIdentity + } else { + pullRequestObservationIdentity = nil + } + cell.contentConfiguration = UIHostingConfiguration { + HomeCollectionCellContent( + item: item, + projectFaviconClient: parent.projectFaviconClient, + isSelected: identifier.threadID == selectedThreadID, + now: now, + onPullRequestChange: { [weak self, weak cell] pullRequest in + guard let self, + let cell, + let threadID = identifier.threadID else { + return + } + self.updatePullRequestAccessibility( + pullRequest, + threadID: threadID, + cell: cell + ) + if let observationIdentity = pullRequestObservationIdentity { + self.parent.onPullRequestChange( + threadID, + observationIdentity, + pullRequest + ) + } + } + ) + } + .margins(.all, 0) + + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + cell.accessories = [] + cell.tintColor = T3Colors.uiTextPrimary + cell.clipsToBounds = true + cell.contentView.clipsToBounds = true + cell.contentView.accessibilityElementsHidden = true + configureAccessibility(cell, item: item) + } + + private func configureAccessibility(_ cell: HomeCollectionCell, item: HomeCollectionItem) { + cell.accessibilityCustomActions = nil + switch item { + case let .thread(thread, context, _, isArchived, _): + cell.isAccessibilityElement = true + cell.accessibilityTraits = selectedThreadID == thread.id + ? [.button, .selected] + : .button + cell.accessibilityLabel = thread.title + cell.accessibilityValue = threadAccessibilityValue(thread, context: context) + cell.accessibilityHint = "Opens thread. More actions are available." + cell.accessibilityCustomActions = threadAccessibilityActions( + for: thread, + isArchived: isArchived + ) + cell.onAccessibilityActivate = { [weak self] in + guard let self else { return } + let previousSelection = self.selectedThreadID + self.selectedThreadID = thread.id + self.parent.onOpen(thread.id) + if let collectionView = self.collectionView { + self.refreshSelection( + in: collectionView, + ids: [previousSelection, thread.id].compactMap { $0 } + ) + } + } + case let .shelfHeader(shelf, count, isExpanded): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .button + cell.accessibilityLabel = "\(shelf.title), \(count) \(count == 1 ? "task" : "tasks")" + cell.accessibilityValue = isExpanded ? "Expanded" : "Collapsed" + cell.accessibilityHint = isExpanded ? "Collapses the task list" : "Expands the task list" + cell.onAccessibilityActivate = { [weak self] in self?.toggle(shelf) } + case let .showMoreSettled(remaining): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .button + cell.accessibilityLabel = "Show \(remaining) more settled \(remaining == 1 ? "task" : "tasks")" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = { [weak self] in + self?.parent.onShowMoreSettled() + } + case let .empty(shelf): + cell.isAccessibilityElement = true + cell.accessibilityTraits = .staticText + cell.accessibilityLabel = shelf == .active ? "No active tasks" : "No \(shelf.title.lowercased()) tasks" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = nil + case .searchEmpty: + cell.isAccessibilityElement = true + cell.accessibilityTraits = .staticText + cell.accessibilityLabel = "No matching tasks" + cell.accessibilityValue = nil + cell.accessibilityHint = nil + cell.onAccessibilityActivate = nil + case .pinnedDivider: + cell.isAccessibilityElement = false + cell.onAccessibilityActivate = nil + } + } + + private func threadAccessibilityValue( + _ thread: FeatureThread, + context: HomeThreadRowContext + ) -> String { + var status = thread.homeStatusLabel ?? "Ready" + if let duration = thread.homeWorkingDuration(at: .now) { + status += " for \(duration)" + } + var values = [status, "Project \(context.projectName)"] + if let pullRequest = pullRequestsByThreadID[thread.id] { + values.append(pullRequest.accessibilityLabel) + } + if thread.pinnedAt != nil { + values.append("Pinned") + } + if thread.isArchived { + values.append("Archived") + } else if thread.isEffectivelySnoozed(at: .now) { + values.append("Snoozed") + } else if thread.isEffectivelySettled() { + values.append("Settled") + } + values.append("Provider \(context.providerName)") + if let environment = context.environmentLabel { + values.append("on \(environment)") + } + return values.joined(separator: ". ") + } + + private func updatePullRequestAccessibility( + _ pullRequest: HomeThreadPullRequestPresentation?, + threadID: String, + cell: HomeCollectionCell + ) { + guard case let .thread(thread, context, _, _, _) = itemsByID[.thread(threadID)], + let indexPath = dataSource?.indexPath(for: .thread(threadID)), + collectionView?.cellForItem(at: indexPath) === cell else { + return + } + if let pullRequest { + pullRequestsByThreadID[threadID] = pullRequest + } else { + pullRequestsByThreadID.removeValue(forKey: threadID) + } + cell.accessibilityValue = threadAccessibilityValue(thread, context: context) + } + + private func threadAccessibilityActions( + for thread: FeatureThread, + isArchived: Bool + ) -> [UIAccessibilityCustomAction] { + var actions = [accessibilityAction("Rename", systemImage: "pencil") { coordinator in + coordinator.parent.onRename(thread) + }] + + if thread.supportsTitleRegeneration == true { + actions.append(accessibilityAction("Regenerate title", systemImage: "sparkles") { coordinator in + coordinator.parent.onRegenerateTitle(thread) + }) + } + + if !isArchived { + if thread.canTogglePin { + let isPinned = thread.pinnedAt != nil + actions.append(accessibilityAction( + isPinned ? "Unpin" : "Pin", + systemImage: isPinned ? "pin.slash" : "pin" + ) { coordinator in + coordinator.parent.onPin(thread, !isPinned) + }) + } + + let isSettled = thread.isEffectivelySettled() + if isSettled || thread.canSettleNow() { + actions.append(accessibilityAction( + isSettled ? "Reopen" : "Settle", + systemImage: isSettled ? "arrow.counterclockwise" : "checkmark" + ) { coordinator in + coordinator.parent.onSettle(thread, !isSettled) { _ in } + }) + } + + if thread.canToggleSnooze { + if thread.isEffectivelySnoozed(at: .now) { + actions.append(accessibilityAction("Wake", systemImage: "bell") { coordinator in + coordinator.parent.onSnooze(thread, nil) + }) + } else if thread.state != .queued, + thread.state != .waitingForApproval, + thread.state != .waitingForInput { + actions.append(contentsOf: DailyUXSnoozePresets.resolve(now: .now).map { preset in + accessibilityAction("Snooze: \(preset.label)", systemImage: "clock") { coordinator in + coordinator.parent.onSnooze(thread, preset.until) + } + }) + } + } + } + + actions.append(accessibilityAction( + isArchived ? "Restore" : "Archive", + systemImage: isArchived ? "arrow.uturn.backward" : "archivebox" + ) { coordinator in + coordinator.parent.onArchive(thread, !isArchived) + }) + actions.append(accessibilityAction("Delete thread", systemImage: "trash") { coordinator in + coordinator.parent.onDelete(thread) + }) + return actions + } + + private func accessibilityAction( + _ title: String, + systemImage: String, + perform: @escaping (Coordinator) -> Void + ) -> UIAccessibilityCustomAction { + UIAccessibilityCustomAction(name: title, image: UIImage(systemName: systemImage)) { [weak self] _ in + guard let self else { return false } + perform(self) + return true + } + } + + private func synchronizeSelection(in collectionView: UICollectionView) { + for indexPath in collectionView.indexPathsForSelectedItems ?? [] { + guard dataSource?.itemIdentifier(for: indexPath)?.threadID != selectedThreadID else { + continue + } + collectionView.deselectItem(at: indexPath, animated: false) + } + guard let selectedThreadID, + let indexPath = dataSource?.indexPath(for: .thread(selectedThreadID)), + !collectionView.indexPathsForSelectedItems.orEmpty.contains(indexPath) else { + return + } + collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) + } + + private func refreshSelection(in collectionView: UICollectionView, ids: [String]) { + for id in ids { + guard let indexPath = dataSource?.indexPath(for: .thread(id)), + let cell = collectionView.cellForItem(at: indexPath) as? HomeCollectionCell else { + continue + } + configure(cell, identifier: .thread(id), now: .now) + } + } + + private func item(at indexPath: IndexPath) -> HomeCollectionItem? { + guard let identifier = dataSource?.itemIdentifier(for: indexPath) else { return nil } + return itemsByID[identifier] + } + + private func toggle(_ shelf: HomeShelf) { + switch shelf { + case .snoozed: parent.onToggleSnoozed() + case .settled: parent.onToggleSettled() + case .archived: parent.onToggleArchive() + case .active: break + } + } + + private func menuActions(for thread: FeatureThread, isArchived: Bool) -> [UIMenuElement] { + let rename = UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in + self?.parent.onRename(thread) + } + + var titleActions: [UIMenuElement] = [rename] + if thread.supportsTitleRegeneration == true { + titleActions.append( + UIAction( + title: "Regenerate title", + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.parent.onRegenerateTitle(thread) + } + ) + } + + var statusActions: [UIMenuElement] = [] + if !isArchived { + if thread.canTogglePin { + let isPinned = thread.pinnedAt != nil + statusActions.append( + UIAction( + title: isPinned ? "Unpin" : "Pin", + image: UIImage(systemName: isPinned ? "pin.slash" : "pin") + ) { [weak self] _ in + self?.parent.onPin(thread, !isPinned) + } + ) + } + let isSettled = thread.isEffectivelySettled() + if isSettled || thread.canSettleNow() { + statusActions.append( + UIAction( + title: isSettled ? "Reopen" : "Settle", + image: UIImage( + systemName: isSettled ? "arrow.counterclockwise" : "checkmark" + ) + ) { [weak self] _ in + self?.parent.onSettle(thread, !isSettled) { _ in } + } + ) + } + + if thread.canToggleSnooze { + let isSnoozed = thread.isEffectivelySnoozed(at: .now) + if isSnoozed { + statusActions.append( + UIAction(title: "Wake", image: UIImage(systemName: "bell")) { + [weak self] _ in + self?.parent.onSnooze(thread, nil) + } + ) + } else { + let presets = DailyUXSnoozePresets.resolve(now: .now) + let children = presets.map { preset in + UIAction(title: preset.label) { [weak self] _ in + self?.parent.onSnooze(thread, preset.until) + } + } + let snoozeIsDisabled = thread.state == .queued + || thread.state == .waitingForApproval + || thread.state == .waitingForInput + if snoozeIsDisabled { + children.forEach { $0.attributes = .disabled } + } + let snooze = UIMenu( + title: "Snooze", + image: UIImage(systemName: "clock"), + children: children + ) + statusActions.append(snooze) + } + } + } + + let archive = UIAction( + title: isArchived ? "Restore" : "Archive", + image: UIImage(systemName: isArchived ? "arrow.uturn.backward" : "archivebox") + ) { [weak self] _ in + self?.parent.onArchive(thread, !isArchived) + } + let delete = UIAction( + title: "Delete thread", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { [weak self] _ in + self?.parent.onDelete(thread) + } + + var sections = [UIMenu(options: .displayInline, children: titleActions)] + if !statusActions.isEmpty { + sections.append(UIMenu(options: .displayInline, children: statusActions)) + } + sections.append(UIMenu(options: .displayInline, children: [archive])) + sections.append(UIMenu(options: .displayInline, children: [delete])) + return sections + } + + /// Working rows show a live per-second duration, so they need a 1 Hz + /// tick. Without any, relative ages only change by the minute, and the + /// timer idles down to match instead of waking the main thread every + /// second for the lifetime of the sidebar. + private func startTimer() { + let interval: TimeInterval = itemsByID.values.contains { + if case let .thread(thread, _, _, _, _) = $0 { + return thread.homeStatus == .working + } + return false + } ? 1 : 60 + + if timer != nil, timerInterval == interval { return } + invalidateTimer() + timerInterval = interval + timerTick = 0 + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.refreshVisibleTimes() + } + } + timer?.tolerance = interval * 0.12 + } + + private func refreshVisibleTimes() { + guard let collectionView, let dataSource else { return } + timerTick = (timerTick + 1) % 60 + let refreshRelativeAges = timerInterval >= 60 || timerTick == 0 + let now = Date.now + + for indexPath in collectionView.indexPathsForVisibleItems { + guard let identifier = dataSource.itemIdentifier(for: indexPath), + case let .thread(thread, _, _, _, _) = itemsByID[identifier], + refreshRelativeAges || thread.homeStatus == .working, + let cell = collectionView.cellForItem(at: indexPath) as? HomeCollectionCell else { + continue + } + configure(cell, identifier: identifier, now: now) + } + } + } + + private var collectionItems: [HomeCollectionItem] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedQuery.isEmpty { + if presentation.searchResults.isEmpty { + return [.searchEmpty(normalizedQuery)] + } + return presentation.searchResults.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + $0.isArchived, + forceRichRows + ) + } + } + + var items = presentation.pinned.map { + HomeCollectionItem.thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + false, + forceRichRows + ) + } + if !presentation.pinned.isEmpty, !presentation.active.isEmpty { + items.append(.pinnedDivider) + } + if presentation.active.isEmpty, presentation.pinned.isEmpty { + items.append(.empty(.active)) + } else { + items.append(contentsOf: presentation.active.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + .rich, + false, + forceRichRows + ) + }) + } + + if !presentation.snoozed.isEmpty { + items.append(.shelfHeader(.snoozed, presentation.snoozed.count, isSnoozedExpanded)) + if isSnoozedExpanded { + items.append(contentsOf: presentation.snoozed.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + false, + forceRichRows + ) + }) + } + } + + if !presentation.settled.isEmpty { + items.append(.shelfHeader(.settled, presentation.settled.count, isSettledExpanded)) + if isSettledExpanded { + items.append(contentsOf: presentation.settled.prefix(settledLimit).map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + false, + forceRichRows + ) + }) + if presentation.settled.count > settledLimit { + items.append(.showMoreSettled(presentation.settled.count - settledLimit)) + } + } + } + + if !presentation.archived.isEmpty { + items.append(.shelfHeader(.archived, presentation.archived.count, isArchiveExpanded)) + if isArchiveExpanded { + items.append(contentsOf: presentation.archived.map { + .thread( + $0, + presentation.rowContexts[$0.id] ?? .fallback, + forceRichRows ? .rich : .slim, + true, + forceRichRows + ) + }) + } + } + return items + } +} + +/// The trailing swipe actions a Home row offers, resolved as data so the row's +/// gesture semantics stay deterministic and testable without hosting a +/// collection view. Order is outermost-first, matching +/// `UISwipeActionsConfiguration`, which lays trailing actions out from the +/// trailing edge inward and runs the first action on a full swipe. +enum HomeThreadSwipeAction: Equatable { + case delete + case restore + case unpin + case settle + case reopen + case archive + + /// The lifecycle mutation an action requests. Keeping it separate from the + /// action keeps the swipe wiring verifiable and forces every case through + /// the row's existing callbacks instead of a second settlement path. + enum Intent: Equatable { + case delete + case setArchived(Bool) + case setPinned(Bool) + case setSettled(Bool) + } + + /// Settlement owns the edge slot on every row that can settle, so a full + /// swipe clears the task in one motion and a partial swipe still reveals + /// every button. Delete is always last and therefore can never be the + /// full-swipe action. A pinned row keeps Unpin between the two: settling + /// already clears the pin, so the full swipe unpins and settles together. + /// Archived rows stay restore-only, and a row with nothing to settle keeps + /// its reversible action at the edge with the full swipe turned off. + static func trailingActions( + for thread: FeatureThread, + isArchived: Bool, + at now: Date + ) -> [HomeThreadSwipeAction] { + guard !isArchived else { return [.restore, .delete] } + + let isSettled = thread.isEffectivelySettled() + let settlement: HomeThreadSwipeAction? = isSettled + ? .reopen + : (thread.canSettleNow(at: now) ? .settle : nil) + let isPinned = thread.pinnedAt != nil && thread.canTogglePin + + var actions: [HomeThreadSwipeAction] = [] + if let settlement { + actions.append(settlement) + if isPinned { + actions.append(.unpin) + } + } else if isPinned { + actions.append(.unpin) + } else { + actions.append(.archive) + } + actions.append(.delete) + return actions + } + + /// The full swipe is armed only when the edge action settles or reopens. + /// Nothing else may run from the gesture alone. + static func performsFullSwipe(with actions: [HomeThreadSwipeAction]) -> Bool { + actions.first?.isSettlement ?? false + } + + var isSettlement: Bool { + self == .settle || self == .reopen + } + + var intent: Intent { + switch self { + case .delete: .delete + case .restore: .setArchived(false) + case .archive: .setArchived(true) + case .unpin: .setPinned(false) + case .settle: .setSettled(true) + case .reopen: .setSettled(false) + } + } + + var title: String { + switch self { + case .delete: "Delete" + case .restore: "Restore" + case .unpin: "Unpin" + case .settle: "Settle" + case .reopen: "Reopen" + case .archive: "Archive" + } + } + + var systemImage: String { + switch self { + case .delete: "trash" + case .restore: "arrow.uturn.backward" + case .unpin: "pin.slash" + case .settle: "checkmark" + case .reopen: "arrow.counterclockwise" + case .archive: "archivebox" + } + } + + var style: UIContextualAction.Style { + self == .delete ? .destructive : .normal + } + + /// Destructive actions keep UIKit's own tint. + var backgroundColor: UIColor? { + switch self { + case .delete: nil + case .restore, .unpin, .reopen: .systemBlue + case .settle: .systemGreen + case .archive: .systemGray + } + } +} + +private final class HomeCollectionCell: UICollectionViewListCell { + var onAccessibilityActivate: (() -> Void)? + + override func accessibilityActivate() -> Bool { + guard let onAccessibilityActivate else { return super.accessibilityActivate() } + onAccessibilityActivate() + return true + } + + override func prepareForReuse() { + super.prepareForReuse() + onAccessibilityActivate = nil + } +} + +private enum HomeShelf: String, Hashable { + case active + case snoozed + case settled + case archived + + var title: String { + rawValue.capitalized + } +} + +private enum HomeCollectionItem: Equatable { + enum ID: Hashable { + case thread(String) + case shelfHeader(HomeShelf) + case empty(HomeShelf) + case showMoreSettled + case searchEmpty + case pinnedDivider + + var threadID: String? { + guard case let .thread(id) = self else { return nil } + return id + } + } + + case thread(FeatureThread, HomeThreadRowContext, FeatureThreadRow.Style, Bool, Bool) + case shelfHeader(HomeShelf, Int, Bool) + case empty(HomeShelf) + case showMoreSettled(Int) + case searchEmpty(String) + case pinnedDivider + + var id: ID { + switch self { + case let .thread(thread, _, _, _, _): .thread(thread.id) + case let .shelfHeader(shelf, _, _): .shelfHeader(shelf) + case let .empty(shelf): .empty(shelf) + case .showMoreSettled: .showMoreSettled + case .searchEmpty: .searchEmpty + case .pinnedDivider: .pinnedDivider + } + } +} + +private struct HomeCollectionCellContent: View { + let item: HomeCollectionItem + let projectFaviconClient: any FeatureClient + let isSelected: Bool + let now: Date + let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void + + @ViewBuilder + var body: some View { + switch item { + case let .thread(thread, context, style, _, allowsMultilineTitle): + FeatureThreadRow( + thread: thread, + context: context, + projectFaviconClient: projectFaviconClient, + onPullRequestChange: onPullRequestChange, + isSelected: isSelected, + style: style, + now: now, + allowsMultilineTitle: allowsMultilineTitle + ) + case let .shelfHeader(shelf, count, isExpanded): + HomeShelfHeader( + title: shelf.title, + count: count, + isExpanded: isExpanded, + accent: shelf == .snoozed ? T3Colors.accent : nil + ) + case let .empty(shelf): + Text(shelf == .active ? "No active tasks" : "None") + .font(T3Typography.homeMetadata) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, minHeight: shelf == .active ? 68 : 34, alignment: .center) + case let .showMoreSettled(remaining): + HStack { + Text("Show more") + Spacer() + Text("\(remaining)") + .monospacedDigit() + .foregroundStyle(T3Colors.textTertiary) + } + .font(T3Typography.homeMetadata.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .padding(.horizontal, 34) + .frame(minHeight: T3Metrics.minimumTapTarget) + case .searchEmpty: + ContentUnavailableView("No matching tasks", systemImage: "magnifyingglass") + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 160) + case .pinnedDivider: + Rectangle() + .fill(T3Colors.textTertiary.opacity(0.18)) + .frame(height: 1) + .padding(.horizontal, 18) + .padding(.vertical, 3) + } + } +} + +private extension Optional where Wrapped == [IndexPath] { + var orEmpty: [IndexPath] { self ?? [] } +} diff --git a/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift b/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift new file mode 100644 index 000000000000..6b73318af697 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewTaskWorkspaceModels.swift @@ -0,0 +1,84 @@ +import Foundation + +public enum FeatureWorkspaceMode: String, CaseIterable, Sendable, Codable { + case local + case worktree + + var title: String { + switch self { + case .local: "Current checkout" + case .worktree: "New worktree" + } + } + + var systemImage: String { + switch self { + case .local: "folder" + case .worktree: "arrow.triangle.branch" + } + } +} + +public struct FeatureWorkspaceBranch: Identifiable, Sendable, Equatable, Hashable { + public var name: String + public var isRemote: Bool + public var isCurrent: Bool + public var isDefault: Bool + public var worktreePath: String? + + public init( + name: String, + isRemote: Bool = false, + isCurrent: Bool = false, + isDefault: Bool = false, + worktreePath: String? = nil + ) { + self.name = name + self.isRemote = isRemote + self.isCurrent = isCurrent + self.isDefault = isDefault + self.worktreePath = worktreePath + } + + public var id: String { + "\(isRemote ? "remote" : "local"):\(name)" + } + + var badge: String? { + if isCurrent { return "Current" } + if worktreePath != nil { return "Worktree" } + if isDefault { return "Default" } + if isRemote { return "Remote" } + return nil + } +} + +enum NewTaskWorkspaceDefaults { + static func localBranch(in branches: [FeatureWorkspaceBranch]) -> FeatureWorkspaceBranch? { + branches.first { $0.isCurrent } + ?? branches.first { $0.isDefault && !$0.isRemote } + ?? branches.first { !$0.isRemote } + ?? branches.first + } + + static func worktreeBase(in branches: [FeatureWorkspaceBranch]) -> FeatureWorkspaceBranch? { + branches.first { $0.isDefault && !$0.isRemote } + ?? branches.first { $0.isCurrent } + ?? branches.first { $0.isDefault } + ?? branches.first { !$0.isRemote } + ?? branches.first + } + + static func normalizedWorktreePath( + for branch: FeatureWorkspaceBranch?, + projectPath: String + ) -> String? { + guard let path = branch?.worktreePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty, + URL(fileURLWithPath: path).standardizedFileURL.path + != URL(fileURLWithPath: projectPath).standardizedFileURL.path else { + return nil + } + return path + } +} diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift new file mode 100644 index 000000000000..9d72569ffee0 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -0,0 +1,1472 @@ +import SwiftUI + +public struct NewThreadView: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + @SwiftUI.Environment(\.scenePhase) private var scenePhase + @Bindable var model: FeatureRootModel + let submit: (NewTaskRequest) async -> FeatureThread? + let onCreated: (FeatureThread) -> Void + let onCreateProject: @MainActor () -> Void + private let draftStore: FeatureComposerDraftStore + private let initialProjectID: String? + + @State private var projectID = "" + @State private var projectSelectionIsExplicit = false + @State private var isAwaitingRecentProject = false + @State private var prompt = "" + @State private var selection: FeatureSelection? + @State private var selectionIsExplicit = false + @State private var preferredSelection: FeatureSelection? + @State private var attachments: [FeatureDraftAttachment] = [] + @State private var workspaceMode: FeatureWorkspaceMode = .local + @State private var workspaceSelectionIsExplicit = false + @State private var branches: [FeatureWorkspaceBranch] = [] + @State private var selectedBranch: FeatureWorkspaceBranch? + @State private var startFromOrigin = true + @State private var branchesLoading = false + @State private var branchLoadFailed = false + @State private var activePicker: NewTaskPicker? + @State private var isSubmitting = false + @State private var submissionFailed = false + @State private var submissionValidationError: String? + @State private var restoredDraftProjectID: String? + @State private var draftRestoreContext: NewTaskDraftRestoreContext? + @State private var draftSaveTask: Task? + @State private var immediateDraftSaveTasks: [String: Task] = [:] + @State private var submittedSuccessfully = false + @State private var restoresPromptAfterPickerDismissal = false + // Plain state, not `FocusState`; see the note on `composerFocused` in + // ThreadDetailView. + @State private var promptFocused = false + + public init( + model: FeatureRootModel, + submit: @escaping (NewTaskRequest) async -> FeatureThread?, + onCreated: @escaping (FeatureThread) -> Void, + onCreateProject: @escaping @MainActor () -> Void = {}, + initialProjectID: String? = nil, + draftStore: FeatureComposerDraftStore = .shared + ) { + self.model = model + self.submit = submit + self.onCreated = onCreated + self.onCreateProject = onCreateProject + self.initialProjectID = initialProjectID + self.draftStore = draftStore + } + + public var body: some View { + ZStack { + T3Colors.background.ignoresSafeArea() + + VStack(spacing: 0) { + topBar + if creationProjects.isEmpty { + noProjects + .padding(.top, 82) + } else if !usesCompactProjectContext { + hero + .padding(.top, 82) + } + Spacer(minLength: 0) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if !creationProjects.isEmpty { + VStack(spacing: 0) { + if usesCompactProjectContext { + compactProjectContext + } + + if let submissionValidationError { + Label(submissionValidationError, systemImage: "exclamationmark.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + + workspaceControls + + FeatureComposerView( + text: $prompt, + selection: selectionBinding, + attachments: $attachments, + draftOwnerID: selectedProject.map { + "new-task:\($0.environmentID):\($0.id)" + } ?? "new-task:unselected", + environmentID: selectedProject?.environmentID, + draftStorageKey: currentDraftKey, + environmentIsConnected: selectedProject.flatMap { project in + model.snapshot.environments.first { + $0.id == project.environmentID + }?.connectionState + } == .connected, + attachmentUploads: model.attachmentUploads, + attachmentPreferences: environmentPreferences, + providers: creationProviders, + threadSelection: nil, + isSending: isSubmitting, + isWorking: false, + focused: $promptFocused, + onSend: startTask, + onStop: {}, + forceExpanded: true, + powerFeatures: composerPowerFeatures, + onDismissKeyboard: { promptFocused = false }, + onRefreshModels: refreshSelectedEnvironmentModels + ) + } + .background(T3Colors.background) + } + } + .onAppear { + if projectID.isEmpty { + let recentProject = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project + let initialID = DailyUXCreationContext.initialProject( + in: model.snapshot, + requestedProjectID: initialProjectID + )?.id ?? "" + isAwaitingRecentProject = initialProjectID == nil && recentProject == nil + selectInitialProject(initialID) + } + } + .onChange(of: projectID) { prepareProjectIfNeeded(projectID) } + .onChange(of: creationProjectIDs) { _, ids in + guard !ids.contains(projectID) else { return } + if projectID.isEmpty { + let recentProject = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project + let initialID = DailyUXCreationContext.initialProject( + in: model.snapshot, + requestedProjectID: initialProjectID + )?.id ?? "" + isAwaitingRecentProject = initialProjectID == nil && recentProject == nil + selectInitialProject(initialID) + return + } + persistCurrentDraftImmediately() + let previousProject = model.snapshot.projects.first { $0.id == projectID } + let previousGroupID = previousProject.map { + DailyUXCreationContext.logicalProjectID(for: $0, in: model.snapshot) + } + let replacement = creationProjectGroups.first { $0.id == previousGroupID }? + .preferredProject(environmentID: previousProject?.environmentID) + ?? creationProjectGroups.first?.projects.first + selectInitialProject(replacement?.id ?? "") + } + .onChange(of: model.homePresentationRevision) { _, _ in + refreshAutomaticProjectIfNeeded() + } + .onChange(of: prompt) { scheduleDraftSave() } + .onChange(of: selection) { scheduleDraftSave() } + .onChange(of: attachments) { scheduleDraftSave() } + .onChange(of: workspaceMode) { scheduleDraftSave() } + .onChange(of: selectedBranch) { scheduleDraftSave() } + .onChange(of: startFromOrigin) { scheduleDraftSave() } + .onChange(of: submissionValidationMessage) { _, _ in + submissionValidationError = nil + } + .onChange(of: scenePhase) { _, phase in + if phase != .active, !submittedSuccessfully { + persistCurrentDraftImmediately() + } + } + .task(id: projectID) { await restoreDraftAndLoadBranches() } + .onDisappear { + guard !submittedSuccessfully else { return } + persistCurrentDraftImmediately() + } + .sheet(item: $activePicker, onDismiss: { + let shouldRestorePrompt = restoresPromptAfterPickerDismissal + restoresPromptAfterPickerDismissal = false + if shouldRestorePrompt, !creationProjects.isEmpty, !isSubmitting { + promptFocused = true + } + }) { picker in + switch picker { + case .project: + NewTaskProjectPicker( + groups: creationProjectGroups, + environments: model.snapshot.environments, + recentGroupIDs: recentProjectGroupIDs, + selectionID: selectedProjectGroup?.id, + onSelect: { group in + if selectProjectGroup(group) { + activePicker = nil + } + } + ) + case .branch: + NewTaskBranchPicker( + branches: branches, + selection: selectedBranch, + isLoading: branchesLoading, + loadFailed: branchLoadFailed, + onSelect: { branch in + workspaceSelectionIsExplicit = true + selectedBranch = branch + activePicker = nil + }, + onRefresh: { Task { await loadBranches(refresh: true) } } + ) + } + } + .alert("Couldn’t start task", isPresented: $submissionFailed) { + // Refocus on dismissal, not on failure: the alert takes first + // responder, so an earlier refocus never survives it. + Button("OK") { promptFocused = true } + } message: { + Text("Check your connection and try again.") + } + .interactiveDismissDisabled(isSubmitting) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + private var usesCompactProjectContext: Bool { + NewThreadComposerLayout.usesCompactContext( + prompt: prompt, + isFocused: promptFocused, + hasAttachments: !attachments.isEmpty + ) + } + + private var topBar: some View { + HStack { + Button("Cancel") { dismiss() } + .font(.body) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .disabled(isSubmitting) + .accessibilityLabel("Cancel new task") + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 48) + } + + private var hero: some View { + VStack(spacing: 8) { + VStack(spacing: 4) { + Text("What should we build") + + HStack(spacing: 0) { + Text("in") + + Button { + presentPicker(.project) + } label: { + Text(selectedProjectGroup?.name ?? selectedProject?.name ?? "a project") + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(T3Colors.textPrimary) + .overlay(alignment: .bottom) { + DottedUnderline() + .stroke( + T3Colors.textPrimary.opacity(0.58), + style: StrokeStyle(lineWidth: 1, dash: [2, 3]) + ) + .frame(height: 1) + .offset(y: 3) + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .padding(.leading, 5) + .layoutPriority(1) + .accessibilityLabel("Choose project") + .accessibilityValue( + selectedProjectGroup?.name ?? selectedProject?.name ?? "Not selected" + ) + + Text("?") + } + } + .font(T3Typography.threadHeading1.weight(.regular)) + .foregroundStyle(T3Colors.textPrimary) + .multilineTextAlignment(.center) + + environmentPicker + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private var compactProjectContext: some View { + HStack(spacing: 12) { + Button { + presentPicker(.project) + } label: { + HStack(spacing: 6) { + Image(systemName: "folder") + .font(.system(size: 11, weight: .medium)) + Text( + selectedProjectGroup?.name + ?? selectedProject?.name + ?? "Choose project" + ) + .lineLimit(1) + .truncationMode(.middle) + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textPrimary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .layoutPriority(1) + .accessibilityLabel("Choose project") + .accessibilityValue( + selectedProjectGroup?.name ?? selectedProject?.name ?? "Not selected" + ) + + Spacer(minLength: 0) + + environmentPicker + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + } + + private var environmentPicker: some View { + Menu { + ForEach(creationEnvironments) { environment in + Button { + selectEnvironment(environment.id) + } label: { + if environment.id == selectedProject?.environmentID { + Label(environmentLabel(environment), systemImage: "checkmark") + } else { + Text(environmentLabel(environment)) + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "server.rack") + .font(.system(size: 11, weight: .medium)) + Text(environmentName) + .lineLimit(1) + .truncationMode(.middle) + if let environmentStatus { + Text(environmentStatus) + .foregroundStyle(T3Colors.warning) + } + if creationEnvironments.count > 1 { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isSubmitting || creationEnvironments.count < 2) + .accessibilityLabel("Environment") + .accessibilityValue(environmentAccessibilityValue) + } + + private var noProjects: some View { + VStack(spacing: 14) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(T3Colors.textSecondary) + Text("No projects") + .font(T3Typography.threadHeading1.weight(.regular)) + .foregroundStyle(T3Colors.textPrimary) + Button("Add project") { + dismiss() + Task { @MainActor in + await Task.yield() + onCreateProject() + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(T3Colors.primaryAction) + .foregroundStyle(T3Colors.primaryActionForeground) + .padding(.top, 6) + } + .padding(.horizontal, 28) + .frame(maxWidth: .infinity) + } + + private var selectedProject: FeatureProject? { + creationProjects.first { $0.id == projectID } + } + + private var creationProjectGroups: [DailyUXProjectGroup] { + DailyUXCreationContext.projectGroups(in: model.snapshot) + } + + private var recentProjectGroupIDs: [String] { + DailyUXCreationContext.recentProjects(in: model.snapshot).map(\.group.id) + } + + private var selectedProjectGroup: DailyUXProjectGroup? { + DailyUXProjectGrouping.group(containing: projectID, in: creationProjectGroups) + } + + private var workspaceControls: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 14) { + Menu { + Button { + setWorkspaceMode(.local) + } label: { + Label( + FeatureWorkspaceMode.local.title, + systemImage: workspaceMode == .local ? "checkmark" : "folder" + ) + } + Button { + setWorkspaceMode(.worktree) + } label: { + Label( + FeatureWorkspaceMode.worktree.title, + systemImage: workspaceMode == .worktree + ? "checkmark" + : "arrow.triangle.branch" + ) + } + } label: { + workspaceControlLabel( + workspaceMode.title, + systemImage: workspaceMode.systemImage, + showsChevron: true + ) + } + .disabled(isSubmitting) + .accessibilityLabel("Workspace") + .accessibilityValue(workspaceMode.title) + + if workspaceMode == .worktree { + Button { + presentPicker(.branch) + } label: { + workspaceControlLabel( + selectedBranch?.name + ?? (branchesLoading ? "Loading branches" : "Choose branch"), + systemImage: "arrow.triangle.branch", + showsChevron: true + ) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .accessibilityLabel("Base branch") + .accessibilityValue(selectedBranch?.name ?? "Not selected") + + Button { + workspaceSelectionIsExplicit = true + startFromOrigin.toggle() + } label: { + Label( + "Latest origin", + systemImage: startFromOrigin ? "checkmark.circle.fill" : "circle" + ) + .lineLimit(1) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle( + startFromOrigin ? T3Colors.textSecondary : T3Colors.textTertiary + ) + .disabled(isSubmitting) + .accessibilityLabel("Start from latest origin") + .accessibilityValue(startFromOrigin ? "On" : "Off") + } else if let selectedBranch { + Label(selectedBranch.name, systemImage: "arrow.triangle.branch") + .lineLimit(1) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityLabel("Current branch, \(selectedBranch.name)") + } + } + .padding(.horizontal, 18) + } + .font(T3Typography.supporting) + .frame(minHeight: 44) + .animation(.snappy(duration: 0.18), value: workspaceMode) + } + + private func workspaceControlLabel( + _ title: String, + systemImage: String, + showsChevron: Bool + ) -> some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .medium)) + Text(title) + .lineLimit(1) + if showsChevron { + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + } + .foregroundStyle(T3Colors.textSecondary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + + private var creationProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot) + } + + private var creationProjectIDs: [String] { + creationProjectGroups.flatMap(\.projects).map(\.id) + } + + private var creationEnvironments: [FeatureEnvironment] { + let environmentIDs = Set(selectedProjectGroup?.projects.map(\.environmentID) ?? []) + return model.snapshot.environments.filter { environmentIDs.contains($0.id) } + } + + private var selectedEnvironment: FeatureEnvironment? { + guard let environmentID = selectedProject?.environmentID else { return nil } + return model.snapshot.environments.first { $0.id == environmentID } + } + + private var environmentName: String { + if let selectedEnvironment { return selectedEnvironment.name } + return model.snapshot.connection.environmentName ?? "this server" + } + + private var environmentStatus: String? { + guard let selectedEnvironment else { return nil } + return environmentStatus(selectedEnvironment) + } + + private var environmentAccessibilityValue: String { + guard let environmentStatus else { return environmentName } + return "\(environmentName), \(environmentStatus)" + } + + private func environmentLabel(_ environment: FeatureEnvironment) -> String { + guard let status = environmentStatus(environment) else { return environment.name } + return "\(environment.name) · \(status)" + } + + private func environmentStatus(_ environment: FeatureEnvironment) -> String? { + guard environment.isEnabled else { return "Off" } + switch environment.connectionState { + case .disconnected: return "Offline" + case .connecting: return "Connecting" + case .reconnecting: return "Reconnecting" + case .connected, .none: return nil + } + } + + private var initialSelection: FeatureSelection? { + ProviderModelSelectionResolver.materialized( + DailyUXCreationContext.initialSelection( + for: selectedProject, + in: model.snapshot + ), + in: creationProviders + ) + } + + private var environmentPreferences: FeatureEnvironmentPreferences { + DailyUXCreationContext.environmentPreferences( + for: selectedProject, + in: model.snapshot + ) + } + + private func refreshSelectedEnvironmentModels() async throws { + guard let environmentID = selectedProject?.environmentID else { return } + guard await model.refreshProviders(environmentID: environmentID) else { + throw FeatureModelRefreshError() + } + } + + private var selectionBinding: Binding { + Binding( + get: { selection }, + set: { value in + let materializesProjectDefault = !selectionIsExplicit + && value == initialSelection + selection = value + guard !materializesProjectDefault else { return } + selectionIsExplicit = true + preferredSelection = value + } + ) + } + + /// Model and provider capabilities belong to the project's environment, + /// which may not be the connection currently selected in Settings. + private var creationProviders: [FeatureProvider] { + ProviderModelCatalogNormalizer.normalized( + DailyUXCreationContext.providers( + for: selectedProject, + in: model.snapshot + ) + ) + } + + private var composerPowerFeatures: FeatureComposerPowerFeatures { + let provider = creationProviders.first { + $0.id == selection?.providerID + } + guard let project = selectedProject else { + return FeatureComposerPowerFeatures( + slashCommands: provider?.slashCommands ?? [], + skills: provider?.skills ?? [] + ) + } + return FeatureComposerPowerFeatures( + slashCommands: provider?.slashCommands ?? [], + skills: provider?.skills ?? [], + pathSearchScopeID: project.id, + searchPaths: { query in + try await model.client.searchProjectFiles( + projectID: project.id, + query: query, + limit: 20 + ).map(Self.composerPathEntry) + } + ) + } + + private static func composerPathEntry(_ entry: FeatureFileEntry) -> FeatureComposerPathEntry { + FeatureComposerPathEntry( + path: entry.path, + kind: entry.kind == .directory ? .directory : .file + ) + } + + private var canSubmit: Bool { + !isSubmitting && submissionValidationMessage == nil + } + + private var submissionValidationMessage: String? { + guard selectedProject != nil else { return "Choose a project." } + if let selectedEnvironment { + guard selectedEnvironment.isEnabled else { return "Environment is off." } + } + guard restoredDraftProjectID == projectID else { return "Project is loading." } + guard concreteSelection != nil else { + guard !creationProviders.isEmpty else { return "No providers available." } + guard creationProviders.contains(where: \.isAvailable) else { + return "No providers are online." + } + guard creationProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) + else { + return "No models available." + } + return "Choose a model." + } + guard !trimmedPrompt.isEmpty || !attachments.isEmpty else { + return "Add a message or image." + } + guard attachments.isEmpty || imagesAllowed else { + return "This model does not support images." + } + guard workspaceMode != .worktree || selectedBranch != nil else { + if branchesLoading { return "Branches are loading." } + return branchLoadFailed ? "Could not load branches." : "Choose a base branch." + } + return nil + } + + private var trimmedPrompt: String { + prompt.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var imagesAllowed: Bool { + DailyUXModelOptions.supportsImages( + selection: concreteSelection, + providers: creationProviders + ) + } + + private var concreteSelection: FeatureSelection? { + guard creationProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) else { + return nil + } + return ProviderModelSelectionResolver.materialized(selection, in: creationProviders) + } + + private func presentPicker(_ picker: NewTaskPicker) { + restoresPromptAfterPickerDismissal = promptFocused + promptFocused = false + activePicker = picker + } + + private func startTask() { + guard !isSubmitting else { return } + guard canSubmit, + let project = selectedProject, + let concreteSelection else { + submissionValidationError = submissionValidationMessage + if submissionValidationError == "Choose a base branch." + || submissionValidationError == "Could not load branches." { + presentPicker(.branch) + } + return + } + submissionValidationError = nil + promptFocused = false + isSubmitting = true + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let draftKey = currentDraftKey + let draftSnapshot = composerDraft + let immediateDraftSaveTask = draftKey.flatMap { + immediateDraftSaveTasks.removeValue(forKey: $0) + } + let request = NewTaskRequest( + projectID: project.id, + prompt: trimmedPrompt, + selection: concreteSelection, + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: workspaceMode, + branch: selectedBranch?.name, + worktreePath: workspaceMode == .local + ? NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: selectedBranch, + projectPath: project.path + ) + : nil, + startFromOrigin: startFromOrigin, + attachments: model.attachmentUploads.attachmentsForSend( + draftKey: draftKey ?? FeatureComposerDraftStore.newTaskKey(project: project), + environmentID: project.environmentID, + attachments: attachments + ) + ) + + Task { @MainActor in + await NewTaskDraftWriteFence.cancelAndWait(pendingDraftSaveTask) + await NewTaskDraftWriteFence.cancelAndWait(immediateDraftSaveTask) + if let draftKey { + try? await draftStore.setDraft(draftSnapshot, for: draftKey) + } + if let thread = await submit(request) { + submittedSuccessfully = true + let trailingDraftSaveTask = draftSaveTask + draftSaveTask = nil + await NewTaskDraftWriteFence.cancelAndWait(trailingDraftSaveTask) + if let draftKey { + let trailingSave = immediateDraftSaveTasks.removeValue(forKey: draftKey) + await NewTaskDraftWriteFence.cancelAndWait(trailingSave) + try? await draftStore.removeDraft(for: draftKey) + } + onCreated(thread) + } else { + isSubmitting = false + submissionFailed = true + } + } + } + + @discardableResult + private func selectProject(_ id: String) -> Bool { + guard creationProjects.contains(where: { $0.id == id }) else { return false } + projectSelectionIsExplicit = true + isAwaitingRecentProject = false + guard id != projectID else { return true } + persistCurrentDraftImmediately() + projectID = id + prepareProjectIfNeeded(id) + return true + } + + @discardableResult + private func selectProjectGroup(_ group: DailyUXProjectGroup) -> Bool { + guard let target = DailyUXProjectGrouping.selectionTarget( + groupID: group.id, + preferredEnvironmentID: selectedProject?.environmentID, + in: creationProjectGroups + ) else { return false } + projectSelectionIsExplicit = true + guard group.id != selectedProjectGroup?.id else { return true } + return selectProject(target.id) + } + + private func selectEnvironment(_ id: String) { + guard selectedProject?.environmentID != id else { return } + let project = selectedProjectGroup?.project(in: id) + guard let project else { return } + selectProject(project.id) + } + + private func selectInitialProject(_ id: String) { + projectID = id + prepareProjectIfNeeded(id) + } + + private func refreshAutomaticProjectIfNeeded() { + guard isAwaitingRecentProject else { return } + let nextProjectID = DailyUXCreationContext.recentProjects( + in: model.snapshot + ).first?.project.id + guard let nextProjectID else { return } + if nextProjectID == projectID { + isAwaitingRecentProject = false + return + } + let draftRestoreIsComplete = restoredDraftProjectID == projectID + guard DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: projectID, + nextRecentProjectID: nextProjectID, + isAwaitingRecentActivity: isAwaitingRecentProject, + projectSelectionIsExplicit: projectSelectionIsExplicit, + modelSelectionIsExplicit: selectionIsExplicit, + workspaceSelectionIsExplicit: workspaceSelectionIsExplicit, + hasDraftContent: !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !attachments.isEmpty, + draftRestoreIsComplete: draftRestoreIsComplete + ) else { + if draftRestoreIsComplete { + isAwaitingRecentProject = false + } + return + } + isAwaitingRecentProject = false + selectInitialProject(nextProjectID) + } + + private func prepareProjectIfNeeded(_ id: String) { + guard draftRestoreContext?.projectID != id else { return } + + if selectionIsExplicit, let selection { + preferredSelection = selection + } + + restoredDraftProjectID = nil + draftSaveTask?.cancel() + draftSaveTask = nil + prompt = "" + attachments = [] + selectionIsExplicit = false + workspaceSelectionIsExplicit = false + branches = [] + selectedBranch = nil + branchLoadFailed = false + branchesLoading = false + + guard let project = creationProjects.first(where: { $0.id == id }) else { + selection = nil + workspaceMode = .local + startFromOrigin = true + draftRestoreContext = nil + return + } + + let providers = ProviderModelCatalogNormalizer.normalized( + DailyUXCreationContext.providers(for: project, in: model.snapshot) + ) + let carriedSelection = DailyUXModelOptions.validated(preferredSelection, in: providers) + selection = ProviderModelSelectionResolver.materialized( + DailyUXCreationContext.selection( + carrying: preferredSelection, + to: project, + in: model.snapshot + ), + in: providers + ) + selectionIsExplicit = carriedSelection != nil + let preferences = DailyUXCreationContext.environmentPreferences( + for: project, + in: model.snapshot + ) + workspaceMode = preferences.defaultWorkspaceMode + startFromOrigin = preferences.newWorktreesStartFromOrigin + draftRestoreContext = NewTaskDraftRestoreContext( + projectID: id, + baseline: FeatureComposerDraft() + ) + } + + private func setWorkspaceMode(_ mode: FeatureWorkspaceMode) { + workspaceSelectionIsExplicit = true + workspaceMode = mode + selectedBranch = switch mode { + case .local: NewTaskWorkspaceDefaults.localBranch(in: branches) + case .worktree: NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + } + + @MainActor + private func loadBranches(refresh: Bool = false) async { + let requestedProjectID = projectID + guard !requestedProjectID.isEmpty else { return } + + branchesLoading = true + branchLoadFailed = false + do { + let loaded = try await model.workspaceBranches( + projectID: requestedProjectID, + refresh: refresh + ) + guard !Task.isCancelled, projectID == requestedProjectID else { return } + branches = loaded.sorted(by: Self.branchSort) + + if let selectedBranch, + let updated = branches.first(where: { $0.name == selectedBranch.name }) { + self.selectedBranch = updated + } else { + self.selectedBranch = switch workspaceMode { + case .local: NewTaskWorkspaceDefaults.localBranch(in: branches) + case .worktree: NewTaskWorkspaceDefaults.worktreeBase(in: branches) + } + } + } catch is CancellationError { + return + } catch { + guard projectID == requestedProjectID else { return } + branchLoadFailed = true + } + guard projectID == requestedProjectID else { return } + branchesLoading = false + } + + @MainActor + private func restoreDraftAndLoadBranches() async { + let requestedProjectID = projectID + guard let project = selectedProject, + let context = draftRestoreContext, + context.projectID == requestedProjectID, + !requestedProjectID.isEmpty else { + return + } + let key = draftKey(for: project) + let pendingImmediateSave = immediateDraftSaveTasks[key] + await NewTaskDraftWriteFence.wait(pendingImmediateSave) + guard !Task.isCancelled, + projectID == requestedProjectID, + draftRestoreContext?.projectID == requestedProjectID else { + return + } + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled, + projectID == requestedProjectID, + draftRestoreContext?.projectID == requestedProjectID else { + return + } + + let liveDraft = composerDraft + let liveSelectionIsExplicit = selectionIsExplicit + let liveWorkspaceSelectionIsExplicit = workspaceSelectionIsExplicit + let restored = context.merging( + saved: saved, + current: liveDraft, + fallbackSelection: initialSelection, + fallbackWorkspace: FeatureComposerWorkspaceDraft( + mode: environmentPreferences.defaultWorkspaceMode, + branch: nil, + worktreePath: nil, + startFromOrigin: environmentPreferences.newWorktreesStartFromOrigin + ) + ) + prompt = restored.text + attachments = restored.attachments + selection = DailyUXModelOptions.validated(restored.selection, in: creationProviders) + ?? initialSelection + selectionIsExplicit = liveSelectionIsExplicit || saved?.selection != nil + if selectionIsExplicit, let selection { + preferredSelection = selection + } + if let workspace = restored.workspace { + workspaceMode = workspace.mode + selectedBranch = workspace.branch.map { + FeatureWorkspaceBranch( + name: $0, + worktreePath: workspace.worktreePath + ) + } + startFromOrigin = workspace.startFromOrigin + } + workspaceSelectionIsExplicit = liveWorkspaceSelectionIsExplicit + || saved?.workspace != nil + restoredDraftProjectID = requestedProjectID + if liveDraft != context.baseline { + scheduleDraftSave() + } else if saved != nil { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: project.environmentID, + attachments: restored.attachments + ) + } + refreshAutomaticProjectIfNeeded() + guard projectID == requestedProjectID else { return } + await loadBranches() + } + + private var currentDraftKey: String? { + guard let project = selectedProject else { return nil } + return draftKey(for: project) + } + + private func draftKey(for project: FeatureProject) -> String { + FeatureComposerDraftStore.newTaskKey(project: project, in: model.snapshot) + } + + private var composerDraft: FeatureComposerDraft { + FeatureComposerDraft( + text: prompt, + attachments: attachments, + selection: selectionIsExplicit ? selection : nil, + workspace: workspaceSelectionIsExplicit + ? FeatureComposerWorkspaceDraft( + mode: workspaceMode, + branch: selectedBranch?.name, + worktreePath: workspaceMode == .local + ? NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: selectedBranch, + projectPath: selectedProject?.path ?? "" + ) + : nil, + startFromOrigin: startFromOrigin + ) + : nil + ) + } + + private func scheduleDraftSave() { + guard restoredDraftProjectID == projectID, + !isSubmitting, + !submittedSuccessfully, + let key = currentDraftKey else { + return + } + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let snapshot = composerDraft + let environmentID = selectedProject?.environmentID + draftSaveTask = Task { + await NewTaskDraftWriteFence.wait(pendingDraftSaveTask) + do { + try await Task.sleep(for: .milliseconds(220)) + try Task.checkCancellation() + try await draftStore.setDraft(snapshot, for: key) + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch is CancellationError { + return + } catch { + return + } + } + } + + private func persistCurrentDraftImmediately() { + guard !submittedSuccessfully, + let key = currentDraftKey else { + return + } + let pendingDraftSaveTask = draftSaveTask + pendingDraftSaveTask?.cancel() + draftSaveTask = nil + let snapshot = composerDraft + let restoreContext = draftRestoreContext + let draftProjectID = projectID + let environmentID = selectedProject?.environmentID + let needsRestoreMerge = restoredDraftProjectID != draftProjectID + let previousSave = immediateDraftSaveTasks[key] + previousSave?.cancel() + let task = Task { @MainActor in + await NewTaskDraftWriteFence.wait(pendingDraftSaveTask) + await NewTaskDraftWriteFence.wait(previousSave) + guard !Task.isCancelled else { return } + if needsRestoreMerge, + let restoreContext, + restoreContext.projectID == draftProjectID { + let saved = try? await draftStore.draft(for: key) + guard !Task.isCancelled else { return } + let merged = restoreContext.merging(saved: saved, current: snapshot) + do { + try await draftStore.setDraft(merged, for: key) + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: merged.attachments + ) + } + } catch { + return + } + } else { + do { + try await draftStore.setDraft(snapshot, for: key) + if let environmentID { + model.attachmentUploads.syncOwner( + draftKey: key, + environmentID: environmentID, + attachments: snapshot.attachments + ) + } + } catch { + return + } + } + } + immediateDraftSaveTasks[key] = task + } + + private static func branchSort( + _ lhs: FeatureWorkspaceBranch, + _ rhs: FeatureWorkspaceBranch + ) -> Bool { + let lhsRank = lhs.isCurrent ? 0 : lhs.isDefault ? 1 : lhs.isRemote ? 3 : 2 + let rhsRank = rhs.isCurrent ? 0 : rhs.isDefault ? 1 : rhs.isRemote ? 3 : 2 + if lhsRank != rhsRank { return lhsRank < rhsRank } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } +} + +enum NewThreadComposerLayout { + /// The full prompt is useful before editing starts. Once a draft needs + /// room, a compact row keeps the project and environment visible while the + /// editor uses the rest of the hero's space. + static func usesCompactContext( + prompt: String, + isFocused: Bool, + hasAttachments: Bool + ) -> Bool { + !prompt.isEmpty || isFocused || hasAttachments + } +} + +enum NewTaskDraftWriteFence { + static func wait(_ task: Task?) async { + await task?.value + } + + static func cancelAndWait(_ task: Task?) async { + task?.cancel() + await task?.value + } +} + +/// Captures the clean target-project state before its persisted draft is read. +/// Async restore results can then merge live typing without ever borrowing state +/// from the project that was previously selected. +struct NewTaskDraftRestoreContext: Equatable { + let projectID: String + let baseline: FeatureComposerDraft + + func merging( + saved: FeatureComposerDraft?, + current: FeatureComposerDraft, + fallbackSelection: FeatureSelection? = nil, + fallbackWorkspace: FeatureComposerWorkspaceDraft? = nil + ) -> FeatureComposerDraft { + FeatureComposerDraftRestoration.merge( + saved: saved, + baseline: baseline, + current: current, + fallbackSelection: fallbackSelection, + fallbackWorkspace: fallbackWorkspace + ) + } +} + +private struct DottedUnderline: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.midY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) + return path + } +} + +private enum NewTaskPicker: String, Identifiable { + case project + case branch + + var id: String { rawValue } +} + +enum NewTaskProjectPickerSearch { + static func matching( + _ groups: [DailyUXProjectGroup], + query: String, + environments: [FeatureEnvironment] + ) -> [DailyUXProjectGroup] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return groups } + + let environmentNames = Dictionary( + environments.map { ($0.id, $0.name) }, + uniquingKeysWith: { first, _ in first } + ) + + return groups.filter { group in + group.name.localizedCaseInsensitiveContains(query) + || group.projects.contains { project in + project.path.localizedCaseInsensitiveContains(query) + || environmentNames[project.environmentID]? + .localizedCaseInsensitiveContains(query) == true + } + } + } +} + +private struct NewTaskProjectPicker: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let groups: [DailyUXProjectGroup] + let environments: [FeatureEnvironment] + let recentGroupIDs: [String] + let selectionID: String? + let onSelect: (DailyUXProjectGroup) -> Void + + @State private var query = "" + + var body: some View { + NavigationStack { + Group { + if groups.isEmpty { + ContentUnavailableView( + "No projects", + systemImage: "folder" + ) + } else if filteredGroups.isEmpty { + ContentUnavailableView( + "No matching projects", + systemImage: "magnifyingglass" + ) + } else { + let sections = DailyUXProjectPickerSections( + groups: filteredGroups, + recentGroupIDs: recentGroupIDs + ) + List { + if sections.recents.isEmpty { + ForEach(sections.others) { group in + projectRow(group) + } + } else { + Section("Recent") { + ForEach(sections.recents) { group in + projectRow(group) + } + } + + if !sections.others.isEmpty { + Section("Other projects") { + ForEach(sections.others) { group in + projectRow(group) + } + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + } + } + .background(T3Colors.background) + .navigationTitle("Project") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search projects") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(T3Colors.background) + } + + private func projectRow(_ group: DailyUXProjectGroup) -> some View { + Button { + onSelect(group) + } label: { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(group.name) + .foregroundStyle(T3Colors.textPrimary) + + Text(projectLocation(group)) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 10) + + if group.id == selectionID { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(T3Colors.accent) + } + } + .frame(minHeight: 46) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(group.name) + .accessibilityValue(projectLocation(group)) + .accessibilityAddTraits( + group.id == selectionID ? .isSelected : [] + ) + .listRowBackground(T3Colors.background) + } + + private var filteredGroups: [DailyUXProjectGroup] { + NewTaskProjectPickerSearch.matching( + groups, + query: query, + environments: environments + ) + } + + private func projectLocation(_ group: DailyUXProjectGroup) -> String { + guard let firstProject = group.projects.first else { return "" } + + var seenEnvironmentIDs = Set() + let allNames = group.projects.compactMap { project -> String? in + guard seenEnvironmentIDs.insert(project.environmentID).inserted else { return nil } + return environments.first { $0.id == project.environmentID }?.name + ?? project.environmentID + } + let names = Array(allNames.prefix(2)) + let additionalCount = allNames.count - names.count + let additionalLocations = additionalCount > 0 ? " +\(additionalCount)" : "" + + return "\(names.joined(separator: ", "))\(additionalLocations) · \(firstProject.path)" + } +} + +private struct NewTaskBranchPicker: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + + let branches: [FeatureWorkspaceBranch] + let selection: FeatureWorkspaceBranch? + let isLoading: Bool + let loadFailed: Bool + let onSelect: (FeatureWorkspaceBranch) -> Void + let onRefresh: () -> Void + + @State private var query = "" + + var body: some View { + NavigationStack { + Group { + if isLoading, branches.isEmpty { + ProgressView("Loading branches") + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredBranches.isEmpty { + ContentUnavailableView { + Label( + loadFailed + ? "Could not load branches" + : query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "No branches available" + : "No matching branches", + systemImage: loadFailed + ? "exclamationmark.triangle" + : "arrow.triangle.branch" + ) + } description: { + EmptyView() + } actions: { + if loadFailed { + Button("Try again", action: onRefresh) + } + } + } else { + List(filteredBranches) { branch in + Button { + onSelect(branch) + } label: { + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.branch") + .foregroundStyle(T3Colors.textTertiary) + + Text(branch.name) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + + Spacer(minLength: 10) + + if let badge = branch.badge { + Text(badge) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + + if branch.id == selection?.id { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(T3Colors.accent) + } + } + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + branch.badge.map { "\(branch.name), \($0)" } ?? branch.name + ) + .accessibilityAddTraits( + branch.id == selection?.id ? .isSelected : [] + ) + .listRowBackground(T3Colors.background) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .refreshable { onRefresh() } + } + } + .background(T3Colors.background) + .navigationTitle("Base branch") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, prompt: "Search branches") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + .accessibilityLabel("Refresh branches") + } + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(T3Colors.background) + } + + private var filteredBranches: [FeatureWorkspaceBranch] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return branches } + return branches.filter { + $0.name.localizedCaseInsensitiveContains(trimmed) + } + } +} diff --git a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift new file mode 100644 index 000000000000..89324b9adfda --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift @@ -0,0 +1,952 @@ +import SwiftUI + +public struct AddProjectView: View { + private struct PendingCloneRegistration: Equatable { + let environmentID: String + let remoteURL: String + let destinationPath: String + let clonedPath: String + } + + private enum ProjectMode: String, CaseIterable, Identifiable { + case folder + case repository + + var id: String { rawValue } + var label: String { self == .folder ? "Folder" : "Clone" } + var icon: String { self == .folder ? "folder" : "arrow.down.circle" } + } + + private enum Field: Hashable { + case localPath + case repository + case destination + } + + @SwiftUI.Environment(\.dismiss) private var dismiss + @Bindable var model: FeatureRootModel + + @State private var selectedEnvironmentID: String? + @State private var mode = ProjectMode.folder + @State private var localPath = "~/" + @State private var source = ProjectRemoteSource.url + @State private var repositoryInput = "" + @State private var destinationPath = "~/" + @State private var resolvedRepository: SourceControlRepositoryInfo? + @State private var didEditDestination = false + @State private var pendingCloneRegistration: PendingCloneRegistration? + + @State private var browsePath = "~/" + @State private var browseResult: FilesystemBrowseResult? + @State private var isBrowsing = false + @State private var browseError: String? + @State private var browseRequestID: UUID? + + @State private var discovery: SourceControlDiscoveryResult? + @State private var isDiscovering = false + @State private var discoveryError: String? + @State private var discoveryRequestID: UUID? + + @State private var isSubmitting = false + @State private var errorMessage: String? + @State private var cloneRequestID: UUID? + @FocusState private var focusedField: Field? + + public init(model: FeatureRootModel) { + self.model = model + } + + public var body: some View { + NavigationStack { + Group { + if let environment = selectedEnvironment { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + if environments.count > 1 { + environmentPicker(environment) + } + modePicker + if let errorMessage { + errorBanner(errorMessage) + } + switch mode { + case .folder: + localProjectForm(environment) + case .repository: + repositoryProjectForm(environment) + } + if showsFolderBrowser { + folderBrowser(environment) + } + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 32) + .disabled(isSubmitting) + } + .scrollDismissesKeyboard(.interactively) + } else { + ContentUnavailableView( + "Environment unavailable", + systemImage: "server.rack", + description: Text("Reconnect a T3 environment before adding a project.") + ) + } + } + .background(T3Colors.background) + .navigationTitle("Add project") + .navigationBarTitleDisplayMode(.inline) + .t3NavigationChrome() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .onAppear(perform: selectEnvironmentIfNeeded) + .onChange(of: model.snapshot.environments) { + selectEnvironmentIfNeeded() + } + .onChange(of: source) { + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + updateSuggestedDestination() + errorMessage = nil + } + .onChange(of: repositoryInput) { + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + updateSuggestedDestination() + errorMessage = nil + } + .task(id: selectedEnvironmentID) { + guard selectedEnvironmentID != nil else { return } + resetEnvironmentState() + await loadDirectory(browsePath, updateSelection: false) + await loadDiscovery() + } + } + + private var projectClient: (any FeatureProjectCreationClient)? { + model.client as? any FeatureProjectCreationClient + } + + private var environments: [FeatureEnvironment] { + model.snapshot.environments + .filter(\.isEnabled) + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var selectedEnvironment: FeatureEnvironment? { + environments.first { $0.id == selectedEnvironmentID && canCreateProject(in: $0) } + } + + private var sourceOptions: [ProjectRemoteSourceOption] { + ProjectRemoteSourceOptions.options(discovery: discovery) + } + + private var selectedSourceOption: ProjectRemoteSourceOption? { + sourceOptions.first { $0.source == source } + } + + private var needsRepositoryLookup: Bool { + source.provider != nil && resolvedRepository == nil + } + + private var showsFolderBrowser: Bool { + mode == .folder || !needsRepositoryLookup + } + + private var repositoryName: String { + ProjectCreationPath.repositoryName( + from: resolvedRepository?.nameWithOwner ?? repositoryInput + ) + } + + private var modePicker: some View { + HStack(spacing: 24) { + ForEach(ProjectMode.allCases) { candidate in + Button { + focusedField = nil + errorMessage = nil + mode = candidate + } label: { + VStack(spacing: 9) { + Label(candidate.label, systemImage: candidate.icon) + .font(T3Typography.control) + .foregroundStyle( + mode == candidate ? T3Colors.textPrimary : T3Colors.textTertiary + ) + Rectangle() + .fill(mode == candidate ? T3Colors.textPrimary : Color.clear) + .frame(height: 2) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .accessibilityElement(children: .contain) + } + + private func environmentPicker(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("Environment") + Menu { + ForEach(environments) { option in + Button { + selectedEnvironmentID = option.id + } label: { + if option.id == environment.id { + Label(option.name, systemImage: "checkmark") + } else { + Text(option.name) + } + } + .disabled(!canCreateProject(in: option)) + } + } label: { + HStack(spacing: 10) { + Image(systemName: "server.rack") + VStack(alignment: .leading, spacing: 2) { + Text(environment.name) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text(environment.endpoint) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + Spacer(minLength: 12) + Image(systemName: "chevron.up.chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 13) + .frame(minHeight: 52) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + + private func localProjectForm(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + sectionTitle("Workspace path") + Spacer() + Text("on \(environment.name)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + pathField( + placeholder: "~/projects/my-app", + text: $localPath, + field: .localPath, + browseAction: { + Task { + await loadDirectory( + ProjectCreationPath.directoryBrowsePath(localPath), + updateSelection: false + ) + } + } + ) + primaryAction(label: "Add project", icon: "plus") { + await addLocalProject(environment) + } + } + } + + private func repositoryProjectForm(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionTitle("Repository source") + if isDiscovering { + ProgressView().controlSize(.small) + } + } + sourcePicker + if let discoveryError { + Label(discoveryError, systemImage: "info.circle") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + } + + VStack(alignment: .leading, spacing: 8) { + sectionTitle(source == .url ? "Remote URL" : "Repository") + TextField(source.prompt, text: $repositoryInput) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(source == .url ? .URL : .default) + .submitLabel(needsRepositoryLookup ? .next : .done) + .focused($focusedField, equals: .repository) + .onSubmit { + Task { + if needsRepositoryLookup { + await resolveRepository(environment) + } else { + focusedField = .destination + } + } + } + .t3ProjectInput() + } + + if let resolvedRepository { + repositorySummary(resolvedRepository) + } + + if needsRepositoryLookup { + primaryAction(label: "Find repository", icon: "magnifyingglass") { + await resolveRepository(environment) + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + sectionTitle("Clone destination") + Spacer() + Text("on \(environment.name)") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + } + pathField( + placeholder: "~/projects/\(repositoryName)", + text: destinationBinding, + field: .destination, + browseAction: nil + ) + } + primaryAction(label: "Clone and add", icon: "arrow.down.circle") { + await cloneProject(environment) + } + } + } + } + + private var sourcePicker: some View { + Menu { + ForEach(sourceOptions) { option in + Button { + source = option.source + } label: { + if option.source == source { + Label(option.source.label, systemImage: "checkmark") + } else if let detail = option.detail { + Text("\(option.source.label) · \(detail)") + } else { + Text(option.source.label) + } + } + .disabled(!option.isReady) + } + } label: { + HStack(spacing: 10) { + Image(systemName: sourceIcon(source)) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(source.label) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + if let detail = selectedSourceOption?.detail { + Text(detail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + } + } + Spacer(minLength: 12) + Image(systemName: "chevron.up.chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 13) + .frame(minHeight: 52) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } + .buttonStyle(.plain) + } + + private func repositorySummary(_ repository: SourceControlRepositoryInfo) -> some View { + HStack(alignment: .top, spacing: 11) { + Image(systemName: sourceIcon(source)) + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 24) + VStack(alignment: .leading, spacing: 3) { + Text(repository.nameWithOwner) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text(repository.sshUrl) + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(2) + } + Spacer(minLength: 0) + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(T3Colors.success) + } + .padding(.vertical, 4) + } + + private func folderBrowser(_ environment: FeatureEnvironment) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionTitle("Folders on \(environment.name)") + Spacer() + if isBrowsing { + ProgressView().controlSize(.small) + } else { + Button { + Task { await loadDirectory(browsePath, updateSelection: false) } + } label: { + Image(systemName: "arrow.clockwise") + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Refresh folders") + } + } + + Text(browsePath) + .font(T3Typography.supporting.monospaced()) + .foregroundStyle(T3Colors.textTertiary) + .lineLimit(1) + .truncationMode(.middle) + + Divider().overlay(T3Colors.separator) + if let browseError { + Text(browseError) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.warning) + .padding(.vertical, 8) + } + if let parentPath = ProjectCreationPath.parentBrowsePath(of: browsePath) { + folderRow(name: "..", icon: "arrow.turn.left.up") { + await loadDirectory(parentPath, updateSelection: true) + } + } + if let entries = browseResult?.entries, !entries.isEmpty { + ForEach(entries, id: \.fullPath) { entry in + Divider().overlay(T3Colors.separator) + folderRow(name: entry.name, icon: "folder") { + await loadDirectory( + ProjectCreationPath.directoryBrowsePath(entry.fullPath), + updateSelection: true + ) + } + } + } else if !isBrowsing, browseError == nil { + Text("No folders here") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textTertiary) + .frame(maxWidth: .infinity, minHeight: 54, alignment: .center) + } + } + } + + private func folderRow( + name: String, + icon: String, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + focusedField = nil + Task { await action() } + } label: { + HStack(spacing: 11) { + Image(systemName: icon) + .font(.body.weight(.medium)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: 24) + Text(name) + .font(.body.weight(.medium)) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + Spacer(minLength: 12) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBrowsing) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + } + + private func pathField( + placeholder: String, + text: Binding, + field: Field, + browseAction: (() -> Void)? + ) -> some View { + HStack(spacing: 4) { + TextField(placeholder, text: text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .focused($focusedField, equals: field) + if let browseAction { + Button(action: browseAction) { + Image(systemName: "folder") + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Browse entered path") + } + } + .t3ProjectInput() + } + + private func primaryAction( + label: String, + icon: String, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + focusedField = nil + Task { await action() } + } label: { + HStack(spacing: 8) { + if isSubmitting { + ProgressView() + .tint(T3Colors.primaryActionForeground) + } else { + Image(systemName: icon) + } + Text(isSubmitting ? "Working…" : label) + } + .font(.body.weight(.semibold)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(maxWidth: .infinity, minHeight: 48) + .background(T3Colors.primaryAction, in: RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + .opacity(isSubmitting ? 0.66 : 1) + } + + private func errorBanner(_ message: String) -> some View { + HStack(alignment: .top, spacing: 9) { + Image(systemName: "exclamationmark.triangle.fill") + Text(message) + .font(T3Typography.supporting) + .frame(maxWidth: .infinity, alignment: .leading) + } + .foregroundStyle(T3Colors.danger) + .padding(12) + .background(T3Colors.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + } + + private var destinationBinding: Binding { + Binding( + get: { destinationPath }, + set: { value in + didEditDestination = true + destinationPath = value + pendingCloneRegistration = nil + cloneRequestID = nil + } + ) + } + + private func canCreateProject(in environment: FeatureEnvironment) -> Bool { + environment.isEnabled && environment.connectionState != .disconnected + } + + private func selectEnvironmentIfNeeded() { + if let selectedEnvironmentID, + environments.contains(where: { + $0.id == selectedEnvironmentID && canCreateProject(in: $0) + }) { + return + } + selectedEnvironmentID = environments.first(where: canCreateProject)?.id + } + + private func resetEnvironmentState() { + browsePath = "~/" + browseResult = nil + browseError = nil + browseRequestID = nil + discovery = nil + discoveryError = nil + discoveryRequestID = nil + source = .url + resolvedRepository = nil + pendingCloneRegistration = nil + cloneRequestID = nil + didEditDestination = false + localPath = "~/" + destinationPath = repositoryInput.isEmpty + ? "~/" + : ProjectCreationPath.appending(repositoryName, to: "~/") + errorMessage = nil + } + + private func loadDiscovery() async { + guard let environmentID = selectedEnvironmentID, + let projectClient else { + discoveryError = "Git URL cloning is available. Provider discovery is unavailable." + return + } + let requestID = UUID() + discoveryRequestID = requestID + isDiscovering = true + defer { + if discoveryRequestID == requestID { + isDiscovering = false + } + } + do { + let result = try await projectClient.discoverProjectSources( + environmentID: environmentID + ) + guard discoveryRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + discovery = result + discoveryError = nil + } catch is CancellationError { + return + } catch { + guard discoveryRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + discovery = nil + discoveryError = "Provider discovery unavailable. Git URL still works." + } + } + + private func loadDirectory(_ path: String, updateSelection: Bool) async { + guard let environmentID = selectedEnvironmentID, + let projectClient else { + browseError = "Folder browsing is unavailable. You can still enter a path directly." + return + } + let requestedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !requestedPath.isEmpty else { return } + let requestID = UUID() + browseRequestID = requestID + isBrowsing = true + browseError = nil + defer { + if browseRequestID == requestID { + isBrowsing = false + } + } + do { + let result = try await projectClient.browseProjectFolders( + environmentID: environmentID, + partialPath: requestedPath + ) + guard browseRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + let selectedDirectory = result.parentPath + browsePath = ProjectCreationPath.directoryBrowsePath(selectedDirectory) + browseResult = result + if updateSelection { + switch mode { + case .folder: + localPath = selectedDirectory + case .repository: + if !didEditDestination { + pendingCloneRegistration = nil + destinationPath = ProjectCreationPath.appending( + repositoryName, + to: selectedDirectory + ) + } + } + } + } catch is CancellationError { + return + } catch { + guard browseRequestID == requestID, + selectedEnvironmentID == environmentID else { + return + } + browseError = "Couldn’t browse that folder. Direct path entry still works." + } + } + + private func addLocalProject(_ environment: FeatureEnvironment) async { + errorMessage = nil + let validated: String + switch ProjectCreationPath.validated(localPath) { + case let .success(path): validated = path + case let .failure(error): + errorMessage = error.localizedDescription + return + } + if let serverPath = browseResult?.parentPath, + !ProjectCreationPath.isCompatibleWithServerPath( + validated, + serverPath: serverPath + ) { + errorMessage = "Use a path that matches \(environment.name)’s filesystem." + return + } + if let existing = existingProject(environmentID: environment.id, path: validated) { + errorMessage = "\(existing.name) already uses this folder." + return + } + + isSubmitting = true + defer { isSubmitting = false } + do { + if let projectClient { + try await projectClient.addProject( + environmentID: environment.id, + path: validated + ) + dismiss() + } else if environments.count == 1, await model.addProject(path: validated) { + dismiss() + } else { + errorMessage = model.errorMessage ?? "The project could not be added." + } + } catch is CancellationError { + return + } catch { + errorMessage = projectErrorMessage(error) + } + } + + private func resolveRepository(_ environment: FeatureEnvironment) async { + guard let provider = source.provider else { return } + let repository = repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !repository.isEmpty else { + errorMessage = "Enter a repository name." + return + } + guard let projectClient else { + errorMessage = "Repository lookup is unavailable on this connection." + return + } + + errorMessage = nil + isSubmitting = true + defer { isSubmitting = false } + do { + let result = try await projectClient.lookupProjectRepository( + environmentID: environment.id, + provider: provider, + repository: repository + ) + guard selectedEnvironmentID == environment.id, + source.provider == provider, + repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + == repository else { + return + } + resolvedRepository = result + updateSuggestedDestination() + focusedField = .destination + } catch is CancellationError { + return + } catch { + guard selectedEnvironmentID == environment.id, + source.provider == provider, + repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + == repository else { + return + } + errorMessage = projectErrorMessage(error) + } + } + + private func cloneProject(_ environment: FeatureEnvironment) async { + let remoteURL = resolvedRepository.map(ProjectCreationPath.defaultCloneURL) + ?? ProjectCreationPath.normalizedCloneURL(repositoryInput) + guard !remoteURL.isEmpty else { + errorMessage = "Enter a Git remote URL." + return + } + let validatedDestination: String + switch ProjectCreationPath.validated(destinationPath) { + case let .success(path): validatedDestination = path + case let .failure(error): + errorMessage = error.localizedDescription + return + } + if let serverPath = browseResult?.parentPath, + !ProjectCreationPath.isCompatibleWithServerPath( + validatedDestination, + serverPath: serverPath + ) { + errorMessage = "Use a path that matches \(environment.name)’s filesystem." + return + } + if let existing = existingProject( + environmentID: environment.id, + path: validatedDestination + ) { + errorMessage = "\(existing.name) already uses this destination." + return + } + guard let projectClient else { + errorMessage = "Repository cloning is unavailable on this connection." + return + } + + errorMessage = nil + let requestID = UUID() + cloneRequestID = requestID + isSubmitting = true + defer { + isSubmitting = false + if cloneRequestID == requestID { + cloneRequestID = nil + } + } + do { + let clonedPath: String + if let pending = pendingCloneRegistration, + pending.environmentID == environment.id, + pending.remoteURL == remoteURL, + pending.destinationPath == validatedDestination { + clonedPath = pending.clonedPath + } else { + let result = try await projectClient.cloneProjectRepository( + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + clonedPath = result.cwd + pendingCloneRegistration = PendingCloneRegistration( + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination, + clonedPath: result.cwd + ) + } + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + try await projectClient.addProject( + environmentID: environment.id, + path: clonedPath + ) + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + pendingCloneRegistration = nil + dismiss() + } catch is CancellationError { + return + } catch { + guard cloneRequestIsCurrent( + requestID, + environmentID: environment.id, + remoteURL: remoteURL, + destinationPath: validatedDestination + ) else { + return + } + if pendingCloneRegistration != nil { + errorMessage = "Repository cloned. Try again to finish adding the project." + } else { + errorMessage = projectErrorMessage(error) + } + } + } + + private func cloneRequestIsCurrent( + _ requestID: UUID, + environmentID: String, + remoteURL: String, + destinationPath: String + ) -> Bool { + let currentRemoteURL = resolvedRepository?.sshUrl + ?? repositoryInput.trimmingCharacters(in: .whitespacesAndNewlines) + return cloneRequestID == requestID + && selectedEnvironmentID == environmentID + && currentRemoteURL == remoteURL + && self.destinationPath.trimmingCharacters(in: .whitespacesAndNewlines) + == destinationPath + } + + private func updateSuggestedDestination() { + guard !didEditDestination, !repositoryInput.isEmpty else { return } + destinationPath = ProjectCreationPath.appending(repositoryName, to: browsePath) + } + + private func existingProject(environmentID: String, path: String) -> FeatureProject? { + let normalized = ProjectCreationPath.normalizedForComparison(path) + return model.snapshot.projects.first { + $0.environmentID == environmentID + && ProjectCreationPath.normalizedForComparison($0.path) == normalized + } + } + + private func sourceIcon(_ source: ProjectRemoteSource) -> String { + switch source { + case .url: "link" + case .github: "chevron.left.forwardslash.chevron.right" + case .gitlab: "shippingbox" + case .bitbucket: "shippingbox.fill" + case .azureDevOps: "point.3.connected.trianglepath.dotted" + } + } + + private func projectErrorMessage(_ error: Error) -> String { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? "The server could not complete that request." : message + } +} + +private extension View { + func t3ProjectInput() -> some View { + font(.body) + .foregroundStyle(T3Colors.textPrimary) + .padding(.horizontal, 13) + .frame(minHeight: 48) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1) + } + } +} diff --git a/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift b/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift new file mode 100644 index 000000000000..23003259f042 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProjectCreationModels.swift @@ -0,0 +1,283 @@ +import Foundation + +@MainActor +protocol FeatureProjectCreationClient: AnyObject { + func addProject(environmentID: String, path: String) async throws + func browseProjectFolders( + environmentID: String, + partialPath: String + ) async throws -> FilesystemBrowseResult + func discoverProjectSources(environmentID: String) async throws -> SourceControlDiscoveryResult + func lookupProjectRepository( + environmentID: String, + provider: SourceControlProviderKind, + repository: String + ) async throws -> SourceControlRepositoryInfo + func cloneProjectRepository( + environmentID: String, + remoteURL: String, + destinationPath: String + ) async throws -> SourceControlCloneResult +} + +enum ProjectRemoteSource: String, CaseIterable, Hashable, Identifiable { + case url + case github + case gitlab + case bitbucket + case azureDevOps = "azure-devops" + + var id: String { rawValue } + + var provider: SourceControlProviderKind? { + switch self { + case .url: nil + case .github: .github + case .gitlab: .gitlab + case .bitbucket: .bitbucket + case .azureDevOps: .azureDevOps + } + } + + var label: String { + switch self { + case .url: "Git URL" + case .github: "GitHub" + case .gitlab: "GitLab" + case .bitbucket: "Bitbucket" + case .azureDevOps: "Azure DevOps" + } + } + + var prompt: String { + switch self { + case .url: "https://github.com/org/repository.git" + case .github, .gitlab, .bitbucket: "owner/repository" + case .azureDevOps: "organization/project/repository" + } + } +} + +struct ProjectRemoteSourceOption: Equatable, Identifiable { + let source: ProjectRemoteSource + let isReady: Bool + let detail: String? + + var id: String { source.id } +} + +enum ProjectRemoteSourceOptions { + static func options( + discovery: SourceControlDiscoveryResult? + ) -> [ProjectRemoteSourceOption] { + let providerByKind = Dictionary( + uniqueKeysWithValues: (discovery?.sourceControlProviders ?? []).map { + ($0.kind.rawValue, $0) + } + ) + + return ProjectRemoteSource.allCases.map { source in + guard let providerKind = source.provider else { + return ProjectRemoteSourceOption(source: source, isReady: true, detail: nil) + } + guard let provider = providerByKind[providerKind.rawValue] else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: "Provider status unavailable" + ) + } + guard provider.status == .available else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: provider.detail ?? provider.installHint + ) + } + guard provider.auth.status != .unauthenticated else { + return ProjectRemoteSourceOption( + source: source, + isReady: false, + detail: provider.auth.detail ?? "Authentication required" + ) + } + let account = provider.auth.account.map { "Signed in as \($0)" } + return ProjectRemoteSourceOption( + source: source, + isReady: true, + detail: account ?? provider.version + ) + } + } +} + +enum ProjectCreationPath { + static func normalizedCloneURL(_ input: String) -> String { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + let pattern = #"^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]+(?:\.git)?$"# + guard trimmed.range(of: pattern, options: .regularExpression) != nil else { + return trimmed + } + let repository = trimmed.hasSuffix(".git") ? trimmed : "\(trimmed).git" + return "https://github.com/\(repository)" + } + + static func defaultCloneURL(for repository: SourceControlRepositoryInfo) -> String { + repository.provider == .github ? repository.url : repository.sshUrl + } + + static func validated(_ rawValue: String) -> Result { + let path = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { + return .failure(.init(message: "Enter a project path.")) + } + guard isAbsoluteOrHomeRelative(path) else { + return .failure( + .init(message: "Use an absolute path, or start with ~/.") + ) + } + return .success(path) + } + + static func repositoryName(from value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "repository" } + let withoutQuery = trimmed.split(separator: "?", maxSplits: 1).first.map(String.init) + ?? trimmed + let withoutFragment = withoutQuery.split(separator: "#", maxSplits: 1).first.map(String.init) + ?? withoutQuery + let normalized = withoutFragment + .replacingOccurrences(of: "\\", with: "/") + .replacingOccurrences(of: ":", with: "/") + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let component = normalized.split(separator: "/").last.map(String.init) ?? "repository" + let withoutGit = component.lowercased().hasSuffix(".git") + ? String(component.dropLast(4)) + : component + let sanitized = withoutGit.trimmingCharacters(in: .whitespacesAndNewlines) + return sanitized.isEmpty ? "repository" : sanitized + } + + static func appending(_ component: String, to basePath: String) -> String { + let base = basePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !base.isEmpty else { return component } + guard !component.isEmpty else { return base } + let separator = base.contains("\\") && !base.contains("/") ? "\\" : "/" + if base.hasSuffix("/") || base.hasSuffix("\\") { + return base + component + } + return base + separator + component + } + + /// `filesystem.browse` interprets a path without a trailing separator as + /// a prefix search. Directory navigation always sends an explicit folder. + static func directoryBrowsePath(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, + !path.hasSuffix("/"), + !path.hasSuffix("\\") else { + return path + } + if isWindowsAbsolutePath(path) { + return path.replacingOccurrences(of: "/", with: "\\") + "\\" + } + return path + "/" + } + + static func parentBrowsePath(of value: String) -> String? { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + + if isWindowsAbsolutePath(path) { + let normalized = path.replacingOccurrences(of: "/", with: "\\") + if normalized.hasPrefix("\\\\") { + let components = normalized.dropFirst(2).split(separator: "\\") + guard components.count > 2 else { return nil } + return "\\\\" + + components.dropLast().joined(separator: "\\") + + "\\" + } + + var trimmed = normalized + while trimmed.count > 3, trimmed.hasSuffix("\\") { + trimmed.removeLast() + } + guard trimmed.count > 3, + let separator = trimmed.lastIndex(of: "\\") else { + return nil + } + let parent = String(trimmed[...separator]) + return parent.count == 3 ? parent : directoryBrowsePath(parent) + } + + var trimmed = path + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed.removeLast() + } + guard trimmed != "/", trimmed != "~", + let separator = trimmed.lastIndex(of: "/") else { + return nil + } + let parent = String(trimmed[...separator]) + guard parent != "~/" || trimmed != "~" else { return nil } + return directoryBrowsePath(parent) + } + + static func lastPathComponent(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = isWindowsAbsolutePath(path) + ? path.replacingOccurrences(of: "\\", with: "/") + : path + return normalized + .split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? path + } + + static func isCompatibleWithServerPath(_ value: String, serverPath: String) -> Bool { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let reference = serverPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.hasPrefix("~"), + isAbsoluteOrHomeRelative(reference) else { + return true + } + return isWindowsAbsolutePath(path) == isWindowsAbsolutePath(reference) + } + + static func normalizedForComparison(_ value: String) -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + let isWindows = isWindowsAbsolutePath(path) + var normalized = isWindows + ? path.replacingOccurrences(of: "\\", with: "/") + : path + while normalized.count > 1, normalized.hasSuffix("/") { + normalized.removeLast() + } + return isWindows ? normalized.lowercased() : normalized + } + + private static func isAbsoluteOrHomeRelative(_ path: String) -> Bool { + if path == "~" || path.hasPrefix("~/") || path.hasPrefix("~\\") { + return true + } + if path.hasPrefix("/") || path.hasPrefix("\\\\") { + return true + } + return isWindowsAbsolutePath(path) + } + + private static func isWindowsAbsolutePath(_ path: String) -> Bool { + if path.hasPrefix("\\\\") || path.hasPrefix("//") { return true } + let scalars = Array(path.unicodeScalars.prefix(3)) + return scalars.count == 3 + && CharacterSet.letters.contains(scalars[0]) + && scalars[1] == ":" + && (scalars[2] == "\\" || scalars[2] == "/") + } +} + +struct ProjectCreationValidationError: LocalizedError, Equatable { + let message: String + + var errorDescription: String? { message } +} diff --git a/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift new file mode 100644 index 000000000000..b9a593012947 --- /dev/null +++ b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift @@ -0,0 +1,1606 @@ +import SwiftUI + +public struct ProviderModelPicker: View { + public enum Style { + case row + case compact + } + + let providers: [FeatureProvider] + private let normalizedProviders: [FeatureProvider] + @Binding var selection: FeatureSelection? + let style: Style + let isLoading: Bool + let threadSelection: FeatureSelection? + let materializesDefaultSelection: Bool + private let onPresentationChange: ((Bool) -> Void)? + private let onRefresh: (@MainActor () async throws -> Void)? + + @State private var isPresented = false + @State private var preservesSelectionDuringRefresh = false + + public init( + providers: [FeatureProvider], + selection: Binding, + style: Style = .row, + isLoading: Bool = false, + threadSelection: FeatureSelection? = nil, + materializesDefaultSelection: Bool = true, + onRefresh: (@MainActor () async throws -> Void)? = nil, + onPresentationChange: ((Bool) -> Void)? = nil + ) { + self.providers = providers + normalizedProviders = ProviderModelCatalogNormalizer.normalized(providers) + _selection = selection + self.style = style + self.isLoading = isLoading + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.onRefresh = onRefresh + self.onPresentationChange = onPresentationChange + } + + public var body: some View { + Button { + onPresentationChange?(true) + isPresented = true + } label: { + switch style { + case .row: + HStack(spacing: 12) { + selectionMark(size: 22) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text("Model") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + Text(selectionLabel) + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + case .compact: + HStack(spacing: 5) { + selectionMark(size: 14) + Text(compactModelName) + .lineLimit(1) + .truncationMode(.middle) + if let compactReasoningSummary { + Text("· \(compactReasoningSummary)") + .fixedSize() + } + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + .fixedSize() + } + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textSecondary) + .compositingGroup() + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Choose model") + .accessibilityValue(selectionLabel) + .accessibilityIdentifier("model-picker") + .sheet(isPresented: $isPresented, onDismiss: { onPresentationChange?(false) }) { + ModelPickerSheet( + providers: normalizedProviders, + selection: $selection, + isLoading: isLoading, + threadSelection: threadSelection, + materializesDefaultSelection: materializesDefaultSelection, + onRefresh: onRefresh.map { refresh in + { + preservesSelectionDuringRefresh = true + defer { preservesSelectionDuringRefresh = false } + try await refresh() + } + } + ) + } + .onAppear(perform: materializeSelection) + .onChange(of: providers) { + if !preservesSelectionDuringRefresh { materializeSelection() } + } + .onChange(of: selection) { materializeSelection() } + } + + private var selectedOption: DailyUXModelOption? { + guard let resolvedSelection, + let provider = normalizedProviders.first(where: { + $0.id == resolvedSelection.providerID + }), + let model = provider.models.first(where: { $0.id == resolvedSelection.modelID }) else { + return nil + } + return DailyUXModelOption(provider: provider, model: model) + } + + private var resolvedSelection: FeatureSelection? { + if materializesDefaultSelection { + return ProviderModelSelectionResolver.materialized(selection, in: normalizedProviders) + } + return ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: selection, + inherited: threadSelection, + providers: normalizedProviders + ) + } + + private func materializeSelection() { + guard !normalizedProviders.isEmpty else { return } + let resolved = materializesDefaultSelection + ? ProviderModelSelectionResolver.materialized(selection, in: normalizedProviders) + : ThreadComposerModelSelectionPolicy.explicitSelection( + selection, + inherited: threadSelection, + providers: normalizedProviders + ) + guard selection != resolved else { return } + selection = resolved + } + + private var selectionLabel: String { + guard let selectedOption else { + return unavailableSelectionLabel + } + let base = "\(selectedOption.provider.name) · \(selectedOption.model.name)" + guard let resolvedSelection, + let summary = DailyUXModelOptions.summary( + for: selectedOption.model, + selections: resolvedSelection.options + ) else { + return base + } + return "\(base) · \(summary)" + } + + private var compactModelName: String { + guard let selectedOption else { + return unavailableSelectionLabel + } + return selectedOption.model.name + } + + private var compactReasoningSummary: String? { + guard let selectedOption, let resolvedSelection else { return nil } + return DailyUXModelOptions.reasoningSummary( + for: selectedOption.model, + selections: resolvedSelection.options + ) + } + + private var unavailableSelectionLabel: String { + if isLoading { return "Loading models" } + if normalizedProviders.isEmpty { return "No providers" } + if !normalizedProviders.contains(where: \.isAvailable) { return "Providers offline" } + if !normalizedProviders.contains(where: { $0.isAvailable && !$0.models.isEmpty }) { + return "No models" + } + return "Choose model" + } + + @ViewBuilder + private func selectionMark(size: CGFloat) -> some View { + if let provider = selectedOption?.provider { + ProviderIcon( + driver: provider.driver, + providerID: provider.id, + fallbackName: provider.name, + size: size + ) + } else { + Image(systemName: "cpu") + .font(.system(size: size * 0.72, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(width: size, height: size) + } + } +} + +private struct ModelPickerSheet: View { + @SwiftUI.Environment(\.dismiss) private var dismiss + let providers: [FeatureProvider] + @Binding var selection: FeatureSelection? + let isLoading: Bool + let threadSelection: FeatureSelection? + let materializesDefaultSelection: Bool + let onRefresh: (@MainActor () async throws -> Void)? + + @AppStorage("swift-ios.model-picker.favorites") private var favoriteStorage = "" + @AppStorage("swift-ios.model-picker.recents") private var recentStorage = "" + @State private var query = "" + @State private var configuring: DailyUXModelOption? + @State private var legacyModelsExpanded = false + @State private var catalogCache = ModelPickerCatalogCache() + @State private var draftSelection: FeatureSelection? + @State private var draftBaseSelection: FeatureSelection? + @State private var modelDrafts: [String: FeatureSelection] + @State private var hasEditedDraft = false + @State private var isRefreshing = false + @State private var refreshError: String? + + init( + providers: [FeatureProvider], + selection: Binding, + isLoading: Bool, + threadSelection: FeatureSelection?, + materializesDefaultSelection: Bool, + onRefresh: (@MainActor () async throws -> Void)? + ) { + self.providers = providers + _selection = selection + self.isLoading = isLoading + self.threadSelection = threadSelection + self.materializesDefaultSelection = materializesDefaultSelection + self.onRefresh = onRefresh + let initialSelection = Self.effectiveSelection( + explicit: selection.wrappedValue, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + _draftSelection = State(initialValue: initialSelection) + _draftBaseSelection = State(initialValue: initialSelection) + _modelDrafts = State(initialValue: initialSelection.map { + [DailyUXModelOption.key(providerID: $0.providerID, modelID: $0.modelID): $0] + } ?? [:]) + } + + var body: some View { + NavigationStack { + Group { + if isLoading, availableModelCount == 0 { + VStack(spacing: 12) { + Image(systemName: "cpu") + .font(.title2) + .foregroundStyle(T3Colors.textTertiary) + .accessibilityHidden(true) + Text("Loading models") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if availableModelCount == 0 { + ContentUnavailableView( + emptyStateTitle, + systemImage: emptyStateSymbol, + description: Text(emptyStateMessage) + ) + } else { + modelList + } + } + .background(T3Colors.background) + .navigationTitle("Choose model") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search models") + .toolbar { + if onRefresh != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + refreshCatalog() + } label: { + if isRefreshing { + Image(systemName: "hourglass") + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshing) + .accessibilityLabel(isRefreshing ? "Refreshing models" : "Refresh models") + .accessibilityIdentifier("model-picker-refresh") + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { applySelection() } + .fontWeight(.semibold) + .disabled(!hasDraftChanges) + .accessibilityIdentifier("model-picker-apply") + } + } + .navigationDestination(item: $configuring) { option in + ModelConfigurationView( + option: option, + currentSelection: pickerSelection + ) { configuredSelection in + draftSelection = configuredSelection + rememberDraft(configuredSelection) + hasEditedDraft = configuredSelection != committedSelection + configuring = nil + } + } + .t3NavigationChrome() + .alert("Could not refresh models", isPresented: Binding( + get: { refreshError != nil }, + set: { if !$0 { refreshError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(refreshError ?? "Try again.") + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .onAppear { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: selection) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: threadSelection) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + .onChange(of: providers) { + reconcileDraftSelectionWithCurrentState() + revealSelectedLegacyModel() + } + } + + private func refreshCatalog() { + guard let onRefresh, !isRefreshing else { return } + isRefreshing = true + refreshError = nil + Task { + do { + try await onRefresh() + } catch { + refreshError = error.localizedDescription + } + isRefreshing = false + } + } + + private var modelList: some View { + let catalog = cachedCatalog + let sections = ProviderModelDisplaySections(catalog: catalog) + let isSearching = !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return List { + if modelChangesAreLocked { + Section { + Label( + "This task cannot change models.", + systemImage: "lock" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + + if isSearching { + ForEach(catalog.all) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } else { + if !sections.favorites.isEmpty { + Section("Favorites") { + ForEach(sections.favorites) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + if !sections.recents.isEmpty { + Section("Recent") { + ForEach(sections.recents) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + ForEach(sections.currentProviderGroups, id: \.provider.id) { group in + Section(group.provider.name) { + ForEach(group.models) { option in + modelRow( + option, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } + } + + if !sections.legacy.isEmpty { + Section { + DisclosureGroup(isExpanded: $legacyModelsExpanded) { + ForEach(sections.legacy) { option in + modelRow( + option, + showsProvider: true, + disambiguatesModel: sections.disambiguatedModelIDs.contains(option.id) + ) + } + } label: { + HStack { + Text("Legacy models") + .font(T3Typography.control.weight(.semibold)) + Spacer() + Text("\(sections.legacy.count)") + .font(T3Typography.supporting.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + } + } + } + } + + if catalog.all.isEmpty { + ContentUnavailableView.search(text: query) + .listRowBackground(Color.clear) + } + + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .background(T3Colors.background) + .safeAreaInset(edge: .bottom, spacing: 0) { + selectionControls + } + } + + private var emptyStateTitle: String { + if providers.isEmpty { return "No providers" } + if !providers.contains(where: \.isAvailable) { return "Providers offline" } + return "No models available" + } + + private var emptyStateSymbol: String { + providers.isEmpty || !providers.contains(where: \.isAvailable) + ? "wifi.slash" + : "cpu" + } + + private var emptyStateMessage: String { + if providers.isEmpty { return "Connect an environment to see its models." } + if !providers.contains(where: \.isAvailable) { + return "Reconnect this environment to choose a model." + } + return "This environment has no available models." + } + + private var availableModelCount: Int { + pickerProviders + .filter(\.isAvailable) + .reduce(into: 0) { count, provider in + count += provider.models.count + } + } + + private func modelRow( + _ option: DailyUXModelOption, + showsProvider: Bool = false, + disambiguatesModel: Bool = false + ) -> some View { + let isSelected = pickerSelection?.providerID == option.provider.id + && pickerSelection?.modelID == option.model.id + let isFavorite = favoriteIDs.contains(option.id) + return HStack(spacing: 10) { + Button { + select(option) + } label: { + ModelOptionLabel( + option: option, + isSelected: isSelected, + showsProvider: showsProvider, + disambiguatesModel: disambiguatesModel + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLocked(option)) + .opacity(isLocked(option) ? 0.36 : 1) + .accessibilityLabel(option.model.name) + .accessibilityValue( + disambiguatesModel + ? "\(option.provider.name), \(option.model.id)" + : option.provider.name + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityIdentifier("model-option-\(option.id)") + .accessibilityHint( + isLocked(option) + ? "This task cannot change models." + : "Select this model." + ) + + Button { + toggleFavorite(option.id) + } label: { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 15)) + .foregroundStyle( + isFavorite ? T3Colors.warning : T3Colors.textTertiary + ) + .frame( + width: T3Metrics.minimumTapTarget, + height: T3Metrics.minimumTapTarget + ) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityLabel(isFavorite ? "Remove from favorites" : "Add to favorites") + .accessibilityValue(option.model.name) + } + .listRowBackground(T3Colors.background) + } + + @ViewBuilder + private var selectionControls: some View { + if let selectedOption { + VStack(alignment: .leading, spacing: 10) { + Divider() + if let descriptor = DailyUXModelOptions.reasoningDescriptor( + for: selectedOption.model + ) { + modelOptionControl(descriptor) + optionFooter(for: descriptor) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } else { + VStack(alignment: .leading, spacing: 3) { + Text("Reasoning effort") + .font(T3Typography.control) + .foregroundStyle(T3Colors.textPrimary) + Text("This environment does not describe reasoning effort choices for this model.") + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("reasoning-effort-unavailable") + } + + if !DailyUXModelOptions.advancedDescriptors(for: selectedOption.model).isEmpty + || !undescribedSelections.isEmpty { + Button { + configuring = selectedOption + } label: { + HStack { + Text("Advanced options") + .foregroundStyle(T3Colors.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(T3Typography.supportingStrong) + .foregroundStyle(T3Colors.textTertiary) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("advanced-model-options") + + if !undescribedSelections.isEmpty { + Text(undescribedOptionsMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background(T3Colors.background) + } + } + + private var selectedOption: DailyUXModelOption? { + guard let pickerSelection, + let provider = providers.first(where: { $0.id == pickerSelection.providerID }), + let model = provider.models.first(where: { $0.id == pickerSelection.modelID }) else { + return nil + } + return DailyUXModelOption(provider: provider, model: model) + } + + private var undescribedSelections: [FeatureModelOptionSelection] { + guard let selectedOption, let pickerSelection else { return [] } + return DailyUXModelOptions.undescribedSelections( + for: selectedOption.model, + selections: pickerSelection.options + ) + } + + private var undescribedOptionsMessage: String { + let optionIDs = undescribedSelections.map(\.id).joined(separator: ", ") + return "This environment does not describe these saved options: \(optionIDs). They will be kept when you apply." + } + + @ViewBuilder + private func modelOptionControl( + _ descriptor: FeatureModelOptionDescriptor + ) -> some View { + switch descriptor.kind { + case .select: + HStack { + Text("Reasoning effort") + .font(T3Typography.control) + Spacer() + if descriptor.choices.isEmpty { + Text("No choices available") + .foregroundStyle(T3Colors.textSecondary) + .accessibilityIdentifier("reasoning-effort-control") + } else { + Menu { + ForEach(descriptor.choices) { choice in + Button { + updateDraftOption( + id: descriptor.id, + value: .string(choice.id) + ) + } label: { + if isSelected(choice, for: descriptor) { + Label(choice.label, systemImage: "checkmark") + } else { + Text(choice.label) + } + } + } + } label: { + HStack(spacing: 5) { + Text(optionValueLabel(for: descriptor)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(T3Colors.textPrimary) + } + .accessibilityLabel("Reasoning effort") + .accessibilityValue(optionValueLabel(for: descriptor)) + .accessibilityIdentifier("reasoning-effort-control") + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + case .boolean: + Toggle("Reasoning effort", isOn: booleanBinding(for: descriptor)) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("reasoning-effort-control") + } + } + + @ViewBuilder + private func optionFooter( + for descriptor: FeatureModelOptionDescriptor + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let detail = descriptor.detail { + Text(detail) + } + if let value = currentValue(for: descriptor), + !DailyUXModelOptions.isSupportedValue(value, for: descriptor) { + Text("The saved value is not listed by this environment. Choose a listed value or keep the saved value.") + } else if descriptor.kind == .select, descriptor.choices.isEmpty { + Text("This environment did not provide choices for this option.") + } + } + } + + private func optionValueLabel( + for descriptor: FeatureModelOptionDescriptor + ) -> String { + guard case let .string(value) = currentValue(for: descriptor) else { + return "Choose" + } + return descriptor.choices.first(where: { $0.id == value })?.label ?? value + } + + private func currentValue( + for descriptor: FeatureModelOptionDescriptor + ) -> FeatureModelOptionValue? { + DailyUXModelOptions.value( + for: descriptor, + in: pickerSelection?.options ?? [] + ) + } + + private func updateDraftOption(id: String, value: FeatureModelOptionValue) { + guard var next = pickerSelection else { return } + next.options = DailyUXModelOptions.updating(next.options, id: id, value: value) + draftSelection = next + rememberDraft(next) + hasEditedDraft = next != committedSelection + } + + private func isSelected( + _ choice: FeatureModelOptionChoice, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + currentValue(for: descriptor) == .string(choice.id) + } + + private func booleanBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { + guard case let .boolean(value) = currentValue(for: descriptor) else { + return false + } + return value + }, + set: { updateDraftOption(id: descriptor.id, value: .boolean($0)) } + ) + } + + private var favoriteIDs: Set { + Set(favoriteStorage.split(separator: "\n").map(String.init)) + } + + private var recentIDs: [String] { + recentStorage.split(separator: "\n").map(String.init) + } + + private var cachedCatalog: DailyUXModelCatalog { + catalogCache.catalog( + providers: pickerProviders, + query: query, + favoriteStorage: favoriteStorage, + recentStorage: recentStorage + ) + } + + private var pickerProviders: [FeatureProvider] { + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) + } + + private var displaySections: ProviderModelDisplaySections { + ProviderModelDisplaySections(catalog: cachedCatalog) + } + + private var committedSelection: FeatureSelection? { + Self.effectiveSelection( + explicit: selection, + inherited: threadSelection, + providers: providers, + materializesDefaultSelection: materializesDefaultSelection + ) + } + + private var pickerSelection: FeatureSelection? { + draftSelection ?? committedSelection + } + + private func select(_ option: DailyUXModelOption) { + guard !isLocked(option) else { return } + let next = ProviderModelDraftPolicy.selection( + for: option, + cached: modelDrafts[option.id], + current: pickerSelection, + committed: committedSelection + ) + draftSelection = next + rememberDraft(next) + hasEditedDraft = next != committedSelection + } + + private var hasDraftChanges: Bool { + hasEditedDraft && draftSelection != nil && draftSelection != committedSelection + } + + private func applySelection() { + guard hasDraftChanges else { return } + guard let validated = ProviderModelDraftPolicy.validated( + draftSelection, + providers: providers, + inheriting: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) else { + replaceDraft(with: committedSelection) + return + } + selection = validated + recordRecent(DailyUXModelOption.key( + providerID: validated.providerID, + modelID: validated.modelID + )) + dismiss() + } + + private func reconcileDraftSelectionWithCurrentState() { + let committed = committedSelection + guard hasEditedDraft else { + replaceDraft(with: committed) + return + } + guard ProviderModelDraftPolicy.canKeepEditedDraft( + base: draftBaseSelection, + currentCommitted: committed, + draft: draftSelection, + providers: providers, + inheriting: threadSelection, + allowsProviderChange: materializesDefaultSelection + ) else { + replaceDraft(with: committed) + return + } + } + + private func replaceDraft(with value: FeatureSelection?) { + draftSelection = value + draftBaseSelection = value + modelDrafts = value.map { + [DailyUXModelOption.key(providerID: $0.providerID, modelID: $0.modelID): $0] + } ?? [:] + hasEditedDraft = false + } + + private func rememberDraft(_ value: FeatureSelection) { + modelDrafts[DailyUXModelOption.key( + providerID: value.providerID, + modelID: value.modelID + )] = value + } + + private func recordRecent(_ id: String) { + recentStorage = ([id] + recentIDs.filter { $0 != id }) + .prefix(8) + .joined(separator: "\n") + } + + private func revealSelectedLegacyModel() { + guard !legacyModelsExpanded, let pickerSelection else { return } + if displaySections.legacy.contains(where: { + $0.provider.id == pickerSelection.providerID + && $0.model.id == pickerSelection.modelID + }) { + legacyModelsExpanded = true + } + } + + private var modelChangesAreLocked: Bool { + guard let threadSelection, + let provider = providers.first(where: { $0.id == threadSelection.providerID }) else { + return false + } + return provider.requiresNewThreadForModelChange + } + + private func isLocked(_ option: DailyUXModelOption) -> Bool { + guard let threadSelection else { return false } + if option.provider.id != threadSelection.providerID { return true } + return modelChangesAreLocked && option.model.id != threadSelection.modelID + } + + private func toggleFavorite(_ id: String) { + var next = favoriteIDs + if next.contains(id) { + next.remove(id) + } else { + next.insert(id) + } + favoriteStorage = next.sorted().joined(separator: "\n") + } + + private static func effectiveSelection( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider], + materializesDefaultSelection: Bool + ) -> FeatureSelection? { + if materializesDefaultSelection { + return ProviderModelSelectionResolver.materialized(explicit, in: providers) + } + return ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: providers + ) + } +} + +enum ProviderModelDraftPolicy { + static func selection( + for option: DailyUXModelOption, + cached: FeatureSelection?, + current: FeatureSelection?, + committed: FeatureSelection? + ) -> FeatureSelection { + if let cached, matches(cached, option: option) { + return ProviderModelConfiguration.selection(for: option, preserving: cached) + } + if let committed, matches(committed, option: option) { + return ProviderModelConfiguration.selection(for: option, preserving: committed) + } + return ProviderModelConfiguration.selection(for: option, preserving: current) + } + + static func validated( + _ selection: FeatureSelection?, + providers: [FeatureProvider], + inheriting inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> FeatureSelection? { + guard let selection, + providers.contains(where: { provider in + provider.id == selection.providerID + && provider.isAvailable + && provider.models.contains { $0.id == selection.modelID } + }) else { + return nil + } + guard let validated = ProviderModelSelectionResolver.validated(selection, in: providers) + else { + return nil + } + if !allowsProviderChange { + guard let inherited else { return nil } + guard validated.providerID == inherited.providerID else { return nil } + let inheritedProvider = providers.first { $0.id == inherited.providerID } + if inheritedProvider?.requiresNewThreadForModelChange == true, + validated.modelID != inherited.modelID { + return nil + } + } + return validated + } + + static func canKeepEditedDraft( + base: FeatureSelection?, + currentCommitted: FeatureSelection?, + draft: FeatureSelection?, + providers: [FeatureProvider], + inheriting inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> Bool { + base == currentCommitted + && validated( + draft, + providers: providers, + inheriting: inherited, + allowsProviderChange: allowsProviderChange + ) != nil + } + + private static func matches( + _ selection: FeatureSelection, + option: DailyUXModelOption + ) -> Bool { + selection.providerID == option.provider.id + && selection.modelID == option.model.id + } +} + +/// SwiftUI computed properties are ordinary function calls. The picker reads +/// its catalog throughout one body evaluation and invalidates on every search +/// keystroke, so retain the last derivation by its real inputs. +@MainActor +private final class ModelPickerCatalogCache { + private struct Key: Equatable { + let providers: [FeatureProvider] + let query: String + let favoriteStorage: String + let recentStorage: String + } + + private var key: Key? + private var value: DailyUXModelCatalog? + + func catalog( + providers: [FeatureProvider], + query: String, + favoriteStorage: String, + recentStorage: String + ) -> DailyUXModelCatalog { + let key = Key( + providers: providers, + query: query, + favoriteStorage: favoriteStorage, + recentStorage: recentStorage + ) + if self.key == key, let value { return value } + + let value = DailyUXModelCatalog( + providers: ProviderModelSearch.matching(providers, query: query), + query: "", + favoriteIDs: Set(favoriteStorage.split(separator: "\n").map(String.init)), + recentIDs: recentStorage.split(separator: "\n").map(String.init) + ) + self.key = key + self.value = value + return value + } +} + +enum ProviderModelSearch { + static func matching(_ providers: [FeatureProvider], query: String) -> [FeatureProvider] { + let terms = query.split { !$0.isLetter && !$0.isNumber }.map(String.init) + guard !terms.isEmpty else { return providers } + + return providers.compactMap { provider in + var matchingProvider = provider + matchingProvider.models = provider.models.filter { model in + let searchableFields = [ + provider.name, + provider.id, + model.name, + model.id, + model.detail ?? "", + model.supportsImages ? "images vision" : "", + ] + return terms.allSatisfy { term in + searchableFields.contains { $0.localizedCaseInsensitiveContains(term) } + } + } + return matchingProvider.models.isEmpty ? nil : matchingProvider + } + } +} + +/// The picker never represents an implicit "automatic" model. A missing or stale +/// selection becomes the environment's concrete preferred model as soon as the +/// catalog is available. +enum ProviderModelSelectionResolver { + static func validated( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard !providers.isEmpty else { return selection } + guard var validated = DailyUXModelOptions.validated(selection, in: providers), + let model = providers + .first(where: { $0.id == validated.providerID })? + .models.first(where: { $0.id == validated.modelID }) else { + return nil + } + validated.options = ProviderModelConfiguration.materializedOptions( + for: model, + preserving: validated.options + ) + return validated + } + + static func materialized( + _ selection: FeatureSelection?, + in providers: [FeatureProvider] + ) -> FeatureSelection? { + guard !providers.isEmpty else { return selection } + if let validated = validated(selection, in: providers) { + return validated + } + let currentProviders = providers.compactMap { provider -> FeatureProvider? in + var current = provider + current.models = provider.models.filter { + ProviderModelFamilyClassifier.isCurrent($0, provider: provider) + } + return current.models.isEmpty ? nil : current + } + return DailyUXModelOptions.preferredSelection(in: currentProviders) + ?? DailyUXModelOptions.preferredSelection(in: providers) + } +} + +/// Existing threads inherit their persisted model until the user deliberately +/// chooses an override. Unlike new-task composers, a missing selection must not +/// materialize the environment default and silently change providers. +enum ThreadComposerModelSelectionPolicy { + static func pickerProviders( + _ providers: [FeatureProvider], + inherited: FeatureSelection?, + allowsProviderChange: Bool + ) -> [FeatureProvider] { + if allowsProviderChange { return providers } + guard let inherited else { return [] } + return providers.filter { $0.id == inherited.providerID } + } + + static func resolvedSelection( + explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + explicitSelection(explicit, inherited: inherited, providers: providers) + ?? preservedSelection(inherited, providers: providers) + } + + static func explicitSelection( + _ explicit: FeatureSelection?, + inherited: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + guard let explicit, let inherited else { return nil } + guard explicit.providerID == inherited.providerID else { return nil } + let inheritedProvider = providers.first { $0.id == inherited.providerID } + if inheritedProvider?.requiresNewThreadForModelChange == true, + explicit.modelID != inherited.modelID { + return nil + } + + // An environment refresh can briefly remove a provider or a custom + // model from discovery. Keep an existing override until discovery can + // validate it again. Applying a new choice still uses the stricter + // ProviderModelDraftPolicy validation path. + return ProviderModelSelectionResolver.validated(explicit, in: providers) + ?? explicit + } + + private static func preservedSelection( + _ selection: FeatureSelection?, + providers: [FeatureProvider] + ) -> FeatureSelection? { + guard var selection else { return nil } + guard let model = providers + .first(where: { $0.id == selection.providerID })? + .models.first(where: { $0.id == selection.modelID }) else { + return selection + } + selection.options = ProviderModelConfiguration.materializedOptions( + for: model, + preserving: selection.options + ) + return selection + } +} + +/// Existing threads use their saved environment and provider instance as the +/// catalog identity. Project rows and provider discovery can be temporarily +/// absent, but neither should make the thread's saved model disappear. +enum ThreadComposerProviderCatalog { + static func providers( + for thread: FeatureThread, + in snapshot: FeatureSnapshot + ) -> [FeatureProvider] { + let environmentID = thread.environmentID + ?? snapshot.projects.first(where: { $0.id == thread.projectID })?.environmentID + var providers = environmentID.flatMap { + snapshot.providersByEnvironment?[$0] + } ?? [] + + guard let providerID = thread.providerID, + let modelID = thread.modelID else { + return providers + } + + let savedModel = FeatureModel(id: modelID, name: modelID) + if let providerIndex = providers.firstIndex(where: { $0.id == providerID }) { + guard !providers[providerIndex].models.contains(where: { $0.id == modelID }) else { + return providers + } + providers[providerIndex].models.append(savedModel) + return providers + } + + let providerName = thread.providerName? + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedProviderName = providerName.flatMap { name in + name.isEmpty ? nil : name + } ?? providerID + providers.append(FeatureProvider( + id: providerID, + name: resolvedProviderName, + isAvailable: false, + models: [savedModel] + )) + return providers + } +} + +enum ProviderModelCatalogNormalizer { + static func normalized(_ providers: [FeatureProvider]) -> [FeatureProvider] { + var order: [String] = [] + var providersByID: [String: FeatureProvider] = [:] + var modelIDsByProvider: [String: Set] = [:] + + for provider in providers { + let visibleModels = provider.models.filter { !isImplicitModel($0) } + if var existing = providersByID[provider.id] { + existing.isAvailable = existing.isAvailable || provider.isAvailable + existing.requiresNewThreadForModelChange = + existing.requiresNewThreadForModelChange + || provider.requiresNewThreadForModelChange + if existing.name.isEmpty { + existing.name = provider.name + } + if existing.driver.isEmpty { + existing.driver = provider.driver + } + existing.slashCommands = mergingMetadata( + existing.slashCommands, + provider.slashCommands, + id: \.id + ) + existing.skills = mergingMetadata( + existing.skills, + provider.skills, + id: \.id + ) + providersByID[provider.id] = existing + } else { + var normalized = provider + normalized.models = [] + providersByID[provider.id] = normalized + modelIDsByProvider[provider.id] = [] + order.append(provider.id) + } + + for model in visibleModels { + let wasInserted = modelIDsByProvider[provider.id, default: []] + .insert(model.id) + .inserted + if wasInserted { + providersByID[provider.id]?.models.append(model) + } + } + } + + return order.compactMap { providersByID[$0] } + } + + private static func mergingMetadata( + _ first: [Value]?, + _ second: [Value]?, + id: KeyPath + ) -> [Value]? { + guard first != nil || second != nil else { return nil } + var seen = Set() + return ((first ?? []) + (second ?? [])).filter { + seen.insert($0[keyPath: id]).inserted + } + } + + private static func isImplicitModel(_ model: FeatureModel) -> Bool { + let tokens = [model.id, model.name].flatMap { + $0.lowercased() + .split { !$0.isLetter && !$0.isNumber } + .map(String.init) + } + return tokens.contains("automatic") || tokens.contains("auto") + } +} + +struct ProviderModelDisplaySections { + let favorites: [DailyUXModelOption] + let recents: [DailyUXModelOption] + let currentProviderGroups: [( + provider: FeatureProvider, + models: [DailyUXModelOption] + )] + let legacy: [DailyUXModelOption] + let disambiguatedModelIDs: Set + + init(catalog: DailyUXModelCatalog) { + let currentIDs = Set(catalog.all.compactMap { option in + ProviderModelFamilyClassifier.isCurrent( + option.model, + provider: option.provider + ) ? option.id : nil + }) + favorites = catalog.favorites + var seenRecentIDs = Set() + recents = catalog.recents.filter { + currentIDs.contains($0.id) && seenRecentIDs.insert($0.id).inserted + } + let promoted = Set((favorites + recents).map(\.id)) + currentProviderGroups = catalog.providerGroups.compactMap { group in + let models = group.models.filter { + currentIDs.contains($0.id) && !promoted.contains($0.id) + } + return models.isEmpty ? nil : (group.provider, models) + } + legacy = catalog.all.filter { !currentIDs.contains($0.id) && !promoted.contains($0.id) } + + let matchingLabels = Dictionary(grouping: catalog.all) { option in + ModelPresentationKey( + providerName: option.provider.name, + name: option.model.name, + detail: option.model.detail ?? option.model.id + ) + } + disambiguatedModelIDs = Set( + matchingLabels.values + .filter { $0.count > 1 } + .flatMap { $0.map(\.id) } + ) + } + + private struct ModelPresentationKey: Hashable { + let providerName: String + let name: String + let detail: String + } +} + +enum ProviderModelFamilyClassifier { + static func isCurrent(_ model: FeatureModel, provider _: FeatureProvider) -> Bool { + model.isLegacy != true + } +} + +private struct ModelConfigurationView: View { + let option: DailyUXModelOption + let onConfirm: (FeatureSelection) -> Void + @State private var optionSelections: [FeatureModelOptionSelection] + + init( + option: DailyUXModelOption, + currentSelection: FeatureSelection?, + onConfirm: @escaping (FeatureSelection) -> Void + ) { + self.option = option + self.onConfirm = onConfirm + _optionSelections = State(initialValue: ProviderModelConfiguration.selection( + for: option, + preserving: currentSelection + ).options) + } + + var body: some View { + Form { + Section { + ModelOptionLabel(option: option, isSelected: false) + } + + ForEach(DailyUXModelOptions.advancedDescriptors(for: option.model)) { descriptor in + Section { + switch descriptor.kind { + case .select: + HStack { + Text(descriptor.label) + Spacer() + if descriptor.choices.isEmpty { + Text("No choices available") + .foregroundStyle(T3Colors.textSecondary) + } else { + Menu { + ForEach(descriptor.choices) { choice in + Button { + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: .string(choice.id) + ) + } label: { + if isSelected(choice, for: descriptor) { + Label(choice.label, systemImage: "checkmark") + } else { + Text(choice.label) + } + } + } + } label: { + HStack(spacing: 5) { + Text(optionValueLabel(for: descriptor)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(T3Colors.textPrimary) + } + } + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("advanced-option-\(descriptor.id)") + .accessibilityValue(optionValueLabel(for: descriptor)) + case .boolean: + Toggle( + descriptor.label, + isOn: booleanBinding(for: descriptor) + ) + .frame(minHeight: T3Metrics.minimumTapTarget) + .accessibilityIdentifier("advanced-option-\(descriptor.id)") + } + } footer: { + optionFooter(for: descriptor) + } + } + + if !undescribedSelections.isEmpty { + Section("Saved options") { + Text(undescribedOptionsMessage) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } + } + } + .scrollContentBackground(.hidden) + .background(T3Colors.background) + .navigationTitle("Model options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save options") { + onConfirm( + FeatureSelection( + providerID: option.provider.id, + modelID: option.model.id, + options: optionSelections + ) + ) + } + .fontWeight(.semibold) + } + } + } + + private var undescribedSelections: [FeatureModelOptionSelection] { + DailyUXModelOptions.undescribedSelections( + for: option.model, + selections: optionSelections + ) + } + + private var undescribedOptionsMessage: String { + let optionIDs = undescribedSelections.map(\.id).joined(separator: ", ") + return "This environment does not describe these saved options: \(optionIDs). T3 Code will keep them." + } + + private func optionValueLabel( + for descriptor: FeatureModelOptionDescriptor + ) -> String { + guard case let .string(value) = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) else { + return "Choose" + } + return descriptor.choices.first(where: { $0.id == value })?.label ?? value + } + + private func isSelected( + _ choice: FeatureModelOptionChoice, + for descriptor: FeatureModelOptionDescriptor + ) -> Bool { + DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) == .string(choice.id) + } + + @ViewBuilder + private func optionFooter( + for descriptor: FeatureModelOptionDescriptor + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + if let detail = descriptor.detail { + Text(detail) + } + if let value = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ), !DailyUXModelOptions.isSupportedValue(value, for: descriptor) { + Text("The saved value is not listed by this environment. Choose a listed value or keep the saved value.") + } else if descriptor.kind == .select, descriptor.choices.isEmpty { + Text("This environment did not provide choices for this option.") + } + } + } + + private func booleanBinding( + for descriptor: FeatureModelOptionDescriptor + ) -> Binding { + Binding( + get: { + guard case let .boolean(value) = DailyUXModelOptions.value( + for: descriptor, + in: optionSelections + ) else { + return false + } + return value + }, + set: { value in + optionSelections = DailyUXModelOptions.updating( + optionSelections, + id: descriptor.id, + value: .boolean(value) + ) + } + ) + } +} + +enum ProviderModelConfiguration { + static func selection( + for option: DailyUXModelOption, + preserving currentSelection: FeatureSelection? + ) -> FeatureSelection { + let selections: [FeatureModelOptionSelection] + if currentSelection?.providerID == option.provider.id, + currentSelection?.modelID == option.model.id { + selections = materializedOptions( + for: option.model, + preserving: currentSelection?.options ?? [] + ) + } else { + selections = DailyUXModelOptions.defaults(for: option.model) + } + return FeatureSelection( + providerID: option.provider.id, + modelID: option.model.id, + options: selections + ) + } + + static func materializedOptions( + for model: FeatureModel, + preserving selections: [FeatureModelOptionSelection] + ) -> [FeatureModelOptionSelection] { + let selectedIDs = Set(selections.map(\.id)) + return selections + DailyUXModelOptions.defaults(for: model).filter { + !selectedIDs.contains($0.id) + } + } +} + +private struct ModelOptionLabel: View { + let option: DailyUXModelOption + let isSelected: Bool + var showsProvider = false + var disambiguatesModel = false + + var body: some View { + HStack(spacing: 12) { + providerMark + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Text(option.model.name) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(1) + if option.model.supportsImages { + capability("Images", icon: "photo") + } + } + Text(modelDetail) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(1) + } + Spacer(minLength: 6) + if isSelected { + Image(systemName: "checkmark") + .font(.subheadline.weight(.bold)) + .foregroundStyle(T3Colors.textPrimary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + + private var modelDetail: String { + let detail = disambiguatesModel + ? option.model.id + : option.model.detail ?? option.model.id + return showsProvider ? "\(option.provider.name) · \(detail)" : detail + } + + private var providerMark: some View { + ProviderIcon( + driver: option.provider.driver, + providerID: option.provider.id, + fallbackName: option.provider.name, + size: 26 + ) + } + + private func capability(_ title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } +} diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift new file mode 100644 index 000000000000..b7771c2c0a7f --- /dev/null +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -0,0 +1,1493 @@ +import SwiftUI +import UIKit + +struct FeatureWorkspaceNavigationRequest: Equatable, Sendable { + enum Destination: Equatable, Sendable { + case thread(id: String) + case project(id: String) + case newTask(projectID: String?) + } + + let id: UUID + let destination: Destination + + init(id: UUID = UUID(), destination: Destination) { + self.id = id + self.destination = destination + } +} + +struct WorkspaceThreadSelection: Equatable { + private(set) var selectedID: String? + private(set) var lastOpenedID: String? + + var highlightedID: String? { selectedID ?? lastOpenedID } + + mutating func open(_ id: String) { + selectedID = id + lastOpenedID = id + } + + mutating func close() { + selectedID = nil + } +} + +public struct WorkspaceView: View { + @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @Bindable var model: FeatureRootModel + private let navigationRequest: FeatureWorkspaceNavigationRequest? + private let onNavigationRequestConsumed: @MainActor (UUID) -> Void + private let submitNewTask: (NewTaskRequest) async -> FeatureThread? + private let submitMessage: (FeatureMessageSubmission) async -> Bool + + @State private var threadSelection = WorkspaceThreadSelection() + @State private var selectedProjectID: String? + @State private var searchText = "" + @State private var isSearching = false + @AppStorage("t3.swiftui.home.snoozedExpanded") private var isSnoozedExpanded = false + @AppStorage("t3.swiftui.home.settledExpanded") private var isSettledExpanded = true + @AppStorage("t3.swiftui.home.archiveExpanded") private var isArchiveExpanded = false + @State private var settledLimit = 10 + @State private var showingNewTask = false + @State private var newTaskInitialProjectID: String? + @State private var showingAddProject = false + @State private var showingEnvironments = false + @State private var showingSettings = false + @State private var showingPrism = false + @State private var renamingThread: FeatureThread? + @State private var deletingThread: FeatureThread? + @State private var renameTitle = "" + @State private var sidebarBoundaryNow = Date.now + @State private var preferredCompactColumn = NavigationSplitViewColumn.sidebar + @State private var homePresentationCache = HomePresentationCache() + @FocusState private var isSearchFocused: Bool + + public init( + model: FeatureRootModel, + submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + ) { + self.init( + model: model, + navigationRequest: nil, + onNavigationRequestConsumed: { _ in }, + submitNewTask: submitNewTask, + submitMessage: submitMessage + ) + } + + init( + model: FeatureRootModel, + navigationRequest: FeatureWorkspaceNavigationRequest?, + onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void, + submitNewTask: ((NewTaskRequest) async -> FeatureThread?)? = nil, + submitMessage: ((FeatureMessageSubmission) async -> Bool)? = nil + ) { + self.model = model + self.navigationRequest = navigationRequest + self.onNavigationRequestConsumed = onNavigationRequestConsumed + self.submitNewTask = submitNewTask ?? { request in + do { + let thread = try await model.client.createThreadAndSend( + projectID: request.projectID, + prompt: request.trimmedPrompt, + selection: request.selection, + runtimeMode: request.runtimeMode, + interactionMode: request.interactionMode, + workspaceMode: request.workspaceMode, + branch: request.branch, + worktreePath: request.worktreePath, + startFromOrigin: request.startFromOrigin, + attachments: request.attachments.map(\.uploadValue) + ) + await model.reload() + return thread + } catch { + return nil + } + } + self.submitMessage = submitMessage ?? { submission in + if submission.attachments.isEmpty { + return await model.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection + ) + } + do { + try await model.client.sendMessage( + threadID: submission.threadID, + text: submission.text, + selection: submission.selection, + attachments: submission.attachments.map(\.uploadValue) + ) + _ = await model.detail(for: submission.threadID, force: true) + return true + } catch { + return false + } + } + } + + public var body: some View { + NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { + sidebar + .navigationSplitViewColumnWidth( + min: T3Metrics.minimumSidebarWidth, + ideal: T3Metrics.sidebarWidth, + max: T3Metrics.maximumSidebarWidth + ) + } detail: { + detail + } + .navigationSplitViewStyle(.balanced) + .sheet(isPresented: $showingNewTask) { + NewThreadView( + model: model, + submit: submitNewTask, + onCreated: { thread in + openThread(thread.id) + showingNewTask = false + }, + onCreateProject: openProjectCreation, + initialProjectID: newTaskInitialProjectID + ) + } + .sheet(isPresented: $showingAddProject) { + AddProjectView(model: model) + } + .sheet(isPresented: $showingEnvironments) { + NavigationStack { + ConnectionsView(model: model) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showingEnvironments = false } + } + } + } + .presentationDragIndicator(.visible) + .onAppear { model.setConnectionManagementPresented(true) } + .onDisappear { model.setConnectionManagementPresented(false) } + } + .sheet(isPresented: $showingPrism) { + NavigationStack { + PrismView(client: model.client, environments: model.snapshot.environments) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPrism = false } } } + } + } + .sheet(isPresented: $showingSettings) { + SettingsView(model: model) + } + .alert( + "Rename thread", + isPresented: Binding( + get: { renamingThread != nil }, + set: { if !$0 { renamingThread = nil } } + ) + ) { + TextField("Thread title", text: $renameTitle) + Button("Cancel", role: .cancel) { renamingThread = nil } + Button("Save") { + guard let thread = renamingThread else { return } + let title = renameTitle + renamingThread = nil + Task { await model.renameThread(thread.id, title: title) } + } + .disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .alert( + "Delete thread?", + isPresented: Binding( + get: { deletingThread != nil }, + set: { if !$0 { deletingThread = nil } } + ), + presenting: deletingThread + ) { thread in + Button("Delete", role: .destructive) { + deletingThread = nil + Task { await model.deleteThread(thread.id) } + } + Button("Cancel", role: .cancel) { deletingThread = nil } + } message: { thread in + Text("\"\(thread.title)\" and its terminal history will be permanently deleted.") + } + .onChange(of: selectedThreadIsAvailable) { _, isAvailable in + if !isAvailable { closeSelectedThread() } + } + .onChange(of: selectedThreadID) { _, newValue in + preferredCompactColumn = newValue == nil ? .sidebar : .detail + } + .onChange(of: selectedProjectIsAvailable) { _, isAvailable in + if !isAvailable { selectedProjectID = nil } + } + .onChange(of: navigationRequest?.id, initial: true) { _, _ in + consumeNavigationRequest() + } + // A request that arrives before its thread or project exists in the + // snapshot stays pending; retry it as data lands so cold-start deep + // links are not silently stranded. + .onChange(of: model.homePresentationRevision) { _, _ in + if navigationRequest != nil { consumeNavigationRequest() } + } + .task(id: nextSidebarBoundary) { + guard let boundary = nextSidebarBoundary else { return } + do { + try await Task.sleep(for: .seconds(max(0, boundary.timeIntervalSinceNow))) + sidebarBoundaryNow = max(.now, boundary) + } catch { + return + } + } + } + + private var sidebar: some View { + ZStack(alignment: .bottomTrailing) { + VStack(spacing: 0) { + homeBar + if isSearching { + searchBar + .transition(.opacity.combined(with: .move(edge: .top))) + } + threadList + } + + composeButton + .padding(.trailing, 16) + .padding(.bottom, 14) + } + .background(T3Colors.background) + .toolbar(.hidden, for: .navigationBar) + .onChange(of: selectedProjectID) { + settledLimit = 10 + } + } + + private var threadList: some View { + let presentation = homePresentationCache.presentation( + snapshot: model.snapshot, + revision: model.homePresentationRevision, + query: searchText, + projectID: selectedProjectID, + now: sidebarBoundaryNow, + pullRequestsByThreadID: model.pullRequestsByThreadID + ) + + return VStack(spacing: 0) { + projectFilter + HomeThreadCollectionView( + presentation: presentation, + projectFaviconClient: model.client, + query: searchText, + selectedThreadID: threadSelection.highlightedID, + forceRichRows: dynamicTypeSize.isAccessibilitySize, + hapticsEnabled: model.snapshot.settings.hapticsEnabled, + settings: model.snapshot.settings, + pullRequestsByThreadID: model.pullRequestsByThreadID, + isSnoozedExpanded: isSnoozedExpanded, + isSettledExpanded: isSettledExpanded, + isArchiveExpanded: isArchiveExpanded, + settledLimit: settledLimit, + onOpen: openThread, + onToggleSnoozed: { isSnoozedExpanded.toggle() }, + onToggleSettled: { isSettledExpanded.toggle() }, + onToggleArchive: { isArchiveExpanded.toggle() }, + onShowMoreSettled: { settledLimit += 25 }, + onRename: { thread in + renameTitle = thread.title + renamingThread = thread + }, + onRegenerateTitle: { thread in + Task { await model.regenerateThreadTitle(thread.id) } + }, + onArchive: { thread, archived in + Task { await model.setArchived(thread.id, archived: archived) } + }, + onSettle: { thread, settled, completion in + Task { completion(await model.setSettled(thread.id, settled: settled)) } + }, + onSnooze: { thread, until in + Task { await model.setSnoozed(thread.id, until: until) } + }, + onPin: { thread, pinned in + Task { await model.setPinned(thread.id, pinned: pinned) } + }, + onDelete: { thread in + deletingThread = thread + }, + onPullRequestChange: { threadID, observationIdentity, pullRequest in + model.updatePullRequest( + pullRequest, + threadID: threadID, + observationIdentity: observationIdentity + ) + } + ) + } + .background(T3Colors.background) + } + + @ViewBuilder + private var detail: some View { + if let id = selectedThreadID, + let thread = model.snapshot.threads.first(where: { $0.id == id }) { + ThreadDetailView( + model: model, + thread: thread, + submitMessage: submitMessage, + onNavigateBack: closeSelectedThread + ) + .id(id) + } else { + VStack(spacing: 14) { + Image(systemName: "square.and.pencil") + .font(.system(size: 30, weight: .light)) + .foregroundStyle(T3Colors.textTertiary) + Text("Start a task") + .font(.title3.weight(.semibold)) + Text("Choose a thread or compose something new.") + .font(.subheadline) + .foregroundStyle(T3Colors.textSecondary) + Button("New task", action: openNewTaskOrProjectCreation) + .buttonStyle(.borderedProminent) + .tint(T3Colors.primaryAction) + .foregroundStyle(T3Colors.primaryActionForeground) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(T3Colors.background) + } + } + + private var homeBar: some View { + HStack(spacing: 2) { + connectionBrand + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + withAnimation(.easeOut(duration: 0.16)) { + isSearching.toggle() + } + if isSearching { + Task { @MainActor in + await Task.yield() + isSearchFocused = true + } + } else { + searchText = "" + isSearchFocused = false + } + } label: { + Image(systemName: isSearching ? "xmark" : "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .frame(width: 40, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel(isSearching ? "Close search" : "Search tasks") + .accessibilityIdentifier("sidebar-search-button") + + if model.snapshot.environments.contains(where: { $0.prismEnabled == true }) { + Button { showingPrism = true } label: { + Image(systemName: "point.3.connected.trianglepath.dotted") + .font(.system(size: 17, weight: .medium)) + .frame(width: 40, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Prism") + .accessibilityIdentifier("sidebar-prism-button") + + } + + Button { showingSettings = true } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 17, weight: .medium)) + .frame(width: 40, height: T3Metrics.minimumTapTarget) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.textSecondary) + .accessibilityLabel("Settings") + .accessibilityIdentifier("sidebar-settings-button") + } + .padding(.leading, 15) + .padding(.trailing, 8) + .frame(height: 49) + .background(T3Colors.background) + } + + @ViewBuilder + private var connectionBrand: some View { + if !unreachableEnvironments.isEmpty { + Button { showingEnvironments = true } label: { + HStack(spacing: 7) { + Image(systemName: "network.slash") + .font(.system(size: 13, weight: .semibold)) + Text(unreachableBrandLabel) + .lineLimit(1) + .font(.system(size: 13, weight: .semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + } + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(T3Colors.danger) + .accessibilityLabel("\(unreachableBrandLabel). Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } else if let reconnecting = reconnectingEnvironments.first { + Button { showingEnvironments = true } label: { + HStack(spacing: 7) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 13, weight: .semibold)) + Text(reconnecting.name) + .lineLimit(1) + Text("reconnecting") + .fontWeight(.medium) + .opacity(0.76) + } + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(T3Colors.warning) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(reconnecting.name) reconnecting. Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } else { + Button { showingEnvironments = true } label: { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("T3") + .fontWeight(.bold) + .foregroundStyle(T3Colors.textPrimary) + Text("Code") + .fontWeight(.medium) + .foregroundStyle(T3Colors.textSecondary) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(T3Colors.textTertiary) + .padding(.leading, 2) + } + .font(.system(size: 16)) + .frame(minHeight: T3Metrics.minimumTapTarget) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("T3 Code. Manage environments") + .accessibilityIdentifier("sidebar-environments-button") + } + } + + private var searchBar: some View { + HStack(spacing: 9) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + TextField("Search tasks and projects", text: $searchText) + .font(.subheadline) + .foregroundStyle(T3Colors.textPrimary) + .focused($isSearchFocused) + .submitLabel(.search) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("sidebar-search-field") + if !searchText.isEmpty { + Button { searchText = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(T3Colors.textTertiary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 12) + .frame(height: T3Metrics.minimumTapTarget) + .background(T3Colors.input, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(T3Colors.border, lineWidth: 1) + } + .padding(.horizontal, 10) + .padding(.bottom, 4) + } + + private var composeButton: some View { + Button { + isSearchFocused = false + openNewTaskOrProjectCreation() + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(T3Colors.primaryActionForeground) + .frame(width: 52, height: 52) + .background(T3Colors.primaryAction, in: Circle()) + .shadow(color: T3Colors.shadow, radius: 16, y: 8) + } + .buttonStyle(.plain) + .accessibilityLabel("New task") + .accessibilityHint( + creationProjects.isEmpty + ? "Create a project to start a task" + : "Compose a message and start a thread" + ) + .accessibilityIdentifier("sidebar-new-task-button") + } + + private var projectFilter: some View { + HStack(spacing: 0) { + Menu { + Button { + selectedProjectID = nil + } label: { + if selectedProjectID == nil { + Label("All projects", systemImage: "checkmark") + } else { + Text("All projects") + } + } + ForEach(model.snapshot.projects) { project in + Button { + selectedProjectID = project.id + } label: { + let title = projectMenuTitle(project) + if selectedProjectID == project.id { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } + } + } label: { + HStack(spacing: 7) { + Image(systemName: "folder") + .font(.system(size: 13, weight: .medium)) + Text(selectedProject?.name ?? "All projects") + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(T3Typography.homeMetadata.weight(.semibold)) + .foregroundStyle(T3Colors.textSecondary) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Project filter") + .accessibilityValue(selectedProject?.name ?? "All projects") + .accessibilityIdentifier("sidebar-project-filter") + + Button { showingAddProject = true } label: { + Image(systemName: "folder.badge.plus") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(T3Colors.textTertiary) + .frame(width: T3Metrics.minimumTapTarget, height: 34) + } + .buttonStyle(.plain) + .accessibilityLabel("Add project") + .accessibilityIdentifier("sidebar-add-project-button") + } + .padding(.leading, 10) + .padding(.trailing, 2) + .accessibilityElement(children: .contain) + } + + private var selectedProject: FeatureProject? { + model.snapshot.projects.first { $0.id == selectedProjectID } + } + + private var creationProjects: [FeatureProject] { + DailyUXCreationContext.projects(in: model.snapshot) + } + + private var unreachableEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + $0.isEnabled && $0.connectionState == .disconnected + } + } + + private var reconnectingEnvironments: [FeatureEnvironment] { + model.snapshot.environments.filter { + $0.isEnabled + && ($0.connectionState == .connecting || $0.connectionState == .reconnecting) + } + } + + private var unreachableBrandLabel: String { + if unreachableEnvironments.count == 1 { + return "\(unreachableEnvironments[0].name) offline" + } + return "\(unreachableEnvironments.count) environments offline" + } + + private var nextSidebarBoundary: Date? { + DailyUXSidebarRefresh.nextBoundary( + for: model.snapshot.threads, + after: max(sidebarBoundaryNow, .now), + settings: model.snapshot.settings, + pullRequestsByThreadID: model.pullRequestsByThreadID + ) + } + + private var selectedThreadIsAvailable: Bool { + guard let selectedThreadID else { return true } + return model.snapshot.threads.contains { $0.id == selectedThreadID } + } + + private var selectedThreadID: String? { threadSelection.selectedID } + + private var selectedProjectIsAvailable: Bool { + guard let selectedProjectID else { return true } + return model.snapshot.projects.contains { $0.id == selectedProjectID } + } + + private func openThread(_ id: String) { + threadSelection.open(id) + preferredCompactColumn = .detail + } + + private func closeSelectedThread() { + threadSelection.close() + preferredCompactColumn = .sidebar + } + + @MainActor + private func openProjectCreation() { + showingNewTask = false + showingAddProject = true + } + + private func openNewTaskOrProjectCreation() { + openNewTaskOrProjectCreation(initialProjectID: selectedProjectID) + } + + private func openNewTaskOrProjectCreation(initialProjectID: String?) { + if creationProjects.isEmpty { + showingAddProject = true + } else { + newTaskInitialProjectID = initialProjectID + showingNewTask = true + } + } + + private func consumeNavigationRequest() { + guard let navigationRequest else { return } + switch navigationRequest.destination { + case let .thread(id): + guard model.snapshot.threads.contains(where: { $0.id == id }) else { return } + dismissTransientPresentations() + openThread(id) + case let .project(id): + guard model.snapshot.projects.contains(where: { $0.id == id }) else { return } + dismissTransientPresentations() + selectedProjectID = id + closeSelectedThread() + case let .newTask(projectID): + if let projectID, + model.snapshot.projects.contains(where: { $0.id == projectID }) { + selectedProjectID = projectID + } + dismissTransientPresentations() + Task { @MainActor in + await Task.yield() + openNewTaskOrProjectCreation(initialProjectID: projectID) + } + } + onNavigationRequestConsumed(navigationRequest.id) + } + + private func dismissTransientPresentations() { + showingNewTask = false + showingAddProject = false + showingEnvironments = false + showingSettings = false + renamingThread = nil + } + + private func projectMenuTitle(_ project: FeatureProject) -> String { + guard model.snapshot.environments.count > 1, + let environment = model.snapshot.environments.first(where: { + $0.id == project.environmentID + }) else { + return project.name + } + return "\(project.name) · \(environment.name)" + } +} + +private extension FeatureDraftAttachment { + var uploadValue: FeatureUploadAttachment { + FeatureUploadAttachment(self) + } +} + +struct HomePresentation { + let pinned: [FeatureThread] + let active: [FeatureThread] + let snoozed: [FeatureThread] + let settled: [FeatureThread] + let archived: [FeatureThread] + let searchResults: [FeatureThread] + let rowContexts: [String: HomeThreadRowContext] + + init( + snapshot: FeatureSnapshot, + query: String, + projectID: String?, + now: Date, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] = [:] + ) { + let index = DailyUXSidebarIndex( + snapshot: snapshot, + query: "", + projectID: projectID, + now: now, + pullRequestsByThreadID: pullRequestsByThreadID + ) + let archived = snapshot.threads + .filter { thread in + thread.isArchived && (projectID == nil || thread.projectID == projectID) + } + .sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.id < $1.id + } + + pinned = index.pinned + active = index.active + snoozed = index.snoozed + settled = index.settled + self.archived = archived + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + searchResults = normalizedQuery.isEmpty + ? [] + : DailyUXSidebarIndex.matchingThreads( + index.pinned + index.active + index.snoozed + index.settled + archived, + snapshot: snapshot, + query: normalizedQuery + ) + rowContexts = HomeThreadRowContext.index(snapshot: snapshot) + } +} + +@MainActor +private final class HomePresentationCache { + private struct Key: Equatable { + let revision: UInt64 + let query: String + let projectID: String? + let now: Date + } + + private var cachedKey: Key? + private var cachedPresentation: HomePresentation? + + func presentation( + snapshot: FeatureSnapshot, + revision: UInt64, + query: String, + projectID: String?, + now: Date, + pullRequestsByThreadID: [String: HomeThreadPullRequestPresentation] + ) -> HomePresentation { + let key = Key( + revision: revision, + query: query, + projectID: projectID, + now: now + ) + if cachedKey == key, let cachedPresentation { + return cachedPresentation + } + + let presentation = HomePresentation( + snapshot: snapshot, + query: query, + projectID: projectID, + now: max(now, .now), + pullRequestsByThreadID: pullRequestsByThreadID + ) + cachedKey = key + cachedPresentation = presentation + return presentation + } +} + +struct HomeShelfHeader: View { + let title: String + let count: Int + let isExpanded: Bool + let accent: Color? + + var body: some View { + HStack(spacing: 8) { + Text(count > 0 ? "\(title) (\(count))" : title) + .lineLimit(1) + Rectangle() + .fill((accent ?? T3Colors.textTertiary).opacity(accent == nil ? 0.16 : 0.24)) + .frame(height: 1) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(T3Typography.homeMetadata.weight(.bold)) + .foregroundStyle(accent ?? T3Colors.textTertiary) + .padding(.horizontal, 10) + .padding(.top, 4) + .frame(minHeight: 40) + .contentShape(Rectangle()) + } +} + +struct HomeThreadRowContext: Equatable { + let projectName: String + let projectEnvironmentID: String? + let projectWorkspaceRoot: String? + let environmentLabel: String? + let providerID: String + let providerDriver: String + let providerName: String + let connectionState: FeatureConnection.State? + + static let fallback = HomeThreadRowContext( + projectName: "Project", + projectEnvironmentID: nil, + projectWorkspaceRoot: nil, + environmentLabel: nil, + providerID: "agent", + providerDriver: "", + providerName: "Agent", + connectionState: nil + ) + + var providerLooksTerminal: Bool { + let normalized = [providerDriver, providerID, providerName] + .joined(separator: " ") + .lowercased() + return normalized.contains("codex") + || normalized.contains("cursor") + || normalized.contains("open") + } + + static func index(snapshot: FeatureSnapshot) -> [String: HomeThreadRowContext] { + let projectByID = snapshot.projects.reduce(into: [String: FeatureProject]()) { + $0[$1.id] = $1 + } + let projectGroupNameByID = DailyUXCreationContext.projectGroups(in: snapshot).reduce( + into: [String: String]() + ) { result, group in + for projectID in group.memberProjectIDs { + result[projectID] = group.name + } + } + let environmentByID = snapshot.environments.reduce(into: [String: FeatureEnvironment]()) { + $0[$1.id] = $1 + } + return snapshot.threads.reduce(into: [String: HomeThreadRowContext]()) { result, thread in + let project = projectByID[thread.projectID] + let environmentID = thread.environmentID ?? project?.environmentID + let environment = environmentID.flatMap { environmentByID[$0] } + let environmentLabel = (environment?.name ?? thread.environmentName)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let explicitProvider = thread.providerName? + .trimmingCharacters(in: .whitespacesAndNewlines) + let configuredProvider = thread.providerID.flatMap { providerID in + environmentID.flatMap { + snapshot.providersByEnvironment?[$0]?.first(where: { $0.id == providerID }) + } + } + let providerName = (explicitProvider?.isEmpty == false ? explicitProvider : nil) + ?? configuredProvider?.name + ?? thread.providerID + ?? "Agent" + let providerID = thread.providerID ?? providerName + let providerDriver = configuredProvider?.driver ?? thread.providerID ?? "" + + let connectionState = environment?.isEnabled == false + ? FeatureConnection.State.disconnected + : environment?.connectionState + + result[thread.id] = HomeThreadRowContext( + projectName: projectGroupNameByID[thread.projectID] ?? project?.name ?? "Project", + projectEnvironmentID: project?.environmentID, + projectWorkspaceRoot: project?.path, + environmentLabel: environmentLabel?.isEmpty == false ? environmentLabel : nil, + providerID: providerID, + providerDriver: providerDriver, + providerName: providerName, + connectionState: connectionState + ) + } + } +} + +struct HomeThreadPullRequestPresentation: Equatable { + enum State: String { + case open + case merged + case closed + } + + let number: Int + let state: State + let updatedAt: Date? + + var label: String { "#\(number)" } + + var accessibilityLabel: String { + "Pull request #\(number), \(state.rawValue)" + } + + static func resolve( + thread: FeatureThread, + status: FeatureSourceControlStatus + ) -> Self? { + guard let branch = thread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty, + status.branch == branch, + let pullRequest = status.pullRequest, + let state = State(rawValue: pullRequest.state.lowercased()) else { + return nil + } + return Self( + number: pullRequest.number, + state: state, + updatedAt: parseDate(pullRequest.updatedAt) + ) + } + + static func resolve( + linkedPullRequest: ThreadLinkedPullRequest, + detail: PullRequestDetail + ) -> Self? { + guard detail.number == linkedPullRequest.number, + detail.repository.caseInsensitiveCompare(linkedPullRequest.repository) == .orderedSame, + let state = State(rawValue: detail.state.rawValue) else { + return nil + } + return Self( + number: detail.number, + state: state, + updatedAt: parseDate(detail.updatedAt) + ) + } + + private static func parseDate(_ value: String?) -> Date? { + guard let value else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) + } +} + +extension FeatureThread { + var pullRequestObservationIdentity: String? { + let environment = environmentID ?? "" + if let linkedPullRequest { + return [ + id, + environment, + projectID, + linkedPullRequest.projectId, + linkedPullRequest.repository.lowercased(), + String(linkedPullRequest.number), + ].joined(separator: "\u{0}") + } + guard let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty else { + return nil + } + return [id, environment, projectID, worktreePath ?? "", branch] + .joined(separator: "\u{0}") + } +} + +struct FeatureThreadRow: View { + enum Style: Equatable { + case rich + case slim + } + + let thread: FeatureThread + private let context: HomeThreadRowContext + private let projectFaviconClient: (any FeatureClient)? + private let onPullRequestChange: (HomeThreadPullRequestPresentation?) -> Void + let isSelected: Bool + let style: Style + let now: Date + let allowsMultilineTitle: Bool + @State private var pullRequest: HomeThreadPullRequestPresentation? + + init( + thread: FeatureThread, + context: HomeThreadRowContext, + projectFaviconClient: (any FeatureClient)? = nil, + onPullRequestChange: @escaping (HomeThreadPullRequestPresentation?) -> Void = { _ in }, + isSelected: Bool = false, + style: Style = .rich, + now: Date = .now, + allowsMultilineTitle: Bool = false + ) { + self.thread = thread + self.context = context + self.projectFaviconClient = projectFaviconClient + self.onPullRequestChange = onPullRequestChange + self.isSelected = isSelected + self.style = style + self.now = now + self.allowsMultilineTitle = allowsMultilineTitle + } + + var body: some View { + row(at: now) + .accessibilityElement(children: .ignore) + .accessibilityLabel(thread.title) + .accessibilityValue(accessibilityValue(at: now)) + .accessibilityHint("Opens task") + .accessibilityIdentifier("thread-\(thread.id)") + .accessibilityAddTraits(isSelected ? .isSelected : []) + .task(id: pullRequestObservationID) { + await observePullRequest() + } + } + + @ViewBuilder + private func row(at now: Date) -> some View { + Group { + switch style { + case .rich: richRow(at: now) + case .slim: slimRow(at: now) + } + } + .contentShape(Rectangle()) + } + + private func richRow(at now: Date) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + projectBadge + Text(context.projectName) + .lineLimit(1) + .foregroundStyle(T3Colors.textSecondary) + Spacer(minLength: 8) + status(at: now) + } + .font(T3Typography.homeMetadata.weight(.medium)) + .frame(minHeight: 20) + + Text(thread.title) + .font(T3Typography.homeTitle) + .tracking(-0.14) + .foregroundStyle(T3Colors.textPrimary) + .lineLimit(allowsMultilineTitle ? 2 : 1) + .padding(.top, 4) + + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .medium)) + Text(branchLabel) + .lineLimit(1) + if context.providerLooksTerminal { + Text(">_") + .font(.system(size: 9.5, weight: .bold, design: .monospaced)) + .foregroundStyle(T3Colors.syntaxProperty) + } + Spacer(minLength: 8) + if let environmentLabel { + HStack(spacing: 4) { + Image(systemName: environmentIcon) + .font(.system(size: 9)) + Text(environmentLabel) + .lineLimit(1) + } + .foregroundStyle(environmentColor) + } + if let pullRequest { + pullRequestIndicator(pullRequest) + } + if thread.pinnedAt != nil { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + } + providerIcon(size: 16) + } + .font(T3Typography.homeMetadata) + .foregroundStyle(T3Colors.textTertiary) + .frame(minHeight: 20) + .padding(.top, 3) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(minHeight: 88) + .background( + isSelected ? T3Colors.subtleStrong : Color.clear, + in: RoundedRectangle(cornerRadius: 8) + ) + .padding(.horizontal, 8) + } + + private func slimRow(at now: Date) -> some View { + HStack(spacing: 9) { + projectBadge + .saturation(0) + .opacity(0.48) + Text(thread.title) + .font(T3Typography.homeTitle) + .foregroundStyle(T3Colors.textSecondary) + .lineLimit(allowsMultilineTitle ? 2 : 1) + Spacer(minLength: 8) + if let pullRequest { + pullRequestIndicator(pullRequest) + } + if thread.pinnedAt != nil { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(T3Colors.textSecondary) + } + providerIcon(size: 15) + Text(SidebarRelativeAge.compact(since: thread.updatedAt, now: now)) + .font(T3Typography.homeMetadata.monospacedDigit()) + .foregroundStyle(T3Colors.textTertiary) + } + .padding(.horizontal, 10) + .frame(minHeight: 44) + .padding(.horizontal, 8) + .background( + isSelected ? T3Colors.subtleStrong : Color.clear, + in: RoundedRectangle(cornerRadius: 7) + ) + } + + @ViewBuilder + private func status(at now: Date) -> some View { + HStack(spacing: 5) { + if let icon = statusIcon { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + } + Text(thread.homeRowStatusLabel(at: now)) + if let duration = thread.homeWorkingDuration(at: now) { + Text(duration) + .font(.system(.footnote, design: .monospaced, weight: .semibold)) + .monospacedDigit() + } + } + .font(T3Typography.status) + .foregroundStyle(statusColor) + } + + private var statusIcon: String? { + switch thread.homeStatus { + case .working: "circle.dotted" + case .failed: "exclamationmark.circle" + case .approval, .input, .monitoring, .done, .ready: nil + } + } + + private var statusColor: Color { + switch thread.homeStatus { + case .working: T3Colors.statusRunning + case .monitoring: T3Colors.statusRunning + case .approval: T3Colors.warning + case .input: T3Colors.statusInput + case .failed: T3Colors.danger + case .done, .ready: T3Colors.textTertiary + } + } + + private var environmentIcon: String { + switch context.connectionState { + case .connecting, .reconnecting: + "wifi" + case .disconnected: + "wifi.slash" + case .connected, nil: + "server.rack" + } + } + + private var environmentColor: Color { + switch context.connectionState { + case .connecting, .reconnecting: + T3Colors.warning.opacity(0.78) + case .disconnected: + T3Colors.danger.opacity(0.78) + case .connected, nil: + T3Colors.textTertiary + } + } + + private var isConnectionStale: Bool { + context.connectionState == .connecting + || context.connectionState == .reconnecting + || context.connectionState == .disconnected + } + + private var branchLabel: String { + if let branch = thread.branch?.trimmingCharacters(in: .whitespacesAndNewlines), + !branch.isEmpty { + return branch + } + if let worktreePath = thread.worktreePath, + !worktreePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: worktreePath).lastPathComponent + } + return "workspace" + } + + private var environmentLabel: String? { + context.environmentLabel + } + + private var pullRequestObservationID: String? { + guard projectFaviconClient != nil else { return nil } + return thread.pullRequestObservationIdentity + } + + @MainActor + private func observePullRequest() async { + guard pullRequestObservationID != nil, + let projectFaviconClient else { + pullRequest = nil + onPullRequestChange(nil) + return + } + + if let linked = thread.linkedPullRequest, + let environmentID = thread.environmentID { + let target = FeaturePullRequestTarget( + environmentID: environmentID, + environmentName: thread.environmentName ?? environmentID, + reference: PullRequestRef( + projectId: linked.projectId, + repository: linked.repository, + number: linked.number + ) + ) + while !Task.isCancelled { + if let detail = try? await projectFaviconClient.pullRequestDetail(target), + let next = HomeThreadPullRequestPresentation.resolve( + linkedPullRequest: linked, + detail: detail + ), + next != pullRequest { + pullRequest = next + onPullRequestChange(next) + } + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return + } + } + return + } + + for await status in projectFaviconClient.sourceControlStatusEvents(threadID: thread.id) { + guard !Task.isCancelled else { return } + let next = HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: status + ) + guard next != pullRequest else { continue } + pullRequest = next + onPullRequestChange(next) + } + } + + private func pullRequestIndicator(_ pullRequest: HomeThreadPullRequestPresentation) -> some View { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 10, weight: .semibold)) + Text(pullRequest.label) + .font(T3Typography.homeMetadata.monospacedDigit().weight(.medium)) + .lineLimit(1) + } + .foregroundStyle(pullRequestColor(pullRequest.state)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(pullRequest.accessibilityLabel) + } + + private func pullRequestColor(_ state: HomeThreadPullRequestPresentation.State) -> Color { + switch state { + case .open: T3Colors.success + case .merged: T3Colors.syntaxKeyword + case .closed: T3Colors.danger + } + } + + private var projectBadge: some View { + ProjectBadge( + name: context.projectName, + environmentID: context.projectEnvironmentID, + workspaceRoot: context.projectWorkspaceRoot, + client: projectFaviconClient + ) + } + + private func providerIcon(size: CGFloat) -> some View { + ProviderIcon( + driver: context.providerDriver, + providerID: context.providerID, + fallbackName: context.providerName, + size: size + ) + } + + private func accessibilityValue(at now: Date) -> String { + var values = [thread.homeStatusLabel ?? "Ready", "Project \(context.projectName)"] + values.append("Harness \(context.providerName)") + if let duration = thread.homeWorkingDuration(at: now) { + values.append("for \(duration)") + } + values.append("Branch \(branchLabel)") + if let pullRequest { + values.append(pullRequest.accessibilityLabel) + } + if let environmentLabel { + values.append("on \(environmentLabel)") + } + if isConnectionStale { + values.append("last known state") + } + return values.joined(separator: ". ") + } + +} + +private struct ProjectBadge: View { + let name: String + let environmentID: String? + let workspaceRoot: String? + let client: (any FeatureClient)? + @State private var favicon: UIImage? + + init( + name: String, + environmentID: String?, + workspaceRoot: String?, + client: (any FeatureClient)? + ) { + self.name = name + self.environmentID = environmentID + self.workspaceRoot = workspaceRoot + self.client = client + let initialKey = environmentID.flatMap { environmentID in + workspaceRoot.map { workspaceRoot in + FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ).fingerprint + } + } + _favicon = State(initialValue: initialKey.flatMap { + FeatureProjectFaviconImageCache.shared.image(for: $0) + }) + } + + var body: some View { + Group { + if let favicon { + Image(uiImage: favicon) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Text(label) + .font(.system(size: 8, weight: .heavy)) + .foregroundStyle(foreground) + .frame(width: 16, height: 16) + .background(background, in: RoundedRectangle(cornerRadius: 4)) + } + } + .frame(width: 16, height: 16) + .accessibilityHidden(true) + .task(id: faviconKey) { + await loadFavicon() + } + } + + private var faviconKey: String? { + guard let environmentID, let workspaceRoot else { return nil } + return FeatureProjectFaviconCacheKey( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ).fingerprint + } + + private func loadFavicon() async { + guard let environmentID, let workspaceRoot, let client, let faviconKey else { + return + } + favicon = FeatureProjectFaviconImageCache.shared.image(for: faviconKey) + if let cached = await client.cachedProjectFavicon( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) { + apply(cached, key: faviconKey) + } + guard !Task.isCancelled else { return } + if let refreshed = await client.refreshProjectFavicon( + environmentID: environmentID, + workspaceRoot: workspaceRoot + ) { + apply(refreshed, key: faviconKey) + } + } + + private func apply(_ data: Data, key: String) { + guard let image = UIImage(data: data) else { return } + FeatureProjectFaviconImageCache.shared.set(image, for: key) + favicon = image + } + + private var label: String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "?" } + if trimmed.lowercased().hasPrefix("t3") { return "T3" } + return String(trimmed.prefix(1)).uppercased() + } + + private var paletteIndex: Int { + if label == "T3" { return 0 } + return name.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 4 } + } + + private var background: Color { + switch paletteIndex { + case 0: Color(red: 0.03, green: 0.24, blue: 0.21) + case 1: Color(red: 0.19, green: 0.13, blue: 0.37) + case 2: Color(red: 0.29, green: 0.18, blue: 0.02) + default: Color(red: 0.10, green: 0.18, blue: 0.34) + } + } + + private var foreground: Color { + switch paletteIndex { + case 0: Color(red: 0.78, green: 0.98, blue: 0.95) + case 1: Color(red: 0.93, green: 0.91, blue: 1) + case 2: Color(red: 1, green: 0.95, blue: 0.78) + default: Color(red: 0.82, green: 0.9, blue: 1) + } + } +} + +@MainActor +private final class FeatureProjectFaviconImageCache { + static let shared = FeatureProjectFaviconImageCache() + + private let images = NSCache() + + private init() { + images.countLimit = FeatureProjectFaviconStore.maximumEntryCount + } + + func image(for key: String) -> UIImage? { + images.object(forKey: key as NSString) + } + + func set(_ image: UIImage, for key: String) { + images.setObject(image, forKey: key as NSString) + } +} diff --git a/apps/swift-ios/README.md b/apps/swift-ios/README.md new file mode 100644 index 000000000000..379ed70ca0c5 --- /dev/null +++ b/apps/swift-ios/README.md @@ -0,0 +1,155 @@ +# T3 Code (SwiftUI) + +A native SwiftUI client for T3 Code. The project targets iOS 17 and later on +iPhone and iPad. It has its own bundle identifier and can be installed beside the +React Native T3 Code app. + +## Requirements + +- A current Xcode release with an iOS Simulator runtime. +- iOS 17 or later for physical-device builds. +- A T3 pairing URL for direct connections. T3 Connect builds additionally need + the cloud settings below. + +## Open + +Open `T3Code.xcodeproj`, choose the `T3Code` scheme, and run an installed iOS +Simulator. Xcode automatically includes files added below `App`, `Core`, +`Features`, `DesignSystem`, and `Resources`; `Info.plist` is the one resource +excluded from copying because it supplies the target's generated Info.plist. + +Pair with the same URL produced by a T3 server. The one-time pairing credential is +exchanged for an access token and stored in the Keychain. Environment metadata and +the active selection are stored separately in Application Support. + +## Structure + +- `App` owns the app lifecycle and the thin root composition seam. +- `Core` owns persistence, credentials, transport, and the T3 protocol. +- `Features` owns onboarding, environments, threads, messages, and settings. +- `DesignSystem` contains the small set of shared visual tokens. +- `Resources` contains the asset catalog. +- `Tests` covers pairing, wire contracts, persistence, and feature state changes. + +`RootView` deliberately accepts any SwiftUI content. Production composition injects +`FeatureRootView(client:)` there, keeping protocol adapters out of the UI shell. + +## Included + +- Local-network preflight, direct pairing links, QR scanning, token exchange, + Keychain credentials, saved environment management, and optional T3 Connect + account and relay discovery. +- A merged Web V2 home across saved environments, with per-device reachability, + collision-safe identities, last-known rows, live active-device updates, and + low-frequency passive refresh. +- Remote filesystem browsing, source discovery, repository cloning, project + creation, plus thread search, creation, rename, archive, restore, delete, + settle, and snooze. +- Provider/model selection, paginated synchronized conversation history, rich Markdown, + photo/camera/file image attachments, turn cancellation, approval decisions, and + structured user-input requests. +- Workspace files and previews, working-tree review, Git status and common actions, + plus Ghostty-rendered terminal sessions with VT/ANSI output, scrollback, hardware + and software keyboard controls, and per-thread session switching. +- Native settings with persisted appearance and behavior preferences, platform + deep links, shortcuts, background refresh, and notification routing. +- A Share extension that imports text, URLs, and images into persistent project + drafts, plus Home Screen widgets and aggregate Live Activities for active work. +- DPoP-bound T3 Connect sessions with account-scoped relay credentials, APNs + device registration on iOS 18+, and automatic credential recovery. + +The app speaks the existing HTTP and Effect RPC WebSocket contracts directly. It +does not embed a JavaScript runtime. + +## Build configuration + +The project expands these user-defined Xcode build settings into its generated +Info.plist: + +| Setting | Required | Purpose | +| ------------------------------ | --------------- | ----------------------------------------------- | +| `T3CODE_CLERK_PUBLISHABLE_KEY` | T3 Connect only | Clerk publishable key. | +| `T3CODE_CLERK_JWT_TEMPLATE` | No | Relay JWT template; defaults to `t3-relay`. | +| `T3CODE_RELAY_URL` | T3 Connect only | Relay base URL using HTTPS. | +| `DEVELOPMENT_TEAM` | Device/archive | Apple Developer team used by automatic signing. | +| `MARKETING_VERSION` | Release | User-facing version. | +| `CURRENT_PROJECT_VERSION` | Release | Monotonically increasing build number. | + +Unset T3 Connect values disable that connection method without affecting direct +pairing. Supply settings on the `xcodebuild` command line or through a local +`.xcconfig`; do not commit private release configuration. + +Debug and Release use separate identities so a local build can remain installed +beside TestFlight: + +| Configuration | Display name | Bundle identifier | URL scheme | +| ------------- | --------------- | -------------------------------- | -------------------- | +| Debug | T3 Swift Dev | `com.t3tools.t3code.swiftui.dev` | `t3code-swiftui-dev` | +| Release | T3 Code SwiftUI | `com.t3tools.t3code.swiftui` | `t3code-swiftui` | + +Each identity also has matching widget and share-extension bundle identifiers +and a separate App Group. Debug data and credentials therefore do not alter the +TestFlight installation. + +## Verify + +Run the `T3Code` scheme's tests in Xcode, or use the same entry point as CI. It +chooses an available iPhone from the newest installed Simulator runtime: + +```sh +./Scripts/ci-test.sh +``` + +Set `T3_SWIFT_SIMULATOR_ID` to pin a specific simulator. CI can invoke this same +entry point without duplicating the simulator-selection or signing policy. + +Contract fixtures are encoded from the TypeScript schemas and decoded by the +Swift test target. Regenerate and verify them after a relevant wire change: + +```sh +node scripts/generate-swift-wire-fixtures.ts +node scripts/generate-swift-wire-fixtures.ts --check +``` + +Pull requests that change `apps/swift-ios`, `packages/contracts`, or the fixture +generator run both checks in the path-gated SwiftUI workflow. + +## Install on a physical device + +Enable Developer Mode on the device, connect and trust the Mac, then find its +CoreDevice identifier or hardware UDID with +`xcrun devicectl list devices --columns UDID`. The script resolves either form to +the destination UDID expected by Xcode. Xcode must be signed into an Apple +Developer account for the requested team. + +```sh +T3_SWIFT_DEVICE_ID="DEVICE-IDENTIFIER" \ +T3_SWIFT_DEVELOPMENT_TEAM="TEAMID1234" \ +./Scripts/install-device.sh +``` + +The script builds, provisions, installs, and launches the Debug identity by +default. Set `T3_SWIFT_CONFIGURATION=Release` for the TestFlight identity. It +accepts the T3 Connect build settings above as environment variables. Optional +overrides are `T3_SWIFT_DERIVED_DATA_PATH`, `T3_SWIFT_VERSION`, and +`T3_SWIFT_BUILD_NUMBER`. Run with +`T3_SWIFT_VERIFY_BUNDLE_IDENTIFIERS_ONLY=1` to verify the configuration's host +and extension bundle identifiers without a device build. + +## Release checklist + +1. Set a unique `MARKETING_VERSION` and a higher `CURRENT_PROJECT_VERSION`. +2. Confirm the production bundle identifier, display name, app icon, signing team, + and T3 Connect HTTPS relay configuration. +3. Run `./Scripts/ci-test.sh` and confirm the native test job is green. +4. Smoke-test direct URL and QR pairing, T3 Connect, multi-environment navigation, + task creation, follow-up messages, attachments, approvals, input requests, + background/reconnect behavior, and deep links on an iPhone and iPad. +5. Confirm the host, widget, and share-extension identifiers have App Group + provisioning, and the host has Push Notifications provisioning. Verify APNs + device registration and Share-extension handoff end to end. +6. Archive the `T3Code` scheme in Release, run Xcode's Validate App and privacy + report, and confirm `PrivacyInfo.xcprivacy` is bundled. Re-audit the manifest + whenever code adds a Required Reason API or data collection. +7. Confirm `ITSAppUsesNonExemptEncryption = NO` remains accurate, then distribute + an internal TestFlight build before App Store submission. diff --git a/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..b1e4d30e3c2b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "0.520", + "red": "0.040" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 000000000000..6efba8682b15 Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..f01d4958bf8a --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png new file mode 100644 index 000000000000..bd1b6801ec53 Binary files /dev/null and b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/AppIconDev-1024.png differ diff --git a/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json new file mode 100644 index 000000000000..7a0f3a741500 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AppIconDev.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "AppIconDev-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json new file mode 100644 index 000000000000..1a6d31b41272 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "github.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg new file mode 100644 index 000000000000..478b49211cfd --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGitHub.imageset/github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json new file mode 100644 index 000000000000..eed4f40a063f --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "google.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg new file mode 100644 index 000000000000..65c447fbc11f --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthGoogle.imageset/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json new file mode 100644 index 000000000000..fc7ae4e5b13b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images": [ + { + "filename": "microsoft.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg new file mode 100644 index 000000000000..1e8f2fa88816 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/AuthMicrosoft.imageset/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/swift-ios/Resources/Assets.xcassets/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..74d6a722cf39 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json new file mode 100644 index 000000000000..eb825d22279c --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "claude.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg new file mode 100644 index 000000000000..324389017b5b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderClaude.imageset/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json new file mode 100644 index 000000000000..46d892b7b03b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "cursor.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg new file mode 100644 index 000000000000..089d4676370b --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderCursor.imageset/cursor.svg @@ -0,0 +1 @@ + diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json new file mode 100644 index 000000000000..e7bc254a84b5 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "grok.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg new file mode 100644 index 000000000000..d094fcc6f853 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderGrok.imageset/grok.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json new file mode 100644 index 000000000000..21dacb7d13cf --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "openai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg new file mode 100644 index 000000000000..b78a51db7bc6 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenAI.imageset/openai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json new file mode 100644 index 000000000000..bc6ee1e94730 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "opencode.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg new file mode 100644 index 000000000000..fc467bf84407 --- /dev/null +++ b/apps/swift-ios/Resources/Assets.xcassets/ProviderOpenCode.imageset/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/swift-ios/Resources/Info.plist b/apps/swift-ios/Resources/Info.plist new file mode 100644 index 000000000000..a544448dd76a --- /dev/null +++ b/apps/swift-ios/Resources/Info.plist @@ -0,0 +1,60 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).refresh + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + T3 Code SwiftUI Routes + CFBundleURLSchemes + + $(T3CODE_URL_SCHEME) + + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + ts.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + NSMicrophoneUsageDescription + Allow T3 Code to use your microphone for voice input. + T3CodeAppGroupIdentifier + $(T3CODE_APP_GROUP_IDENTIFIER) + T3GitCommit + $(T3_GIT_COMMIT) + T3ConnectClerkJWTTemplate + $(T3CODE_CLERK_JWT_TEMPLATE) + T3ConnectClerkPublishableKey + $(T3CODE_CLERK_PUBLISHABLE_KEY) + T3ConnectRelayHTTPURL + $(T3CODE_RELAY_URL) + UIBackgroundModes + + fetch + remote-notification + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + + diff --git a/apps/swift-ios/Resources/PrivacyInfo.xcprivacy b/apps/swift-ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..396aeea9d154 --- /dev/null +++ b/apps/swift-ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,21 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/apps/swift-ios/Scripts/ci-test.sh b/apps/swift-ios/Scripts/ci-test.sh new file mode 100755 index 000000000000..b5b4273993a1 --- /dev/null +++ b/apps/swift-ios/Scripts/ci-test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SIMULATOR_ID="${T3_SWIFT_SIMULATOR_ID:-}" +DERIVED_DATA_ROOT="${RUNNER_TEMP:-${APP_DIR}/.derivedData}" +DERIVED_DATA_PATH="${T3_SWIFT_DERIVED_DATA_PATH:-${DERIVED_DATA_ROOT}/swift-ios-ci}" + +die() { + printf '[swift-ios-ci] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +require_cmd awk +require_cmd xcodebuild +require_cmd xcrun + +if [[ -z "${SIMULATOR_ID}" ]]; then + # simctl groups devices by runtime. Keeping the last available iPhone picks + # the newest installed iOS runtime without coupling CI to a device model. + SIMULATOR_ID="$( + xcrun simctl list devices available \ + | awk ' + /^[[:space:]]+iPhone/ { + line = $0 + while (match(line, /\([[:xdigit:]-]+\)/)) { + value = substr(line, RSTART + 1, RLENGTH - 2) + if (value ~ /^[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}$/) { + candidate = value + } + line = substr(line, RSTART + RLENGTH) + } + } + END { print candidate } + ' + )" +fi + +[[ -n "${SIMULATOR_ID}" ]] || die "no available iPhone simulator was found" + +printf '[swift-ios-ci] Xcode: %s\n' "$(xcodebuild -version | tr '\n' ' ')" +printf '[swift-ios-ci] simulator: %s\n' "${SIMULATOR_ID}" + +xcodebuild test \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -scheme T3Code \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -maximum-concurrent-test-simulator-destinations 1 \ + -parallel-testing-enabled NO \ + -collect-test-diagnostics never \ + -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 30 \ + -maximum-test-execution-time-allowance 60 \ + -only-testing:T3CodeTests \ + CODE_SIGNING_ALLOWED=NO diff --git a/apps/swift-ios/Scripts/install-device.sh b/apps/swift-ios/Scripts/install-device.sh new file mode 100755 index 000000000000..091d96365db7 --- /dev/null +++ b/apps/swift-ios/Scripts/install-device.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEVICE_ID="${T3_SWIFT_DEVICE_ID:-${1:-}}" +DEVELOPMENT_TEAM="${T3_SWIFT_DEVELOPMENT_TEAM:-${2:-}}" +CONFIGURATION="${T3_SWIFT_CONFIGURATION:-Debug}" +DERIVED_DATA_PATH="${T3_SWIFT_DERIVED_DATA_PATH:-${APP_DIR}/.derivedData/device}" + +die() { + printf '[swift-ios-device] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +require_cmd awk +require_cmd mktemp +require_cmd plutil +require_cmd xcodebuild +require_cmd xcrun + +[[ "${CONFIGURATION}" == "Debug" || "${CONFIGURATION}" == "Release" ]] || die \ + "T3_SWIFT_CONFIGURATION must be Debug or Release" + +if [[ "${CONFIGURATION}" == "Debug" ]]; then + BUNDLE_IDENTIFIER="com.t3tools.t3code.swiftui.dev" +else + BUNDLE_IDENTIFIER="com.t3tools.t3code.swiftui" +fi +WIDGET_BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER}.widgets" +SHARE_BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER}.sharing" + +[[ -z "${T3_SWIFT_BUNDLE_IDENTIFIER:-}" || "${T3_SWIFT_BUNDLE_IDENTIFIER}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "custom bundle identifiers are unsupported; select the Debug or Release identity with T3_SWIFT_CONFIGURATION" + +bundle_identifier_for_target() { + local target="$1" + xcodebuild -showBuildSettings \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -target "${target}" \ + -configuration "${CONFIGURATION}" \ + | awk '$1 == "PRODUCT_BUNDLE_IDENTIFIER" && $2 == "=" { print $3; exit }' +} + +verify_bundle_identifiers() { + local host widgets share + host="$(bundle_identifier_for_target T3Code)" + widgets="$(bundle_identifier_for_target T3CodeWidgets)" + share="$(bundle_identifier_for_target T3CodeShare)" + [[ "${host}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "host bundle identifier resolved to '${host}'" + [[ "${widgets}" == "${WIDGET_BUNDLE_IDENTIFIER}" ]] || die \ + "widget bundle identifier resolved to '${widgets}'" + [[ "${share}" == "${SHARE_BUNDLE_IDENTIFIER}" ]] || die \ + "share bundle identifier resolved to '${share}'" + printf '[swift-ios-device] bundle identifiers: %s, %s, %s\n' \ + "${host}" "${widgets}" "${share}" +} + +if [[ "${T3_SWIFT_VERIFY_BUNDLE_IDENTIFIERS_ONLY:-0}" == "1" ]]; then + verify_bundle_identifiers + exit 0 +fi + +[[ -n "${DEVICE_ID}" ]] || die \ + "set T3_SWIFT_DEVICE_ID to a CoreDevice identifier or UDID from 'xcrun devicectl list devices --columns UDID'" +[[ -n "${DEVELOPMENT_TEAM}" ]] || die \ + "set T3_SWIFT_DEVELOPMENT_TEAM to your Apple Developer team ID" + +build_settings=( + "DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}" +) + +DEVICE_JSON="$(mktemp -t t3-swift-devices.XXXXXX)" +trap 'unlink "${DEVICE_JSON}" 2>/dev/null || true' EXIT +xcrun devicectl list devices --json-output "${DEVICE_JSON}" --quiet >/dev/null +DESTINATION_ID="$( + xcrun swift "${SCRIPT_DIR}/resolve-device-udid.swift" "${DEVICE_JSON}" "${DEVICE_ID}" +)" || die "could not resolve device '${DEVICE_ID}' to an Xcode destination UDID" + +for key in \ + T3CODE_CLERK_PUBLISHABLE_KEY \ + T3CODE_CLERK_JWT_TEMPLATE \ + T3CODE_RELAY_URL; do + value="${!key:-}" + if [[ -n "${value}" ]]; then + build_settings+=("${key}=${value}") + fi +done + +if [[ -n "${T3_SWIFT_VERSION:-}" ]]; then + build_settings+=("MARKETING_VERSION=${T3_SWIFT_VERSION}") +fi +if [[ -n "${T3_SWIFT_BUILD_NUMBER:-}" ]]; then + build_settings+=("CURRENT_PROJECT_VERSION=${T3_SWIFT_BUILD_NUMBER}") +fi + +printf '[swift-ios-device] building %s for %s\n' "${BUNDLE_IDENTIFIER}" "${DESTINATION_ID}" +xcodebuild build \ + -project "${APP_DIR}/T3Code.xcodeproj" \ + -scheme T3Code \ + -configuration "${CONFIGURATION}" \ + -destination "platform=iOS,id=${DESTINATION_ID}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration \ + "${build_settings[@]}" + +APP_PATH="${DERIVED_DATA_PATH}/Build/Products/${CONFIGURATION}-iphoneos/T3Code.app" +[[ -d "${APP_PATH}" ]] || die "built app was not found at ${APP_PATH}" + +actual_host="$(plutil -extract CFBundleIdentifier raw -o - "${APP_PATH}/Info.plist")" +actual_widgets="$( + plutil -extract CFBundleIdentifier raw -o - \ + "${APP_PATH}/PlugIns/T3CodeWidgets.appex/Info.plist" +)" +actual_share="$( + plutil -extract CFBundleIdentifier raw -o - \ + "${APP_PATH}/PlugIns/T3CodeShare.appex/Info.plist" +)" +[[ "${actual_host}" == "${BUNDLE_IDENTIFIER}" ]] || die \ + "built host bundle identifier is '${actual_host}'" +[[ "${actual_widgets}" == "${WIDGET_BUNDLE_IDENTIFIER}" ]] || die \ + "built widget bundle identifier is '${actual_widgets}'" +[[ "${actual_share}" == "${SHARE_BUNDLE_IDENTIFIER}" ]] || die \ + "built share bundle identifier is '${actual_share}'" + +printf '[swift-ios-device] installing %s\n' "${APP_PATH}" +xcrun devicectl device install app --device "${DESTINATION_ID}" "${APP_PATH}" + +printf '[swift-ios-device] launching %s\n' "${BUNDLE_IDENTIFIER}" +if ! launch_output="$( + xcrun devicectl device process launch \ + --device "${DESTINATION_ID}" \ + "${BUNDLE_IDENTIFIER}" 2>&1 +)"; then + printf '%s\n' "${launch_output}" >&2 + if [[ "${launch_output}" == *"BSErrorCodeDescription = Locked"* ]]; then + printf '[swift-ios-device] installed; unlock the device to launch the app\n' + exit 0 + fi + exit 1 +fi +printf '%s\n' "${launch_output}" diff --git a/apps/swift-ios/Scripts/resolve-device-udid.swift b/apps/swift-ios/Scripts/resolve-device-udid.swift new file mode 100644 index 000000000000..70d4d0315622 --- /dev/null +++ b/apps/swift-ios/Scripts/resolve-device-udid.swift @@ -0,0 +1,47 @@ +import Foundation + +private struct DeviceList: Decodable { + struct Result: Decodable { + struct Device: Decodable { + struct HardwareProperties: Decodable { + let udid: String? + } + + let identifier: String + let hardwareProperties: HardwareProperties? + } + + let devices: [Device] + } + + let result: Result +} + +guard CommandLine.arguments.count == 3 else { + FileHandle.standardError.write(Data("usage: resolve-device-udid \n".utf8)) + exit(64) +} + +do { + let payload = try JSONDecoder().decode( + DeviceList.self, + from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])) + ) + let requested = CommandLine.arguments[2] + guard let udid = payload.result.devices.lazy.compactMap({ device -> String? in + guard let udid = device.hardwareProperties?.udid, !udid.isEmpty else { + return nil + } + let matchesIdentifier = device.identifier.caseInsensitiveCompare(requested) == .orderedSame + let matchesUDID = udid.caseInsensitiveCompare(requested) == .orderedSame + return matchesIdentifier || matchesUDID ? udid : nil + }).first else { + throw CocoaError(.fileNoSuchFile) + } + print(udid) +} catch { + FileHandle.standardError.write( + Data("Could not resolve that device identifier: \(error.localizedDescription)\n".utf8) + ) + exit(1) +} diff --git a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..7e31fa17553f --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj @@ -0,0 +1,1003 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + B10000000000000000000001 /* ClerkKit in Frameworks */ = {isa = PBXBuildFile; productRef = B30000000000000000000001 /* ClerkKit */; }; + B10000000000000000000002 /* ClerkKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = B30000000000000000000002 /* ClerkKitUI */; }; + D10000000000000000000001 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D40000000000000000000001 /* GhosttyKit.xcframework */; }; + C10000000000000000000001 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000001 /* AgentActivityAttributes.swift */; }; + C10000000000000000000002 /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C10000000000000000000003 /* TaskWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000003 /* TaskWidgetSnapshot.swift */; }; + C10000000000000000000004 /* ShareInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000004 /* ShareInbox.swift */; }; + C10000000000000000000005 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000001 /* AgentActivityAttributes.swift */; }; + C10000000000000000000006 /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C10000000000000000000007 /* TaskWidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000003 /* TaskWidgetSnapshot.swift */; }; + C10000000000000000000008 /* T3CodeWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000006 /* T3CodeWidgets.swift */; }; + C10000000000000000000009 /* AgentActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000007 /* AgentActivityWidget.swift */; }; + C1000000000000000000000A /* RecentTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000008 /* RecentTasksWidget.swift */; }; + C1000000000000000000000B /* SharedContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000002 /* SharedContainer.swift */; }; + C1000000000000000000000C /* ShareInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000004 /* ShareInbox.swift */; }; + C1000000000000000000000D /* SharePayloadLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000B /* SharePayloadLoader.swift */; }; + C1000000000000000000000E /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000C /* ShareViewController.swift */; }; + C1000000000000000000000F /* T3CodeWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = C4000000000000000000000F /* T3CodeWidgets.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C10000000000000000000010 /* T3CodeShare.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = C40000000000000000000010 /* T3CodeShare.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C10000000000000000000011 /* ExtensionContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40000000000000000000011 /* ExtensionContractTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + CE0000000000000000000001 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C1000000000000000000000F /* T3CodeWidgets.appex in Embed App Extensions */, + C10000000000000000000010 /* T3CodeShare.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BE0000000000000000000001 /* Exceptions for "Resources" folder in "T3Code" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A50000000000000000000001 /* T3Code */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A10000000000000000000001 /* App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = App; + sourceTree = ""; + }; + A10000000000000000000002 /* Core */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Core; + sourceTree = ""; + }; + A10000000000000000000003 /* Features */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Features; + sourceTree = ""; + }; + A10000000000000000000004 /* DesignSystem */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = DesignSystem; + sourceTree = ""; + }; + A10000000000000000000005 /* Resources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BE0000000000000000000001 /* Exceptions for "Resources" folder in "T3Code" target */, + ); + path = Resources; + sourceTree = ""; + }; + A10000000000000000000006 /* Tests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A20000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B10000000000000000000001 /* ClerkKit in Frameworks */, + B10000000000000000000002 /* ClerkKitUI in Frameworks */, + D10000000000000000000001 /* GhosttyKit.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A20000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C20000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C20000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + AC0000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = T3Code; + }; + CC0000000000000000000002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C50000000000000000000003; + remoteInfo = T3CodeWidgets; + }; + CC0000000000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A90000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C50000000000000000000004; + remoteInfo = T3CodeShare; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXGroup section */ + A30000000000000000000001 = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + A10000000000000000000006 /* Tests */, + C30000000000000000000001 /* Extensions */, + D40000000000000000000001 /* GhosttyKit.xcframework */, + E40000000000000000000001 /* T3Code.xcconfig */, + A30000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + A30000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + A40000000000000000000001 /* T3 Code.app */, + A40000000000000000000002 /* T3CodeTests.xctest */, + C4000000000000000000000F /* T3CodeWidgets.appex */, + C40000000000000000000010 /* T3CodeShare.appex */, + ); + name = Products; + sourceTree = ""; + }; + C30000000000000000000001 /* Extensions */ = { + isa = PBXGroup; + children = ( + C30000000000000000000002 /* Shared */, + C30000000000000000000003 /* Widgets */, + C30000000000000000000004 /* Share */, + C30000000000000000000005 /* Tests */, + ); + path = Extensions; + sourceTree = ""; + }; + C30000000000000000000002 /* Shared */ = { + isa = PBXGroup; + children = ( + C40000000000000000000001 /* AgentActivityAttributes.swift */, + C40000000000000000000002 /* SharedContainer.swift */, + C40000000000000000000003 /* TaskWidgetSnapshot.swift */, + C40000000000000000000004 /* ShareInbox.swift */, + C40000000000000000000005 /* T3Code.entitlements */, + ); + path = Shared; + sourceTree = ""; + }; + C30000000000000000000003 /* Widgets */ = { + isa = PBXGroup; + children = ( + C40000000000000000000006 /* T3CodeWidgets.swift */, + C40000000000000000000007 /* AgentActivityWidget.swift */, + C40000000000000000000008 /* RecentTasksWidget.swift */, + C40000000000000000000009 /* Info.plist */, + C4000000000000000000000A /* T3CodeWidgets.entitlements */, + ); + path = Widgets; + sourceTree = ""; + }; + C30000000000000000000004 /* Share */ = { + isa = PBXGroup; + children = ( + C4000000000000000000000B /* SharePayloadLoader.swift */, + C4000000000000000000000C /* ShareViewController.swift */, + C4000000000000000000000D /* Info.plist */, + C4000000000000000000000E /* T3CodeShare.entitlements */, + ); + path = Share; + sourceTree = ""; + }; + C30000000000000000000005 /* Tests */ = { + isa = PBXGroup; + children = ( + C40000000000000000000011 /* ExtensionContractTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A50000000000000000000001 /* T3Code */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */; + buildPhases = ( + A70000000000000000000001 /* Sources */, + A20000000000000000000001 /* Frameworks */, + A80000000000000000000001 /* Resources */, + CE0000000000000000000001 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + CD0000000000000000000002 /* PBXTargetDependency */, + CD0000000000000000000003 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000001 /* App */, + A10000000000000000000002 /* Core */, + A10000000000000000000003 /* Features */, + A10000000000000000000004 /* DesignSystem */, + A10000000000000000000005 /* Resources */, + ); + name = T3Code; + packageProductDependencies = ( + B30000000000000000000001 /* ClerkKit */, + B30000000000000000000002 /* ClerkKitUI */, + ); + productName = T3Code; + productReference = A40000000000000000000001 /* T3 Code.app */; + productType = "com.apple.product-type.application"; + }; + A50000000000000000000002 /* T3CodeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */; + buildPhases = ( + A70000000000000000000002 /* Sources */, + A20000000000000000000002 /* Frameworks */, + A80000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AD0000000000000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A10000000000000000000006 /* Tests */, + ); + name = T3CodeTests; + packageProductDependencies = ( + ); + productName = T3CodeTests; + productReference = A40000000000000000000002 /* T3CodeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + C50000000000000000000003 /* T3CodeWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = C60000000000000000000004 /* Build configuration list for PBXNativeTarget "T3CodeWidgets" */; + buildPhases = ( + C70000000000000000000003 /* Sources */, + C20000000000000000000003 /* Frameworks */, + C80000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T3CodeWidgets; + packageProductDependencies = ( + ); + productName = T3CodeWidgets; + productReference = C4000000000000000000000F /* T3CodeWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + C50000000000000000000004 /* T3CodeShare */ = { + isa = PBXNativeTarget; + buildConfigurationList = C60000000000000000000005 /* Build configuration list for PBXNativeTarget "T3CodeShare" */; + buildPhases = ( + C70000000000000000000004 /* Sources */, + C20000000000000000000004 /* Frameworks */, + C80000000000000000000004 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = T3CodeShare; + packageProductDependencies = ( + ); + productName = T3CodeShare; + productReference = C40000000000000000000010 /* T3CodeShare.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A90000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + com.apple.SafariKeychain = { + enabled = 1; + }; + com.apple.SignInWithApple = { + enabled = 1; + }; + }; + }; + A50000000000000000000002 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = A50000000000000000000001; + }; + C50000000000000000000003 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + }; + }; + C50000000000000000000004 = { + CreatedOnToolsVersion = 16.0; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + }; + }; + }; + }; + buildConfigurationList = A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */; + compatibilityVersion = "Xcode 16.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A30000000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A30000000000000000000002 /* Products */; + packageReferences = ( + B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* T3Code */, + A50000000000000000000002 /* T3CodeTests */, + C50000000000000000000003 /* T3CodeWidgets */, + C50000000000000000000004 /* T3CodeShare */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A80000000000000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A80000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C80000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C80000000000000000000004 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A70000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000001 /* AgentActivityAttributes.swift in Sources */, + C10000000000000000000002 /* SharedContainer.swift in Sources */, + C10000000000000000000003 /* TaskWidgetSnapshot.swift in Sources */, + C10000000000000000000004 /* ShareInbox.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A70000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000011 /* ExtensionContractTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C70000000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C10000000000000000000005 /* AgentActivityAttributes.swift in Sources */, + C10000000000000000000006 /* SharedContainer.swift in Sources */, + C10000000000000000000007 /* TaskWidgetSnapshot.swift in Sources */, + C10000000000000000000008 /* T3CodeWidgets.swift in Sources */, + C10000000000000000000009 /* AgentActivityWidget.swift in Sources */, + C1000000000000000000000A /* RecentTasksWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C70000000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C1000000000000000000000B /* SharedContainer.swift in Sources */, + C1000000000000000000000C /* ShareInbox.swift in Sources */, + C1000000000000000000000D /* SharePayloadLoader.swift in Sources */, + C1000000000000000000000E /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AD0000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* T3Code */; + targetProxy = AC0000000000000000000001 /* PBXContainerItemProxy */; + }; + CD0000000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C50000000000000000000003 /* T3CodeWidgets */; + targetProxy = CC0000000000000000000002 /* PBXContainerItemProxy */; + }; + CD0000000000000000000003 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C50000000000000000000004 /* T3CodeShare */; + targetProxy = CC0000000000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + AB0000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E40000000000000000000001 /* T3Code.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AB0000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E40000000000000000000001 /* T3Code.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Release; + }; + AB0000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APS_ENVIRONMENT = development; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDev; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Extensions/Shared/T3Code.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(T3CODE_DEBUG_DISPLAY_NAME)"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes and attach photos to T3 Code tasks."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-lz", + "-framework", + IOSurface, + "-framework", + Metal, + "-framework", + MetalKit, + "-framework", + QuartzCore, + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev"; + T3CODE_URL_SCHEME = t3code-swiftui-dev; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AB0000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APS_ENVIRONMENT = production; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Extensions/Shared/T3Code.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(T3CODE_RELEASE_DISPLAY_NAME)"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan pairing QR codes and attach photos to T3 Code tasks."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Allow T3 Code to connect to T3 Code servers on your local network or tailnet."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; + INFOPLIST_KEY_NSSupportsLiveActivitiesFrequentUpdates = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lc++", + "-lz", + "-framework", + IOSurface, + "-framework", + Metal, + "-framework", + MetalKit, + "-framework", + QuartzCore, + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX)"; + T3CODE_URL_SCHEME = t3code-swiftui; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AB0000000000000000000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Debug; + }; + AB0000000000000000000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/T3Code.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/T3Code"; + }; + name = Release; + }; + CB0000000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Widgets/T3CodeWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Widgets/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev.widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev"; + T3CODE_WIDGET_DISPLAY_NAME = "T3 Swift Dev Widgets"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CB0000000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Widgets/T3CodeWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Widgets/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).widgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX)"; + T3CODE_WIDGET_DISPLAY_NAME = "T3 Code Widgets"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CB0000000000000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Share/T3CodeShare.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Share/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev.sharing"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).dev"; + T3CODE_SHARE_DISPLAY_NAME = "T3 Swift Dev"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CB000000000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = Extensions/Share/T3CodeShare.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 40; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Extensions/Share/Info.plist; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(T3CODE_BUNDLE_IDENTIFIER_PREFIX).sharing"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + T3CODE_APP_GROUP_IDENTIFIER = "group.$(T3CODE_BUNDLE_IDENTIFIER_PREFIX)"; + T3CODE_SHARE_DISPLAY_NAME = "T3 Code (SwiftUI)"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A60000000000000000000001 /* Build configuration list for PBXProject "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000001 /* Debug */, + AB0000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000002 /* Build configuration list for PBXNativeTarget "T3Code" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000003 /* Debug */, + AB0000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A60000000000000000000003 /* Build configuration list for PBXNativeTarget "T3CodeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB0000000000000000000005 /* Debug */, + AB0000000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C60000000000000000000004 /* Build configuration list for PBXNativeTarget "T3CodeWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CB0000000000000000000007 /* Debug */, + CB0000000000000000000008 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C60000000000000000000005 /* Build configuration list for PBXNativeTarget "T3CodeShare" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CB0000000000000000000009 /* Debug */, + CB000000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/clerk/clerk-ios.git"; + requirement = { + kind = exactVersion; + version = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B30000000000000000000001 /* ClerkKit */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */; + productName = ClerkKit; + }; + B30000000000000000000002 /* ClerkKitUI */ = { + isa = XCSwiftPackageProductDependency; + package = B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */; + productName = ClerkKitUI; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin PBXFileReference section */ + A40000000000000000000001 /* T3 Code.app */ = { + isa = PBXFileReference; + explicitFileType = wrapper.application; + includeInIndex = 0; + path = "T3 Code.app"; + sourceTree = BUILT_PRODUCTS_DIR; + }; + A40000000000000000000002 /* T3CodeTests.xctest */ = { + isa = PBXFileReference; + explicitFileType = wrapper.cfbundle; + includeInIndex = 0; + path = T3CodeTests.xctest; + sourceTree = BUILT_PRODUCTS_DIR; + }; + C40000000000000000000001 /* AgentActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityAttributes.swift; sourceTree = ""; }; + C40000000000000000000002 /* SharedContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedContainer.swift; sourceTree = ""; }; + C40000000000000000000003 /* TaskWidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskWidgetSnapshot.swift; sourceTree = ""; }; + C40000000000000000000004 /* ShareInbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInbox.swift; sourceTree = ""; }; + C40000000000000000000005 /* T3Code.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3Code.entitlements; sourceTree = ""; }; + C40000000000000000000006 /* T3CodeWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeWidgets.swift; sourceTree = ""; }; + C40000000000000000000007 /* AgentActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityWidget.swift; sourceTree = ""; }; + C40000000000000000000008 /* RecentTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentTasksWidget.swift; sourceTree = ""; }; + C40000000000000000000009 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C4000000000000000000000A /* T3CodeWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3CodeWidgets.entitlements; sourceTree = ""; }; + C4000000000000000000000B /* SharePayloadLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePayloadLoader.swift; sourceTree = ""; }; + C4000000000000000000000C /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + C4000000000000000000000D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C4000000000000000000000E /* T3CodeShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = T3CodeShare.entitlements; sourceTree = ""; }; + C4000000000000000000000F /* T3CodeWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = T3CodeWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C40000000000000000000010 /* T3CodeShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = T3CodeShare.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + C40000000000000000000011 /* ExtensionContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionContractTests.swift; sourceTree = ""; }; + D40000000000000000000001 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework; sourceTree = ""; }; + E40000000000000000000001 /* T3Code.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = T3Code.xcconfig; path = Config/T3Code.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + }; + rootObject = A90000000000000000000001 /* Project object */; +} diff --git a/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000000..828e8e704d0a --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "0da9fa23290c75a06cf2d88ffeab37061a648ea0656f8262ec633d415fa7466a", + "pins" : [ + { + "identity" : "clerk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/clerk/clerk-ios.git", + "state" : { + "revision" : "d0a5f2231dcb4b66e091a514ce2d0bead9056404", + "version" : "1.2.0" + } + }, + { + "identity" : "nuke", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Nuke.git", + "state" : { + "revision" : "30f7a7e72e0607d304fbf69c799474bd5fb6d1ce", + "version" : "13.2.0" + } + }, + { + "identity" : "phonenumberkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/PhoneNumberKit", + "state" : { + "revision" : "169ab10234347fb19b37441f2867ace896a284b0", + "version" : "4.3.0" + } + } + ], + "version" : 3 +} diff --git a/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme new file mode 100644 index 000000000000..45680ddc0e70 --- /dev/null +++ b/apps/swift-ios/T3Code.xcodeproj/xcshareddata/xcschemes/T3Code.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift new file mode 100644 index 000000000000..20775900c61c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/CoreContractTests.swift @@ -0,0 +1,481 @@ +import XCTest +@testable import T3Code + +@MainActor +final class CoreContractTests: XCTestCase { + func testJSONValueRoundTripsLargeIntegersWithoutDoubleRounding() throws { + let signedData = Data("9007199254740993".utf8) + let signed = try JSONDecoder.t3.decode(JSONValue.self, from: signedData) + XCTAssertEqual(signed, .integer(9_007_199_254_740_993)) + XCTAssertEqual(try JSONEncoder.t3.encode(signed), signedData) + + let unsignedData = Data("18446744073709551615".utf8) + let unsigned = try JSONDecoder.t3.decode(JSONValue.self, from: unsignedData) + XCTAssertEqual(unsigned, .unsignedInteger(UInt64.max)) + XCTAssertEqual(try JSONEncoder.t3.encode(unsigned), unsignedData) + } + + func testDirectAndHostedPairingURLsResolveLikeExistingClients() throws { + let direct = try PairingURL.resolve("https://studio.example/pair#token=secret") + XCTAssertEqual(direct.credential, "secret") + XCTAssertEqual(direct.httpBaseURL.absoluteString, "https://studio.example/") + XCTAssertEqual(direct.webSocketBaseURL.absoluteString, "wss://studio.example/") + + let hosted = try PairingURL.resolve( + "https://app.t3.codes/pair?host=https%3A%2F%2Fremote.example#token=hosted-secret" + ) + XCTAssertEqual(hosted.credential, "hosted-secret") + XCTAssertEqual(hosted.httpBaseURL.absoluteString, "https://remote.example/") + XCTAssertEqual(hosted.webSocketBaseURL.absoluteString, "wss://remote.example/") + } + + func testShellSnapshotDecodesCurrentWireShape() throws { + let data = Data( + """ + { + "snapshotSequence": 7, + "projects": [{ + "id": "project-1", + "title": "T3 Code", + "workspaceRoot": "/work/t3", + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol" + }, + "scripts": [], + "createdAt": "2026-07-30T12:00:00.000Z", + "updatedAt": "2026-07-30T12:00:00.000Z" + }], + "threads": [], + "updatedAt": "2026-07-30T12:00:00.000Z" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(OrchestrationShellSnapshot.self, from: data) + XCTAssertEqual(snapshot.snapshotSequence, 7) + XCTAssertEqual(snapshot.projects.first?.defaultModelSelection?.instanceId, "codex") + XCTAssertNil(snapshot.projects.first?.deletedAt) + } + + func testThreadPageMetadataDecodesCurrentWireShape() throws { + let page = try JSONDecoder.t3.decode( + OrchestrationThreadDetailPage.self, + from: Data( + #"{"beforeCursor":"opaque-cursor","hasMore":true,"snapshotSequence":42,"threadSequence":39}"#.utf8 + ) + ) + + XCTAssertEqual(page.beforeCursor, "opaque-cursor") + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.snapshotSequence, 42) + XCTAssertEqual(page.threadSequence, 39) + } + + func testServerConfigAdvertisesThreadSnapshotPagination() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data(#"{"providers":[],"threadSnapshotPagination":true}"#.utf8) + ) + + XCTAssertEqual(config.threadSnapshotPagination, true) + XCTAssertNil(config.environment) + } + + func testEnvironmentDescriptorRequiresExplicitAutomaticSettlementCapability() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + #"{"providers":[],"environment":{"environmentId":"wire-environment","label":"Studio","platform":{"os":"darwin","arch":"arm64"},"serverVersion":"1.0.0","capabilities":{"repositoryIdentity":true,"threadAutoSettlement":true}}}"#.utf8 + ) + ) + let supported = try XCTUnwrap(config.environment) + let absent = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + #"{"environmentId":"older-server","label":"Old","platform":{"os":"darwin","arch":"arm64"},"serverVersion":"0.9.0","capabilities":{"repositoryIdentity":true}}"#.utf8 + ) + ) + + XCTAssertEqual(supported.capabilities.threadAutoSettlement, true) + XCTAssertNil(absent.capabilities.threadAutoSettlement) + } + + func testAutomaticSettlementSettingsPreserveNullAndApplyMissingDefaults() throws { + let missing = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data("{}".utf8) + ) + let explicit = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data( + #"{"sidebarAutoSettleOnMerge":false,"sidebarAutoSettleAfterDays":null}"#.utf8 + ) + ) + let fractional = try JSONDecoder.t3.decode( + ServerSettingsSnapshot.self, + from: Data(#"{"sidebarAutoSettleAfterDays":2.5}"#.utf8) + ) + + XCTAssertTrue(missing.sidebarAutoSettleOnMerge) + XCTAssertEqual(missing.sidebarAutoSettleAfterDays, 3) + XCTAssertFalse(explicit.sidebarAutoSettleOnMerge) + XCTAssertNil(explicit.sidebarAutoSettleAfterDays) + XCTAssertEqual(fractional.sidebarAutoSettleAfterDays, 2.5) + } + + func testAutomaticSettlementPatchesContainOnlyTheChangedKey() { + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleOnMerge(false).jsonValue, + .object(["sidebarAutoSettleOnMerge": .bool(false)]) + ) + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleAfterDays(4.5).jsonValue, + .object(["sidebarAutoSettleAfterDays": .number(4.5)]) + ) + XCTAssertEqual( + ServerSettingsChange.sidebarAutoSettleAfterDays(nil).jsonValue, + .object(["sidebarAutoSettleAfterDays": .null]) + ) + XCTAssertEqual(RPCMethod.serverUpdateSettings.rawValue, "server.updateSettings") + } + + func testCommandBuildersMatchOrchestrationContract() throws { + let model = ModelSelection(instanceId: "codex", model: "gpt-5.6-sol") + let command = try OrchestrationCommands.createThread( + threadID: "thread-1", + projectID: "project-1", + title: "Native rebuild", + model: model, + runtimeMode: .fullAccess, + commandID: "command-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + XCTAssertEqual(command["type"]?.stringValue, "thread.create") + XCTAssertEqual(command["threadId"]?.stringValue, "thread-1") + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + XCTAssertEqual(command["runtimeMode"]?.stringValue, "full-access") + + let pin = OrchestrationCommands.pin( + threadID: "thread-1", + pinned: true, + commandID: "command-pin" + ) + XCTAssertEqual(pin["type"]?.stringValue, "thread.pin") + XCTAssertEqual(pin["threadId"]?.stringValue, "thread-1") + + let unpin = OrchestrationCommands.pin( + threadID: "thread-1", + pinned: false, + commandID: "command-unpin" + ) + XCTAssertEqual(unpin["type"]?.stringValue, "thread.unpin") + } + + func testRegenerateTitleCommandMatchesOrchestrationContract() { + let command = OrchestrationCommands.regenerateTitle( + threadID: "thread-1", + commandID: "command-title" + ) + + XCTAssertEqual( + command, + .object([ + "type": .string("thread.meta.update"), + "commandId": .string("command-title"), + "threadId": .string("thread-1"), + "regenerateTitle": .bool(true), + ]) + ) + } + + func testFirstSendCommandCarriesCanonicalBootstrapMetadata() throws { + let model = ModelSelection(instanceId: "codex", model: "gpt-5.4") + let command = try OrchestrationCommands.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Build the native app", + text: "Build the native app", + model: model, + runtimeMode: .fullAccess, + commandID: "command-first-send", + messageID: "message-first-send", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + XCTAssertEqual(command["type"]?.stringValue, "thread.turn.start") + XCTAssertEqual(command["titleSeed"]?.stringValue, "Build the native app") + XCTAssertEqual(command["modelSelection"]?["model"]?.stringValue, "gpt-5.4") + XCTAssertEqual( + command["bootstrap"]?["createThread"]?["projectId"]?.stringValue, + "project-1" + ) + XCTAssertEqual( + command["bootstrap"]?["createThread"]?["modelSelection"]?["instanceId"]?.stringValue, + "codex" + ) + } + + func testFirstSendCanPrepareAWorktreeBeforeDispatchingTheTurn() throws { + let command = try OrchestrationCommands.createThreadAndSend( + threadID: "thread-worktree", + projectID: "project-1", + title: "Build in isolation", + text: "Build in isolation", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + branch: "main", + worktreePreparation: ThreadWorktreePreparation( + projectCwd: "/work/t3", + baseBranch: "main", + branch: "t3code/deadbeef", + startFromOrigin: true + ) + ) + + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["projectCwd"]?.stringValue, + "/work/t3" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["baseBranch"]?.stringValue, + "main" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["branch"]?.stringValue, + "t3code/deadbeef" + ) + XCTAssertEqual( + command["bootstrap"]?["prepareWorktree"]?["startFromOrigin"], + .bool(true) + ) + XCTAssertEqual(command["bootstrap"]?["runSetupScript"], .bool(true)) + } + + func testEnvironmentStorePersistsSelectionAndClearsRemovedActiveEnvironment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-core-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = EnvironmentStore(fileURL: file) + let first = Environment( + id: "one", + label: "One", + httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example")! + ) + let second = Environment( + id: "two", + label: "Two", + httpBaseURL: URL(string: "https://two.example")!, + webSocketBaseURL: URL(string: "wss://two.example")! + ) + + try await store.save([first, second]) + try await store.setActiveEnvironment(id: second.id) + let selected = try await store.activeEnvironmentID() + XCTAssertEqual(selected, second.id) + + let remaining = try await store.remove(id: second.id) + let fallback = try await store.activeEnvironmentID() + XCTAssertEqual(remaining.map(\.id), [first.id]) + XCTAssertEqual(fallback, first.id) + } + + func testKeychainCredentialUpdatesAreAtomicAcrossStoreInstances() async throws { + let service = "codes.t3.swift-ios.credential-tests.\(UUID().uuidString)" + let environmentID = "shared-environment" + let backend = InMemoryKeychainCredentialBackend() + let first = KeychainCredentialStore(service: service, backend: backend) + let second = KeychainCredentialStore(service: service, backend: backend) + + do { + for index in 0..<12 { + let original = EnvironmentCredential(accessToken: "original-\(index)") + let replacement = EnvironmentCredential(accessToken: "replacement-\(index)") + try await first.setCredential(original, for: environmentID) + + async let replaced = first.replaceCredential( + replacement, + ifMatching: original, + for: environmentID + ) + async let removed = second.removeCredential( + ifMatching: original, + for: environmentID + ) + let (didReplace, didRemove) = try await (replaced, removed) + + XCTAssertNotEqual(didReplace, didRemove) + let stored = try await first.credential(for: environmentID) + XCTAssertEqual(stored, didReplace ? replacement : nil) + } + try await first.removeCredential(for: environmentID) + } catch { + try? await first.removeCredential(for: environmentID) + throw error + } + } + + func testRuntimeReplacesCachedClientWhenSavedEndpointChanges() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-runtime-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = EnvironmentStore(fileURL: file) + let first = Environment( + id: "same-server", + label: "Studio", + httpBaseURL: URL(string: "http://192.168.1.10:3773")!, + webSocketBaseURL: URL(string: "ws://192.168.1.10:3773")! + ) + let moved = Environment( + id: "same-server", + label: "Studio", + httpBaseURL: URL(string: "http://192.168.1.20:4773")!, + webSocketBaseURL: URL(string: "ws://192.168.1.20:4773")! + ) + try await store.save([first]) + try await store.setActiveEnvironment(id: first.id) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore() + ) + + let firstClientValue = try await runtime.activeClient() + let firstClient = try XCTUnwrap(firstClientValue) + try await store.upsert(moved) + let movedClientValue = try await runtime.activeClient() + let movedClient = try XCTUnwrap(movedClientValue) + let movedEnvironment = await movedClient.environment + + XCTAssertFalse(firstClient === movedClient) + XCTAssertEqual(movedEnvironment.httpBaseURL, moved.httpBaseURL) + XCTAssertEqual(movedEnvironment.webSocketBaseURL, moved.webSocketBaseURL) + } + + func testRuntimeRemovesCatalogEntryBeforeDestroyingCredential() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-removal-\(UUID().uuidString)", isDirectory: true) + let file = directory.appendingPathComponent("environments.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "removable", + label: "Removable", + httpBaseURL: URL(string: "https://remove.example")!, + webSocketBaseURL: URL(string: "wss://remove.example")! + ) + let store = EnvironmentStore(fileURL: file) + try await store.save([environment]) + let credentials = RemovalOrderCredentialStore( + environmentStore: store, + environmentID: environment.id, + credential: EnvironmentCredential(accessToken: "secret") + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + + try await runtime.remove(id: environment.id) + + let catalogContainedEnvironment = await credentials.catalogContainedEnvironmentOnRemoval + XCTAssertEqual(catalogContainedEnvironment, false) + let remaining = try await store.load() + XCTAssertTrue(remaining.isEmpty) + let credential = await credentials.credential(for: environment.id) + XCTAssertNil(credential) + } +} + +private final class InMemoryKeychainCredentialBackend: @unchecked Sendable, + KeychainCredentialBackend +{ + private var credentials: [String: EnvironmentCredential] = [:] + + func credential(for environmentID: String) -> EnvironmentCredential? { + credentials[environmentID] + } + + func setCredential(_ credential: EnvironmentCredential, for environmentID: String) { + credentials[environmentID] = credential + } + + func removeCredential(for environmentID: String) { + credentials.removeValue(forKey: environmentID) + } +} + +private actor RemovalOrderCredentialStore: CredentialStore { + let environmentStore: EnvironmentStore + let environmentID: String + var storedCredential: EnvironmentCredential? + private(set) var catalogContainedEnvironmentOnRemoval: Bool? + + init( + environmentStore: EnvironmentStore, + environmentID: String, + credential: EnvironmentCredential + ) { + self.environmentStore = environmentStore + self.environmentID = environmentID + storedCredential = credential + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + environmentID == self.environmentID ? storedCredential : nil + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + guard environmentID == self.environmentID else { return } + storedCredential = credential + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + guard environmentID == self.environmentID else { return nil } + let previousCredential = storedCredential + storedCredential = credential + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard environmentID == self.environmentID, + storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) async throws { + guard environmentID == self.environmentID else { return } + catalogContainedEnvironmentOnRemoval = try await environmentStore.load() + .contains(where: { $0.id == environmentID }) + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) async throws -> Bool { + guard environmentID == self.environmentID, + storedCredential == expected else { return false } + catalogContainedEnvironmentOnRemoval = try await environmentStore.load() + .contains(where: { $0.id == environmentID }) + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} diff --git a/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift b/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift new file mode 100644 index 000000000000..c49e38450235 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/EnvironmentConnectionStateTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Environment connection state") +struct EnvironmentConnectionStateTests { + @Test + func oldEnvironmentRecordsDefaultToEnabled() throws { + let data = Data( + #"{"id":"studio","label":"Studio","httpBaseURL":"https://studio.example","webSocketBaseURL":"wss://studio.example/ws","kind":"bearer"}"#.utf8 + ) + + let environment = try JSONDecoder.t3.decode(Environment.self, from: data) + + #expect(environment.isEnabled) + } + + @Test + func oldFeatureEnvironmentRecordsDefaultToDirectAndEnabled() throws { + let data = Data( + #"{"id":"studio","name":"Studio","endpoint":"https://studio.example","isActive":true}"#.utf8 + ) + + let environment = try JSONDecoder.t3.decode(FeatureEnvironment.self, from: data) + + #expect(environment.isEnabled) + #expect(environment.source == .direct) + } + + @Test + func disablingLastUsedEnvironmentSelectsAnotherEnabledFallback() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("environment-enabled-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let store = EnvironmentStore(fileURL: directory.appendingPathComponent("environments.json")) + let studio = environment(id: "studio") + let laptop = environment(id: "laptop") + try await store.save([studio, laptop]) + try await store.setActiveEnvironment(id: studio.id) + + let afterStudio = try await store.setEnabled(id: studio.id, enabled: false) + + #expect(afterStudio.first(where: { $0.id == studio.id })?.isEnabled == false) + #expect(try await store.activeEnvironmentID() == laptop.id) + + _ = try await store.setEnabled(id: laptop.id, enabled: false) + #expect(try await store.activeEnvironmentID() == nil) + + _ = try await store.setEnabled(id: studio.id, enabled: true) + #expect(try await store.activeEnvironmentID() == nil) + #expect(try await store.load().first(where: { $0.id == studio.id })?.isEnabled == true) + } + + private func environment(id: String) -> Environment { + Environment( + id: id, + label: id.capitalized, + httpBaseURL: URL(string: "https://\(id).example")!, + webSocketBaseURL: URL(string: "wss://\(id).example/ws")! + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift new file mode 100644 index 000000000000..d2e347fe8ae2 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift @@ -0,0 +1,495 @@ +import XCTest +@testable import T3Code + +@MainActor +final class NativeContractExpansionTests: XCTestCase { + func testAdministrativeClientSessionContractsAndRequests() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential( + accessToken: "bearer", + scopes: ["access:read", "access:write"] + ), + ]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let sessions = try await api.clientSessions(for: environment) + let revoked = try await api.revokeClientSession( + id: "session-2", + environment: environment + ) + let others = try await api.revokeOtherClientSessions(for: environment) + + XCTAssertEqual(sessions.first?.client.label, "Big O") + XCTAssertEqual(sessions.first?.client.deviceType, "mobile") + XCTAssertFalse(sessions.first?.current ?? true) + XCTAssertTrue(revoked.revoked) + XCTAssertEqual(others.revokedCount, 2) + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/api/auth/clients", + "/api/auth/clients/revoke", + "/api/auth/clients/revoke-others", + ]) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "Bearer bearer" + }) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Accept-Encoding") == "gzip" + }) + let revokeBody = try JSONDecoder.t3.decode( + [String: String].self, + from: try XCTUnwrap(requests[1].httpBody) + ) + XCTAssertEqual(revokeBody, ["sessionId": "session-2"]) + } + + func testImageAttachmentBuildsExactTurnUploadShape() throws { + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + let command = try OrchestrationCommands.sendTurn( + threadID: "thread-1", + text: "What is in this image?", + runtimeMode: .fullAccess, + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + attachments: [image], + commandID: "command-1", + messageID: "message-1", + createdAt: "2026-07-30T12:00:00.000Z" + ) + + guard case let .array(attachments)? = command["message"]?["attachments"] else { + return XCTFail("Expected an attachment array") + } + let attachment = try XCTUnwrap(attachments.first) + XCTAssertEqual(attachment["type"]?.stringValue, "image") + XCTAssertEqual(attachment["name"]?.stringValue, "screenshot.png") + XCTAssertEqual(attachment["mimeType"]?.stringValue, "image/png") + guard case let .number(sizeBytes)? = attachment["sizeBytes"] else { + return XCTFail("Expected numeric attachment size") + } + XCTAssertEqual(sizeBytes, 4) + XCTAssertEqual( + attachment["dataUrl"]?.stringValue, + "data:image/png;base64,iVBORw==" + ) + XCTAssertEqual(command["modelSelection"]?["instanceId"]?.stringValue, "codex") + } + + func testImageAttachmentRejectsOversizedInput() { + XCTAssertThrowsError( + try UploadChatImageAttachment( + data: Data(count: UploadChatImageAttachment.maximumBytes + 1), + name: "huge.png", + mimeType: "image/png" + ) + ) { error in + guard case ImageAttachmentError.tooLarge = error else { + return XCTFail("Expected size validation, got \(error)") + } + } + } + + func testUploadedImageAttachmentUsesPersistedReferenceInsteadOfInlineBytes() throws { + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + let command = try OrchestrationCommands.sendTurn( + threadID: "thread-1", + text: "Review the screenshot", + runtimeMode: .fullAccess, + attachments: [image], + uploadedAttachments: [image.uploadedJSONValue(id: "attachment-1")] + ) + + guard case let .array(attachments)? = command["message"]?["attachments"], + let attachment = attachments.first else { + return XCTFail("Expected an uploaded attachment reference") + } + XCTAssertEqual(attachment["id"]?.stringValue, "attachment-1") + XCTAssertEqual(attachment["mimeType"]?.stringValue, "image/png") + XCTAssertNil(attachment["dataUrl"]) + } + + func testSignedAttachmentUploadPostsImageBytesWithoutCredentials() async throws { + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: InMemoryCredentialStore()) + let data = Data([0x89, 0x50, 0x4e, 0x47]) + + try await api.uploadAttachment( + data, + mimeType: "image/png", + to: URL(string: "https://studio.example/api/attachments/upload/signed-token")! + ) + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.httpBody, data) + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "image/png") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Length"), "4") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + } + + func testEnvironmentDescriptorDecodesAttachmentUploadCapability() throws { + let descriptor = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": { + "repositoryIdentity": true, + "attachmentUploads": true, + "fileAttachments": {"maxUploadBytes": 123456} + } + } + """.utf8 + ) + ) + + XCTAssertEqual(descriptor.capabilities.attachmentUploads, true) + XCTAssertEqual(descriptor.capabilities.fileAttachments?.maxUploadBytes, 123_456) + XCTAssertEqual(RPCMethod.attachmentsCreateUploadURL.rawValue, "attachments.createUploadUrl") + XCTAssertEqual(RPCMethod.attachmentsDelete.rawValue, "attachments.delete") + } + + func testCodexFeedbackContractMatchesTheServerRPC() throws { + let result = try JSONDecoder.t3.decode( + ProviderUploadFeedbackResult.self, + from: Data(#"{"feedbackId":"codex-thread-1"}"#.utf8) + ) + + XCTAssertEqual(result.feedbackId, "codex-thread-1") + XCTAssertEqual(RPCMethod.providerUploadFeedback.rawValue, "provider.uploadFeedback") + } + + func testAssetContractUsesExactTagsAndResultFields() throws { + XCTAssertEqual(RPCMethod.assetsCreateURL.rawValue, "assets.createUrl") + let legacyAttachment = AssetResource.attachment(id: "attachment-1").jsonValue + XCTAssertEqual( + legacyAttachment["_tag"]?.stringValue, + "attachment" + ) + XCTAssertNil(legacyAttachment["fileName"]) + XCTAssertNil(legacyAttachment["mimeType"]) + XCTAssertEqual( + AssetResource.attachment( + id: "attachment-2", + fileName: "report.pdf", + mimeType: "application/pdf" + ).jsonValue, + .object([ + "_tag": .string("attachment"), + "attachmentId": .string("attachment-2"), + "fileName": .string("report.pdf"), + "mimeType": .string("application/pdf"), + ]) + ) + XCTAssertEqual( + AssetResource.workspaceFile( + threadID: "thread-1", + path: "screenshots/app.png" + ).jsonValue["threadId"]?.stringValue, + "thread-1" + ) + XCTAssertEqual( + AssetResource.mediaFile( + threadID: "thread-2", + path: "uploads/report.pdf" + ).jsonValue, + .object([ + "_tag": .string("media-file"), + "threadId": .string("thread-2"), + "path": .string("uploads/report.pdf"), + ]) + ) + let result = try JSONDecoder.t3.decode( + AssetCreateURLResult.self, + from: Data( + """ + { + "relativeUrl": "/api/assets/signed/image.png", + "expiresAt": 1785466800000 + } + """.utf8 + ) + ) + XCTAssertEqual(result.relativeUrl, "/api/assets/signed/image.png") + XCTAssertEqual(result.expiresAt, 1_785_466_800_000) + XCTAssertEqual( + RPCMethod.reviewDiffFileContents.rawValue, + "review.getDiffFileContents" + ) + let contents = try JSONDecoder.t3.decode( + ReviewDiffFileContents.self, + from: Data(#"{"oldContents":"before\n","newContents":"after\n"}"#.utf8) + ) + XCTAssertEqual(contents.oldContents, "before\n") + XCTAssertEqual(contents.newContents, "after\n") + } + + func testServerConfigDecodesFullModelPickerCatalogue() throws { + let config = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + """ + { + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + }, + "providers": [{ + "instanceId": "codex-work", + "driver": "codex", + "displayName": "Codex", + "accentColor": "#10a37f", + "badgeLabel": "OpenAI", + "showInteractionModeToggle": true, + "requiresNewThreadForModelChange": false, + "enabled": true, + "installed": true, + "version": "1.2.3", + "status": "ready", + "auth": { + "status": "authenticated", + "type": "chatgpt", + "label": "ChatGPT", + "email": "theo@example.com" + }, + "checkedAt": "2026-07-30T12:00:00.000Z", + "availability": "available", + "models": [{ + "slug": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "shortName": "Sol", + "isCustom": false, + "isDefault": true, + "capabilities": { + "optionDescriptors": [{ + "id": "effort", + "type": "select", + "label": "Reasoning", + "description": "How hard the model thinks.", + "options": [{ + "id": "high", + "label": "High", + "isDefault": true + }], + "currentValue": "high" + }, { + "id": "fastMode", + "type": "boolean", + "label": "Fast mode", + "currentValue": true + }] + } + }, { + "slug": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "shortName": "Terra", + "isCustom": false, + "isDefault": false, + "isLegacy": true, + "capabilities": null + }], + "slashCommands": [{ + "name": "review", + "description": "Review the current changes", + "input": { "hint": "focus" } + }], + "skills": [{ + "name": "gh-fix-ci", + "description": "Fix CI failures", + "path": "/skills/gh-fix-ci/SKILL.md", + "scope": "user", + "enabled": true, + "displayName": "Fix CI", + "shortDescription": "Debug GitHub Actions" + }] + }] + } + """.utf8 + ) + ) + + let provider = try XCTUnwrap(config.providers.first) + XCTAssertEqual(provider.instanceId, "codex-work") + XCTAssertEqual(provider.auth.status, "authenticated") + XCTAssertEqual(provider.models.map(\.slug), ["gpt-5.6-sol", "gpt-5.6-terra"]) + XCTAssertEqual(provider.slashCommands?.first?.name, "review") + XCTAssertEqual(provider.slashCommands?.first?.input?.hint, "focus") + XCTAssertEqual(provider.skills?.first?.displayName, "Fix CI") + XCTAssertEqual(config.settings?.defaultThreadEnvMode, .worktree) + XCTAssertEqual(config.settings?.newWorktreesStartFromOrigin, false) + let model = try XCTUnwrap(provider.models.first) + XCTAssertEqual(model.slug, "gpt-5.6-sol") + XCTAssertNil(model.isLegacy) + XCTAssertEqual(provider.models[1].isLegacy, true) + let descriptors = try XCTUnwrap(model.capabilities?.optionDescriptors) + guard case let .select(effort) = descriptors[0], + case let .boolean(fastMode) = descriptors[1] + else { + return XCTFail("Expected typed select and boolean descriptors") + } + XCTAssertEqual(effort.options.first?.label, "High") + XCTAssertEqual(effort.currentValue, "high") + XCTAssertEqual(fastMode.currentValue, true) + } + + func testServerConfigSettingsUpdateDecodesEnvironmentPreferences() throws { + let event = try JSONDecoder.t3.decode( + ServerConfigStreamEvent.self, + from: Data( + """ + { + "version": 1, + "type": "settingsUpdated", + "payload": { + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + } + } + } + """.utf8 + ) + ) + + guard case let .settingsUpdated(settings) = event else { + return XCTFail("Expected a settings update") + } + XCTAssertEqual(settings.defaultThreadEnvMode, .worktree) + XCTAssertFalse(settings.newWorktreesStartFromOrigin) + } + + func testProviderArraysDropOnlyUnknownProviderEntries() throws { + let providers = """ + [{ + "instanceId": "future-provider", + "driver": "future", + "enabled": true, + "installed": true, + "status": "ready", + "auth": { "status": "authenticated" }, + "checkedAt": "2026-08-04T12:00:00.000Z", + "models": [{ + "slug": "future-model", + "name": "Future", + "isCustom": false, + "capabilities": { + "optionDescriptors": [{ "type": "future-option" }] + } + }] + }, { + "instanceId": "codex", + "driver": "codex", + "enabled": true, + "installed": true, + "status": "ready", + "auth": { "status": "authenticated" }, + "checkedAt": "2026-08-04T12:00:00.000Z", + "models": [{ + "slug": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "isCustom": false, + "isLegacy": false + }] + }] + """ + let snapshot = try JSONDecoder.t3.decode( + ServerConfigSnapshot.self, + from: Data( + """ + { + "providers": \(providers), + "settings": { + "defaultThreadEnvMode": "worktree", + "newWorktreesStartFromOrigin": false + } + } + """.utf8 + ) + ) + + XCTAssertEqual(snapshot.providers.map(\.instanceId), ["codex"]) + XCTAssertEqual(snapshot.settings?.defaultThreadEnvMode, .worktree) + + let event = try JSONDecoder.t3.decode( + ServerConfigStreamEvent.self, + from: Data( + """ + { + "type": "providerStatuses", + "payload": { "providers": \(providers) } + } + """.utf8 + ) + ) + guard case let .providerStatuses(decodedProviders) = event else { + return XCTFail("Expected provider statuses") + } + XCTAssertEqual(decodedProviders.map(\.instanceId), ["codex"]) + } +} + +private actor AccessHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/api/auth/clients": + body = """ + [{ + "sessionId": "session-2", + "subject": "paired-client", + "scopes": ["orchestration:read"], + "method": "bearer-access-token", + "client": { + "label": "Big O", + "ipAddress": "192.168.1.10", + "deviceType": "mobile", + "os": "iOS" + }, + "issuedAt": "2026-07-30T12:00:00.000Z", + "expiresAt": "2026-08-30T12:00:00.000Z", + "lastConnectedAt": "2026-07-30T12:05:00.000Z", + "connected": true, + "current": false + }] + """ + case "/api/auth/clients/revoke": + body = #"{"revoked":true}"# + case "/api/auth/clients/revoke-others": + body = #"{"revokedCount":2}"# + default: + body = "{}" + } + return ( + Data(body.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift new file mode 100644 index 000000000000..ae831374bb4c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PairingServiceTests.swift @@ -0,0 +1,270 @@ +import XCTest +@testable import T3Code + +@MainActor +final class PairingServiceTests: XCTestCase { + func testPairingExchangesTokenAndPersistsSecretSeparately() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transport = PairingHTTPTransport() + let credentials = InMemoryCredentialStore() + let environments = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + let service = PairingService( + transport: transport, + environmentStore: environments, + credentialStore: credentials + ) + + let environment = try await service.pair( + url: "https://studio.example/#token=pair-once", + label: "Theo's iPhone" + ) + + XCTAssertEqual(environment.id, "environment-1") + let storedEnvironments = try await environments.load() + let activeID = try await environments.activeEnvironmentID() + let credential = await credentials.credential(for: "environment-1") + XCTAssertEqual(storedEnvironments.map(\.id), ["environment-1"]) + XCTAssertEqual(activeID, "environment-1") + XCTAssertEqual(credential?.accessToken, "access-token") + + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.url?.path }, [ + "/.well-known/t3/environment", + "/oauth/token", + ]) + let form = String(data: requests[1].httpBody!, encoding: .utf8)! + XCTAssertTrue(form.contains("subject_token=pair-once")) + XCTAssertTrue(form.contains("client_device_type=mobile")) + XCTAssertTrue(form.contains("client_surface=mobile")) + XCTAssertTrue(form.contains("client_app_version=")) + // Omitting scope accepts the exact grant carried by the one-time link. + // Requesting administrative scopes consumes ordinary links and then + // fails with scope_not_granted. + XCTAssertFalse(form.contains("scope=")) + } + + func testFailedRepairRestoresTheExistingCredential() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-rollback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let credentials = InMemoryCredentialStore(credentials: [ + "environment-1": EnvironmentCredential(accessToken: "previous-access-token"), + ]) + let environments = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + let service = PairingService( + transport: PairingHTTPTransport(), + environmentStore: environments, + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await service.pair( + url: "https://studio.example/#token=pair-once", + label: "Theo's iPhone" + ) + XCTFail("Pairing unexpectedly updated a read-only environment catalog") + } catch { + let restored = await credentials.credential(for: "environment-1") + XCTAssertEqual(restored?.accessToken, "previous-access-token") + } + } + + func testFailedRepairDoesNotOverwriteNewerCredential() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: EnvironmentCredential(accessToken: "previous-access-token"), + newerCredential: EnvironmentCredential(accessToken: "newer-access-token") + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + func testFailedFirstPairingDoesNotDeleteNewerCredential() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: nil, + newerCredential: EnvironmentCredential(accessToken: "newer-access-token") + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + func testFailedRepairRestoresCredentialRefreshedBeforeInstallation() async throws { + let credentials = InterleavedCredentialStore( + previousCredential: EnvironmentCredential(accessToken: "previous-access-token"), + newerCredential: EnvironmentCredential(accessToken: "newer-access-token"), + replacementTiming: .beforeInstallation + ) + + try await assertFailedPairingPreservesNewerCredential(credentials) + } + + private func assertFailedPairingPreservesNewerCredential( + _ credentials: InterleavedCredentialStore + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swift-pairing-race-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let service = PairingService( + transport: PairingHTTPTransport(), + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await service.pair(url: "https://studio.example/#token=pair-once") + XCTFail("Pairing unexpectedly updated a read-only environment catalog") + } catch { + let stored = await credentials.credential(for: "environment-1") + XCTAssertEqual(stored?.accessToken, "newer-access-token") + } + } +} + +private actor InterleavedCredentialStore: CredentialStore { + enum ReplacementTiming { + case beforeInstallation + case afterInstallation + } + + private var storedCredential: EnvironmentCredential? + private let newerCredential: EnvironmentCredential + private let replacementTiming: ReplacementTiming + private var hasInsertedNewerCredential = false + + init( + previousCredential: EnvironmentCredential?, + newerCredential: EnvironmentCredential, + replacementTiming: ReplacementTiming = .afterInstallation + ) { + storedCredential = previousCredential + self.newerCredential = newerCredential + self.replacementTiming = replacementTiming + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + let currentCredential = storedCredential + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + return currentCredential + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + storedCredential = credential + if replacementTiming == .afterInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + let previousCredential = storedCredential + setCredential(credential, for: environmentID) + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) { + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} + +private actor PairingHTTPTransport: HTTPTransport { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url?.path { + case "/.well-known/t3/environment": + body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + case "/oauth/token": + body = """ + { + "access_token": "access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "orchestration:read orchestration:operate" + } + """ + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "")") + body = "{}" + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (Data(body.utf8), response) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift b/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift new file mode 100644 index 000000000000..e9a63c07745b --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/PullRequestContractTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import T3Code + +final class PullRequestContractTests: XCTestCase { + func testThreadLinkedPullRequestDecodesCurrentWireShape() throws { + let linked = try JSONDecoder.t3.decode( + ThreadLinkedPullRequest.self, + from: Data( + #"{"projectId":"project-1","repository":"pingdotgg/t3code","number":5178,"url":"https://github.com/pingdotgg/t3code/pull/5178"}"#.utf8 + ) + ) + + XCTAssertEqual(linked.projectId, "project-1") + XCTAssertEqual(linked.repository, "pingdotgg/t3code") + XCTAssertEqual(linked.number, 5178) + } + + func testListResultDecodesCurrentWireShape() throws { + let data = Data( + #""" + { + "viewers":{"github.com":"theo"}, + "providers":[{ + "host":"github.com","kind":"github","searchesOnHost":true, + "projectCount":1,"configured":true,"detail":null + }], + "entries":[{ + "provider":"github","host":"github.com","projectId":"project-1", + "projectTitle":"T3 Code","repository":"pingdotgg/t3code","number":5178, + "title":"Native SwiftUI app","url":"https://github.com/pingdotgg/t3code/pull/5178", + "author":{"login":"theo","name":"Theo","avatarUrl":null}, + "headBranch":"native","baseBranch":"main","state":"open","isDraft":false, + "mergeability":"mergeable","additions":20,"deletions":4, + "createdAt":"2026-08-18T12:00:00.000Z","updatedAt":"2026-08-18T13:00:00.000Z", + "viewerReviewRequested":false,"labels":[],"reviewDecision":"approved", + "checksState":"passing" + }], + "errors":[],"truncated":false,"nextCursors":{} + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(PullRequestListResult.self, from: data) + + XCTAssertEqual(result.entries.first?.number, 5178) + XCTAssertEqual(result.entries.first?.reviewDecision, .approved) + XCTAssertEqual(result.providers.first?.kind, .github) + } + + func testReferenceEncodesExactRpcPayload() throws { + let reference = PullRequestRef( + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 5178 + ) + + XCTAssertEqual( + try JSONValue.encode(reference), + .object([ + "projectId": .string("project-1"), + "repository": .string("pingdotgg/t3code"), + "number": .number(5178), + ]) + ) + } + + func testListPagesPreserveRowsAndAdvanceCursors() { + let first = PullRequestListResult( + viewers: ["github.com": "theo"], + providers: [], + entries: [], + errors: [], + truncated: true, + nextCursors: ["github.com t3/repo": "first"] + ) + let second = PullRequestListResult( + viewers: ["gitlab.com": "maintainer"], + providers: [], + entries: [], + errors: [], + truncated: false, + nextCursors: [:] + ) + + let combined = first.appending(second) + + XCTAssertEqual(combined.viewers["github.com"], "theo") + XCTAssertEqual(combined.viewers["gitlab.com"], "maintainer") + XCTAssertFalse(combined.truncated) + XCTAssertTrue(combined.nextCursors.isEmpty) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift b/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift new file mode 100644 index 000000000000..b4d71dccc544 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/SourceControlDiscoveryTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Source control discovery contracts") +struct SourceControlDiscoveryTests { + @Test + func decodesEffectOptionsFromTheServerContract() throws { + let data = Data( + #""" + { + "versionControlSystems": [ + { + "kind": "git", + "label": "Git", + "executable": "git", + "implemented": true, + "status": "available", + "version": {"_id":"Option","_tag":"Some","value":"git 2.50"}, + "installHint": "Install Git", + "detail": {"_id":"Option","_tag":"None"} + } + ], + "sourceControlProviders": [ + { + "kind": "github", + "label": "GitHub", + "executable": "gh", + "status": "available", + "version": {"_id":"Option","_tag":"Some","value":"2.76"}, + "installHint": "Install gh", + "detail": {"_id":"Option","_tag":"None"}, + "auth": { + "status": "authenticated", + "account": {"_id":"Option","_tag":"Some","value":"octocat"}, + "host": {"_id":"Option","_tag":"Some","value":"github.com"}, + "detail": {"_id":"Option","_tag":"None"} + } + } + ] + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(SourceControlDiscoveryResult.self, from: data) + + #expect(result.versionControlSystems.first?.version == "git 2.50") + #expect(result.versionControlSystems.first?.detail == nil) + #expect(result.sourceControlProviders.first?.kind == .github) + #expect(result.sourceControlProviders.first?.auth.account == "octocat") + #expect(result.sourceControlProviders.first?.auth.host == "github.com") + #expect(result.sourceControlProviders.first?.auth.detail == nil) + #expect(RPCMethod.serverDiscoverSourceControl.rawValue == "server.discoverSourceControl") + } + + @Test + func toleratesMissingAndPlainOptionalStrings() throws { + let data = Data( + #""" + { + "versionControlSystems": [], + "sourceControlProviders": [ + { + "kind": "gitlab", + "label": "GitLab", + "status": "missing", + "version": null, + "installHint": "Install glab", + "detail": "Not installed", + "auth": { + "status": "unknown", + "account": null, + "host": null, + "detail": "Not installed" + } + } + ] + } + """#.utf8 + ) + + let result = try JSONDecoder.t3.decode(SourceControlDiscoveryResult.self, from: data) + let provider = try #require(result.sourceControlProviders.first) + + #expect(provider.status == .missing) + #expect(provider.version == nil) + #expect(provider.detail == "Not installed") + #expect(provider.auth.account == nil) + #expect(provider.auth.detail == "Not installed") + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift new file mode 100644 index 000000000000..01c5505378b0 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ClientServerConfigTests.swift @@ -0,0 +1,284 @@ +import XCTest +@testable import T3Code + +@MainActor +final class T3ClientServerConfigTests: XCTestCase { + func testBootstrapAndListenerShareSubscriptionThenReplayFoldedCatalog() async throws { + let connection = ServerConfigTestConnection(mode: .snapshot) + let client = makeClient(connection: connection) + let events = await client.serverConfigEvents() + async let bootstrap = client.serverConfig() + var iterator = events.makeAsyncIterator() + + guard case let .snapshot(first)? = try await iterator.next() else { + return XCTFail("Expected the subscription snapshot.") + } + XCTAssertEqual(first.threadSnapshotPagination, true) + let bootstrapped = try await bootstrap + XCTAssertEqual(bootstrapped.providers.first?.instanceId, "codex-old") + let initialTags = await connection.tags() + XCTAssertEqual(initialTags, ["subscribeServerConfig"]) + + try await connection.pushProviderStatus(id: "codex-new") + guard case .providerStatuses? = try await iterator.next() else { + return XCTFail("Expected the provider status delta.") + } + let folded = try await client.serverConfig() + XCTAssertEqual(folded.providers.first?.instanceId, "codex-new") + XCTAssertEqual(folded.settings?.newWorktreesStartFromOrigin, false) + XCTAssertEqual(folded.threadSnapshotPagination, true) + XCTAssertEqual(folded.environment?.environmentId, "environment-1") + + let replay = await client.serverConfigEvents() + var replayIterator = replay.makeAsyncIterator() + guard case let .snapshot(replayed)? = try await replayIterator.next() else { + return XCTFail("Expected a cached snapshot replay.") + } + XCTAssertEqual(replayed, folded) + let replayTags = await connection.tags() + XCTAssertEqual(replayTags, ["subscribeServerConfig"]) + await client.disconnect() + } + + func testDisconnectCancelsPendingBootstrapAndRejectsStaleStreamCallbacks() async throws { + let connection = ServerConfigTestConnection(mode: .silent) + let client = makeClient(connection: connection) + let pending = Task { try await client.serverConfig() } + await connection.waitForRequestCount(1) + await client.disconnect() + + do { + _ = try await pending.value + XCTFail("Disconnect must finish the pending bootstrap.") + } catch let error as RPCError { + guard case .disconnected = error else { + return XCTFail("Unexpected error: \(error)") + } + } + try await connection.pushSnapshot(id: "stale") + let tags = await connection.tags() + XCTAssertEqual(tags, ["subscribeServerConfig"]) + } + + func testSilentSubscriptionBootstrapTimesOutAndCancellationDoesNotLeaveAWaiter() async { + let connection = ServerConfigTestConnection(mode: .silent) + let client = makeClient(connection: connection, waitTimeout: .milliseconds(20)) + let cancelled = Task { try await client.serverConfig() } + await connection.waitForRequestCount(1) + cancelled.cancel() + do { + _ = try await cancelled.value + XCTFail("Cancellation must finish the bootstrap wait.") + } catch is CancellationError { + } catch { + XCTFail("Unexpected cancellation error: \(error)") + } + + do { + _ = try await client.serverConfig() + XCTFail("A silent config subscription must have a bounded wait.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.disconnect() + return XCTFail("Unexpected error: \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + await client.disconnect() + } + + func testOnlyExplicitUnsupportedMethodFallsBackToUnaryConfig() async throws { + let unsupported = ServerConfigTestConnection( + mode: .failure("Unsupported method subscribeServerConfig") + ) + let legacyClient = makeClient(connection: unsupported) + let legacyConfig = try await legacyClient.serverConfig() + XCTAssertEqual(legacyConfig.providers.first?.instanceId, "codex-old") + let unsupportedTags = await unsupported.tags() + XCTAssertEqual(unsupportedTags, ["subscribeServerConfig", "server.getConfig"]) + await legacyClient.disconnect() + + let auth = ServerConfigTestConnection( + mode: .failure("Unsupported authentication scheme for subscribeServerConfig") + ) + let authClient = makeClient(connection: auth) + do { + _ = try await authClient.serverConfig() + XCTFail("Authentication errors must not use the legacy config fallback.") + } catch let error as RPCError { + guard case .remote = error else { return XCTFail("Unexpected error: \(error)") } + } + let authTags = await auth.tags() + XCTAssertEqual(authTags, ["subscribeServerConfig"]) + await authClient.disconnect() + } + + private func makeClient( + connection: ServerConfigTestConnection, + waitTimeout: Duration = .seconds(4) + ) -> T3Client { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + return T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ]), + httpTransport: ServerConfigTicketTransport(), + webSocketConnector: ServerConfigTestConnector(connection: connection), + rpcConnectionWaitTimeout: waitTimeout + ) + } +} + +private struct ServerConfigTicketTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let data = Data(#"{"ticket":"ticket","expiresAt":"2026-09-01T12:05:00.000Z"}"#.utf8) + return (data, HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!) + } +} + +private struct ServerConfigTestConnector: WebSocketConnecting { + let connection: ServerConfigTestConnection + func connect(to _: URL) async throws -> any WebSocketConnection { connection } +} + +private actor ServerConfigTestConnection: WebSocketConnection { + enum Mode { case snapshot, silent, failure(String) } + + private let mode: Mode + private var requestTags: [String] = [] + private var subscriptionRequestID: Int? + private var responses: [Data] = [] + private var receiver: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + + init(mode: Mode) { self.mode = mode } + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard let tag = request["tag"]?.stringValue, + case let .number(rawID) = request["id"] else { return } + let id = Int(rawID) + requestTags.append(tag) + requestWaiters.forEach { $0.resume() } + requestWaiters.removeAll() + switch tag { + case "subscribeServerConfig": + subscriptionRequestID = id + switch mode { + case .snapshot: try enqueue(chunk(id: id, value: snapshot(id: "codex-old"))) + case .silent: break + case let .failure(message): try enqueue(failure(id: id, message: message)) + } + case "server.getConfig": + try enqueue(success(id: id, value: config(id: "codex-old"))) + default: break + } + } + + func receive() async throws -> Data { + if !responses.isEmpty { return responses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func tags() -> [String] { requestTags } + + func waitForRequestCount(_ count: Int) async { + if requestTags.count >= count { return } + await withCheckedContinuation { requestWaiters.append($0) } + } + + func pushProviderStatus(id: String) throws { + guard let subscriptionRequestID else { return } + try enqueue(chunk(id: subscriptionRequestID, value: .object([ + "type": .string("providerStatuses"), + "payload": .object(["providers": .array([provider(id: id)])]), + ]))) + } + + func pushSnapshot(id: String) throws { + guard let subscriptionRequestID else { return } + try enqueue(chunk(id: subscriptionRequestID, value: snapshot(id: id))) + } + + private func snapshot(id: String) -> JSONValue { + .object(["type": .string("snapshot"), "config": config(id: id)]) + } + + private func config(id: String) -> JSONValue { + .object([ + "providers": .array([provider(id: id)]), + "settings": .object([ + "defaultThreadEnvMode": .string("worktree"), + "newWorktreesStartFromOrigin": .bool(false), + ]), + "threadSnapshotPagination": .bool(true), + "environment": .object([ + "environmentId": .string("environment-1"), + "label": .string("Studio"), + "platform": .object(["os": .string("darwin"), "arch": .string("arm64")]), + "serverVersion": .string("1.0.0"), + "capabilities": .object([:]), + ]), + ]) + } + + private func provider(id: String) -> JSONValue { + .object([ + "instanceId": .string(id), "driver": .string("codex"), + "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), + "auth": .object(["status": .string("authenticated")]), + "checkedAt": .string("2026-09-01T12:00:00.000Z"), "models": .array([]), + ]) + } + + private func chunk(id: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Chunk"), "requestId": .number(Double(id)), "values": .array([value]), + ])) + } + + private func success(id: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object(["_tag": .string("Success"), "value": value]), + ])) + } + + private func failure(id: Int, message: String) throws -> Data { + try JSONEncoder.t3.encode(JSONValue.object([ + "_tag": .string("Exit"), "requestId": .number(Double(id)), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), "error": .object(["message": .string(message)]), + ])]), + ]), + ])) + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + responses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift new file mode 100644 index 000000000000..7d5547ef196c --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectDPoPTests.swift @@ -0,0 +1,237 @@ +import CryptoKit +import XCTest +@testable import T3Code + +final class T3ConnectDPoPTests: XCTestCase { + func testCanonicalJWKThumbprintAndSignedProofMatchRFCShape() async throws { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 1 + let signer = try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + let jwk = try await signer.publicJWK() + + XCTAssertEqual( + jwk.canonicalThumbprintInput, + #"{"crv":"P-256","kty":"EC","x":"axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpY","y":"T-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU"}"# + ) + XCTAssertEqual(jwk.thumbprint, "xx0BcA-wMohw8atYDJOe6peGModklG2wRHBlXHMvl0M") + + let token = "access-token" + let proof = try await signer.proof( + method: "post", + url: URL(string: "https://relay.example/v1/connect?ignored=yes#fragment")!, + accessToken: token, + issuedAt: Date(timeIntervalSince1970: 1_800_000_000), + identifier: UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")! + ) + let components = proof.value.split(separator: ".").map(String.init) + XCTAssertEqual(components.count, 3) + + let header = try jsonObject(components[0]) + XCTAssertEqual(header["typ"] as? String, "dpop+jwt") + XCTAssertEqual(header["alg"] as? String, "ES256") + XCTAssertNil((header["jwk"] as? [String: Any])?["d"]) + + let payload = try jsonObject(components[1]) + XCTAssertEqual(payload["htm"] as? String, "POST") + XCTAssertEqual(payload["htu"] as? String, "https://relay.example/v1/connect") + XCTAssertEqual(payload["iat"] as? Int, 1_800_000_000) + XCTAssertEqual( + payload["ath"] as? String, + T3ConnectDPoPSigner.accessTokenHash(token) + ) + + let x = try decodeBase64URL(jwk.x) + let y = try decodeBase64URL(jwk.y) + let publicKey = try P256.Signing.PublicKey( + x963Representation: Data([0x04]) + x + y + ) + let signature = try P256.Signing.ECDSASignature( + rawRepresentation: decodeBase64URL(components[2]) + ) + XCTAssertTrue( + publicKey.isValidSignature( + signature, + for: Data("\(components[0]).\(components[1])".utf8) + ) + ) + } + + func testHTUNormalizationDropsDefaultPortQueryAndFragment() { + XCTAssertEqual( + T3ConnectDPoPSigner.normalizedHTU( + URL(string: "https://relay.example:443/path?query=one#fragment")! + )?.absoluteString, + "https://relay.example/path" + ) + XCTAssertEqual( + T3ConnectDPoPSigner.normalizedHTU( + URL(string: "https://relay.example:8443/path")! + )?.absoluteString, + "https://relay.example:8443/path" + ) + } + + func testCloudConfigurationRequiresAllPublicEndpoints() { + XCTAssertEqual( + T3ConnectConfiguration.resolve(infoDictionary: [:]), + .unavailable( + reason: "This build is missing Clerk publishable key, relay HTTP URL." + ) + ) + + let resolution = T3ConnectConfiguration.resolve(infoDictionary: [ + "T3ConnectClerkPublishableKey": "pk_test_example", + "T3ConnectClerkJWTTemplate": "relay-template", + "T3ConnectRelayHTTPURL": "https://relay.example/", + ]) + guard case let .available(configuration) = resolution else { + return XCTFail("Expected available T3 Connect configuration") + } + XCTAssertEqual(configuration.clerkJWTTemplate, "relay-template") + XCTAssertEqual(configuration.relayHTTPURL.absoluteString, "https://relay.example") + } + + func testBearerRejectionDoesNotGetDPoPClockAdvice() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "bearer-token"), + ]) + let api = EnvironmentAPI( + transport: DPoPErrorHTTPTransport( + body: #"{"code":"auth_invalid","reason":"invalid_credential","dpopFailureReason":"time_window","message":"The bearer token expired.","traceId":"trace-bearer"}"# + ), + credentials: credentials + ) + + do { + _ = try await api.session(for: environment) + XCTFail("A rejected bearer credential was accepted") + } catch let error as HTTPError { + XCTAssertEqual( + error.errorDescription, + "The bearer token expired. (trace trace-bearer)" + ) + XCTAssertFalse(error.errorDescription?.contains("clock") == true) + } + } + + func testManagedEnvironmentConfirmedClockFailureUsesClockAdvice() async throws { + let environment = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential.managedDPoP( + accessToken: "managed-token", + expiresAt: Date().addingTimeInterval(300), + scopes: ["orchestration:read"], + environmentID: environment.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let transport = DPoPErrorHTTPTransport( + body: #"{"code":"auth_invalid","reason":"invalid_credential","dpopFailureReason":"time_window","traceId":"trace-clock"}"# + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: StaticDPoPAuthorizer() + ) + + do { + _ = try await api.session(for: environment) + XCTFail("A rejected managed credential was accepted") + } catch let error as HTTPError { + XCTAssertEqual( + error.errorDescription, + "The environment credential is invalid. \(DPoPFailurePresentation.clockHint) (trace trace-clock)" + ) + } + + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "DPoP") == "proof" + }) + } + + private func jsonObject(_ encoded: String) throws -> [String: Any] { + let data = try decodeBase64URL(encoded) + return try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + private func decodeBase64URL(_ value: String) throws -> Data { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + return try XCTUnwrap(Data(base64Encoded: base64)) + } +} + +private actor DPoPErrorHTTPTransport: HTTPTransport { + let body: String + private(set) var requests: [URLRequest] = [] + + init(body: String) { + self.body = body + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + return ( + Data(body.utf8), + HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} + +private struct StaticDPoPAuthorizer: ManagedEnvironmentAuthorizing { + func credentialRequiresRefresh( + _: EnvironmentCredential, + environment _: Environment + ) async throws -> Bool { + false + } + + func authorize( + _ request: URLRequest, + environment _: Environment, + credential: EnvironmentCredential + ) async throws -> URLRequest { + var authorized = request + authorized.setValue( + "DPoP \(credential.accessToken)", + forHTTPHeaderField: "Authorization" + ) + authorized.setValue("proof", forHTTPHeaderField: "DPoP") + return authorized + } + + func refreshCredential( + for environment: Environment, + replacing _: EnvironmentCredential + ) async throws -> EnvironmentCredential { + .managedDPoP( + accessToken: "refreshed-token", + expiresAt: Date().addingTimeInterval(300), + scopes: ["orchestration:read"], + environmentID: environment.id, + proofKeyThumbprint: "proof-key" + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift new file mode 100644 index 000000000000..463e0744f250 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectRelayDecodingTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import T3Code + +final class T3ConnectRelayDecodingTests: XCTestCase { + func testRelayEnvironmentAndStatusDecodeCurrentContract() throws { + let environment = try JSONDecoder.t3.decode( + T3ConnectRelayEnvironment.self, + from: Data( + #"{"environmentId":"env-1","label":"Studio","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"cloudflare_tunnel"},"linkedAt":"2026-08-01T12:00:00.000Z"}"#.utf8 + ) + ) + XCTAssertEqual(environment.id, "env-1") + XCTAssertEqual(environment.endpoint.providerKind, .cloudflareTunnel) + + let status = try JSONDecoder.t3.decode( + T3ConnectRelayEnvironmentStatus.self, + from: Data( + #"{"environmentId":"env-1","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"cloudflare_tunnel"},"status":"offline","checkedAt":"2026-08-01T12:01:00.000Z","error":"connector unavailable","traceId":"trace-1"}"#.utf8 + ) + ) + XCTAssertEqual(status.status, .offline) + XCTAssertEqual(status.error, "connector unavailable") + XCTAssertEqual(status.traceId, "trace-1") + } + + func testRelayTokensAndLinkResponsesDecodeSnakeCaseAndOptionalRuntime() throws { + let token = try JSONDecoder.t3.decode( + T3ConnectRelayAccessToken.self, + from: Data( + #"{"access_token":"relay-token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":300,"scope":"environment:status environment:connect"}"#.utf8 + ) + ) + XCTAssertEqual(token.accessToken, "relay-token") + XCTAssertEqual(token.expiresIn, 300) + + let link = try JSONDecoder.t3.decode( + T3ConnectEnvironmentLinkResponse.self, + from: Data( + #"{"ok":true,"cloudUserId":"user-1","environmentId":"env-1","endpoint":{"httpBaseUrl":"https://studio.example","wsBaseUrl":"wss://studio.example","providerKind":"t3_relay"},"endpointRuntime":null,"relayIssuer":"https://relay.example","environmentCredential":"credential","cloudMintPublicKey":"public-key"}"#.utf8 + ) + ) + XCTAssertTrue(link.ok) + XCTAssertNil(link.endpointRuntime) + XCTAssertEqual(link.endpoint.providerKind, .t3Relay) + } + + func testConfirmedDPoPClockFailureHasClockHint() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"time_window","traceId":"trace-clock"}"# + ) + + XCTAssertEqual(body.dpopFailureReason, .timeWindow) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.clockHint)" + ) + } + + func testOlderDPoPFailureTreatsClockSkewAsPossible() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","traceId":"trace-old"}"# + ) + + XCTAssertNil(body.dpopFailureReason) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.unknownHint)" + ) + } + + func testNonClockAndUnknownDPoPFailuresUseNeutralHint() throws { + for reason in ["key_mismatch", "request_mismatch", "token_mismatch", "replay", + "invalid_proof", "future_reason"] + { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"\#(reason)","traceId":"trace-retry"}"# + ) + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay rejected the DPoP proof. \(DPoPFailurePresentation.retryHint)" + ) + } + } + + func testMissingEnvironmentLinkUsesSpecificMessage() throws { + let body = try relayErrorBody( + #"{"code":"environment_connect_not_authorized","reason":"environment_link_not_found","traceId":"trace-link"}"# + ) + + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: true), + "Relay has no active link for this environment. The environment server may not have re-established its link yet." + ) + } + + func testPresentedRelayErrorPreservesTraceID() throws { + let body = try relayErrorBody( + #"{"code":"environment_endpoint_timed_out","traceId":"trace-timeout"}"# + ) + let error = T3ConnectRelayError.response( + status: 504, + message: T3ConnectRelayErrorPresentation.message( + for: body, + requestUsesDPoP: true + ), + traceID: body.traceId + ) + + XCTAssertEqual( + error.errorDescription, + "Relay timed out while contacting the environment endpoint. (trace trace-timeout)" + ) + } + + func testBearerRelayRequestDoesNotGetDPoPClockAdvice() throws { + let body = try relayErrorBody( + #"{"code":"auth_invalid","reason":"invalid_dpop","dpopFailureReason":"time_window","message":"The session was rejected.","traceId":"trace-bearer"}"# + ) + + XCTAssertEqual( + T3ConnectRelayErrorPresentation.message(for: body, requestUsesDPoP: false), + "The session was rejected." + ) + } + + private func relayErrorBody(_ json: String) throws -> T3ConnectRelayErrorBody { + try JSONDecoder.t3.decode(T3ConnectRelayErrorBody.self, from: Data(json.utf8)) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift b/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift new file mode 100644 index 000000000000..f7663e5c07a2 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/T3ConnectRuntimeTests.swift @@ -0,0 +1,1430 @@ +import CryptoKit +import XCTest +@testable import T3Code + +final class T3ConnectRuntimeTests: XCTestCase { + func testLegacyCredentialsRemainBearerAndManagedMetadataIsRedacted() async throws { + let legacy = Data(#"{"accessToken":"legacy-secret","scopes":["read"]}"#.utf8) + let decoded = try JSONDecoder.t3.decode(EnvironmentCredential.self, from: legacy) + XCTAssertEqual(decoded.authorizationMethod, .bearer) + XCTAssertEqual(decoded.accessToken, "legacy-secret") + XCTAssertThrowsError( + try JSONDecoder.t3.decode( + EnvironmentCredential.self, + from: Data(#"{"accessToken":"secret","authorizationMethod":"dpop"}"#.utf8) + ) + ) + + let managed = EnvironmentCredential.managedDPoP( + accessToken: "managed-secret", + expiresAt: Date(timeIntervalSince1970: 2_000_000_000), + scopes: ["orchestration:read"], + environmentID: "managed-1", + proofKeyThumbprint: "proof-key" + ) + XCTAssertFalse(String(describing: managed).contains("managed-secret")) + XCTAssertFalse(String(reflecting: managed).contains("managed-secret")) + XCTAssertEqual( + try JSONDecoder.t3.decode( + EnvironmentCredential.self, + from: JSONEncoder.t3.encode(managed) + ), + managed + ) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-redaction-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([ + managedEnvironment(descriptor: descriptor(environmentID: "managed-1")), + ]) + let catalog = try String(contentsOf: catalogURL, encoding: .utf8) + XCTAssertTrue(catalog.contains("managed-dpop")) + XCTAssertFalse(catalog.contains("managed-secret")) + XCTAssertFalse(catalog.contains("proof-key")) + } + + func testEveryManagedHTTPRequestGetsFreshDPoPProof() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + XCTAssertEqual(request.url?.path, "/api/auth/session") + return (.authSession, 200) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { _ in throw T3ConnectTestError.unexpectedRefresh } + ) + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "bound-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + _ = try await api.session(for: environment) + _ = try await api.session(for: environment) + + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "DPoP bound-token" + }) + let proofs = requests.compactMap { $0.value(forHTTPHeaderField: "DPoP") } + XCTAssertEqual(proofs.count, 2) + XCTAssertNotEqual(proofs[0], proofs[1]) + XCTAssertFalse(requests.contains { + $0.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true + }) + XCTAssertTrue(requests.allSatisfy { + $0.value(forHTTPHeaderField: "Accept-Encoding") == "gzip" + }) + } + + func testManagedRoutesReplaceAdvertisedBasePath() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + return (.descriptor, 200) + case 2: + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + case 3: + return (.webSocketTicket("canonical-ticket"), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let endpoint = T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example/advertised/prefix?stale=true", + wsBaseUrl: "wss://managed.example/ws", + providerKind: .t3Relay + ) + let bootstrap = T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: endpoint, + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2026-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + + _ = try await authorizer.descriptor(at: try XCTUnwrap(endpoint.httpBaseURL)) + let authorization = try await authorizer.exchange(bootstrap) + _ = try await authorizer.webSocketURL(using: authorization) + + let requests = await transport.requests + XCTAssertEqual( + requests.map(\.url?.path), + [ + "/.well-known/t3/environment", + "/oauth/token", + "/api/auth/websocket-ticket", + ] + ) + XCTAssertTrue(requests.allSatisfy { $0.url?.query == nil }) + } + + func testSixtySecondMarginRefreshesAndPersistsBeforeFirstRequest() async throws { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(45) + ) + + _ = try await fixture.api.session(for: fixture.environment) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let saved = await fixture.credentials.credential(for: fixture.environment.id) + XCTAssertEqual(saved?.accessToken, "fresh-environment-token") + XCTAssertEqual(saved?.authorizationMethod, .dpop) + let requests = await fixture.transport.requests + XCTAssertEqual( + requests.map(\.url?.path), + ["/.well-known/t3/environment", "/oauth/token", "/api/auth/session"] + ) + XCTAssertEqual( + requests.last?.value(forHTTPHeaderField: "Authorization"), + "DPoP fresh-environment-token" + ) + } + + func testConcurrentExpiryRefreshIsSingleFlight() async throws { + let fixture = try await refreshFixture( + savedThumbprint: nil, + expiresAt: Date().addingTimeInterval(-1) + ) + + async let first = fixture.api.session(for: fixture.environment) + async let second = fixture.secondAPI.session(for: fixture.environment) + _ = try await (first, second) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let refreshRequests = await fixture.transport.requests + let paths = refreshRequests.map(\.url?.path) + XCTAssertEqual(paths.filter { $0 == "/oauth/token" }.count, 1) + XCTAssertEqual(paths.filter { $0 == "/api/auth/session" }.count, 2) + } + + func testCancellingRefreshWaiterDoesNotBreakSingleFlight() async throws { + let signer = try testSigner() + let environment = managedEnvironment(descriptor: descriptor()) + let replacing = EnvironmentCredential.managedDPoP( + accessToken: "expired-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: try await signer.thumbprint() + ) + let source = BlockingT3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorization = T3ConnectRuntimeAuthorization( + authorizer: T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ), + bootstrapProvider: { id in try await source.value(for: id) } + ) + + let first = Task { + try await authorization.refreshCredential( + for: environment, + replacing: replacing + ) + } + await source.waitUntilCallCount(1) + first.cancel() + let secondStarted = AsyncTestMarker() + let second = Task { + await secondStarted.mark() + return try await authorization.refreshCredential( + for: environment, + replacing: replacing + ) + } + await secondStarted.waitUntilMarked() + await Task.yield() + await source.release() + + let firstCredential = try await first.value + let secondCredential = try await second.value + XCTAssertEqual(firstCredential, secondCredential) + let calls = await source.calls + XCTAssertEqual(calls, 1) + } + + func testRevokedCredentialCannotBeRestoredByAnInFlightRefresh() async throws { + let signer = try testSigner() + let environment = managedEnvironment(descriptor: descriptor()) + let expired = EnvironmentCredential.managedDPoP( + accessToken: "expired-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: try await signer.thumbprint() + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: expired]) + let bootstrap = BlockingT3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return ( + .token( + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ") + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let authorization = T3ConnectRuntimeAuthorization( + authorizer: T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ), + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: authorization + ) + + let request = Task { try await api.session(for: environment) } + await bootstrap.waitUntilCallCount(1) + await credentials.removeCredential(for: environment.id) + await bootstrap.release() + + do { + _ = try await request.value + XCTFail("A revoked environment credential was restored by an in-flight refresh") + } catch HTTPError.missingCredential { + // Removing the saved credential permanently invalidates this refresh. + } + let restoredCredential = await credentials.credential(for: environment.id) + XCTAssertNil(restoredCredential) + } + + func testFailedManagedSaveDoesNotOverwriteNewerCredential() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: managedCredential(accessToken: "previous-managed-token"), + replacementTiming: .afterInstallation + ) + } + + func testFailedFirstManagedSaveDoesNotDeleteNewerCredential() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: nil, + replacementTiming: .afterInstallation + ) + } + + func testFailedManagedSaveRestoresCredentialRefreshedBeforeInstallation() async throws { + try await assertFailedManagedSavePreservesNewerCredential( + previousCredential: managedCredential(accessToken: "previous-managed-token"), + replacementTiming: .beforeInstallation + ) + } + + func testRotatedProofKeyRebindsFreshCredential() async throws { + let fixture = try await refreshFixture( + savedThumbprint: "stale-proof-key", + expiresAt: Date().addingTimeInterval(300) + ) + + _ = try await fixture.api.session(for: fixture.environment) + + let refreshCalls = await fixture.bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let currentThumbprint = try await fixture.signer.thumbprint() + let saved = await fixture.credentials.credential(for: fixture.environment.id) + XCTAssertEqual(saved?.proofKeyThumbprint, currentThumbprint) + XCTAssertEqual(saved?.accessToken, "fresh-environment-token") + } + + func testStaggered401ReusesNewerSavedCredentialWithoutSecondMint() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let original = EnvironmentCredential.managedDPoP( + accessToken: "old-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let newer = EnvironmentCredential.managedDPoP( + accessToken: "newer-token", + expiresAt: Date().addingTimeInterval(300), + scopes: original.scopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: original]) + let transport = T3ConnectStaggered401Transport( + credentialStore: credentials, + environmentID: environment.id, + newerCredential: newer + ) + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + _ = try await api.session(for: environment) + + let refreshCalls = await bootstrap.calls + XCTAssertEqual(refreshCalls, 0) + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertEqual( + requests.map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP old-token", "DPoP newer-token"] + ) + XCTAssertNotEqual( + requests[0].value(forHTTPHeaderField: "DPoP"), + requests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testRejectedTokenRefreshesOnceAndRetriesWithANewProof() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "rejected-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch (request.url?.path, ordinal) { + case ("/api/auth/session", 1): + return (Data(#"{"message":"expired"}"#.utf8), 401) + case ("/.well-known/t3/environment", 2): + return (.descriptor, 200) + case ("/oauth/token", 3): + return (.token(scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ")), 200) + case ("/api/auth/session", 4): + return (.authSession, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + + _ = try await api.session(for: environment) + + let refreshCalls = await bootstrap.calls + XCTAssertEqual(refreshCalls, 1) + let requests = await transport.requests + let sessionRequests = requests.filter { $0.url?.path == "/api/auth/session" } + XCTAssertEqual(sessionRequests.count, 2) + XCTAssertEqual( + sessionRequests.map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP rejected-token", "DPoP fresh-environment-token"] + ) + XCTAssertNotEqual( + sessionRequests[0].value(forHTTPHeaderField: "DPoP"), + sessionRequests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testRefreshDescriptorMismatchDoesNotReplaceSavedCredential() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let original = EnvironmentCredential.managedDPoP( + accessToken: "saved-token", + expiresAt: Date().addingTimeInterval(-1), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ) + let credentials = InMemoryCredentialStore(credentials: [environment.id: original]) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + guard request.url?.path == "/.well-known/t3/environment" else { + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + return (.descriptor("another-environment"), 200) + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let api = EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + ) + + do { + _ = try await api.session(for: environment) + XCTFail("Refresh accepted a descriptor for another environment") + } catch T3ConnectRelayError.environmentMismatch { + // Expected identity rejection. + } + let saved = await credentials.credential(for: environment.id) + XCTAssertEqual(saved, original) + let requests = await transport.requests + XCTAssertEqual(requests.map(\.url?.path), ["/.well-known/t3/environment"]) + } + + func testWebSocketReconnectMintsAOneUseTicketEveryTime() async throws { + let signer = try testSigner() + let thumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "socket-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: thumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + XCTAssertEqual(request.url?.path, "/api/auth/websocket-ticket") + return (.webSocketTicket("ticket-\(ordinal)"), 200) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { _ in throw T3ConnectTestError.unexpectedRefresh } + ) + let connector = T3ConnectReconnectConnector() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: connector, + managedAuthorization: runtimeAuthorization + ) + + await client.connect() + await connector.waitForConnectionCount(2) + await client.disconnect() + + let urls = await connector.urls + XCTAssertEqual(urls.prefix(2).map { ticket(in: $0) }, ["ticket-1", "ticket-2"]) + XCTAssertFalse(urls.prefix(2).contains(environment.webSocketBaseURL)) + XCTAssertFalse(urls.prefix(2).contains { $0.absoluteString.contains("socket-token") }) + let requests = await transport.requests + XCTAssertGreaterThanOrEqual(requests.count, 2) + XCTAssertNotEqual( + requests[0].value(forHTTPHeaderField: "DPoP"), + requests[1].value(forHTTPHeaderField: "DPoP") + ) + } + + func testManagedWebSocketRejectsUnencryptedEndpointBeforeMintingTicket() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let authorization = T3ConnectEnvironmentAccessToken( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "ws://managed.example/ws", + providerKind: .t3Relay + ), + accessToken: "access-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + proofKeyThumbprint: try await signer.thumbprint() + ) + + do { + _ = try await authorizer.webSocketURL(using: authorization) + XCTFail("An unencrypted managed WebSocket endpoint was accepted") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + func testManagedWebSocketRejectsDifferentHostBeforeMintingTicket() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, _ in + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let authorization = T3ConnectEnvironmentAccessToken( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://different.example/ws", + providerKind: .t3Relay + ), + accessToken: "access-token", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + proofKeyThumbprint: try await signer.thumbprint() + ) + + do { + _ = try await authorizer.webSocketURL(using: authorization) + XCTFail("A managed WebSocket ticket was sent to a different host") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + func testEnvironmentExchangeRejectsMalformedTokenContracts() async throws { + let signer = try testSigner() + let bootstrap = try await bootstrapCredential(signer: signer) + let validScopes = T3ConnectManagedEnvironmentAuthorizer.standardScopes.joined(separator: " ") + let invalidBodies = [ + Data.token(accessToken: "", scopes: validScopes), + Data.token(issuedTokenType: "wrong", scopes: validScopes), + Data.token(expiresIn: 0, scopes: validScopes), + Data.token(scopes: "orchestration:read"), + ] + + for body in invalidBodies { + let transport = T3ConnectScriptedHTTPTransport { _, _ in (body, 200) } + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + do { + _ = try await authorizer.exchange(bootstrap) + XCTFail("Malformed environment token response was accepted") + } catch is T3ConnectRelayError { + // Expected contract rejection. + } + } + } + + func testRelayTokenCacheNeverCrossesClerkAccounts() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch (request.url?.path, ordinal) { + case ("/v1/client/dpop-token", 1): + return (.relayToken("relay-account-a"), 200) + case ("/v1/environments/managed-1/status", 2): + return (.relayStatus, 200) + case ("/v1/client/dpop-token", 3): + return (.relayToken("relay-account-b"), 200) + case ("/v1/environments/managed-1/status", 4): + return (.relayStatus, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let record = T3ConnectRelayEnvironment( + environmentId: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + linkedAt: "2026-08-01T12:00:00.000Z" + ) + + _ = try await relay.status(for: record, clerkToken: clerkJWT(subject: "account-a")) + _ = try await relay.status(for: record, clerkToken: clerkJWT(subject: "account-b")) + + let requests = await transport.requests + XCTAssertEqual(requests.filter { $0.url?.path == "/v1/client/dpop-token" }.count, 2) + XCTAssertEqual( + requests.filter { $0.url?.path.hasSuffix("/status") == true } + .map { $0.value(forHTTPHeaderField: "Authorization") }, + ["DPoP relay-account-a", "DPoP relay-account-b"] + ) + } + + func testRelayMobileDeliveryEndpointsUseBoundDPoPRequests() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + XCTAssertEqual(request.url?.path, "/v1/client/dpop-token") + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2, 3, 4: + return (Data(#"{"ok":true}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let clerkToken = clerkJWT(subject: "mobile-account") + let device = T3ConnectDeviceRegistration( + deviceID: "phone-1", + label: "Big O", + iosMajorVersion: 26, + bundleID: "com.t3tools.t3code.swiftui", + apsEnvironment: .sandbox, + pushToken: "apns-token", + pushToStartToken: "start-token" + ) + + try await relay.registerDevice(device, clerkToken: clerkToken) + try await relay.registerLiveActivity( + T3ConnectLiveActivityRegistration( + deviceID: "phone-1", + activityPushToken: "activity-token" + ), + clerkToken: clerkToken + ) + try await relay.unregisterDevice(deviceID: "phone-1", clerkToken: clerkToken) + + let requests = await transport.requests + XCTAssertEqual( + requests.dropFirst().map(\.url?.path), + [ + "/v1/mobile/devices", + "/v1/mobile/live-activities", + "/v1/mobile/devices/phone-1", + ] + ) + XCTAssertEqual(requests.last?.httpMethod, "DELETE") + let proofs = requests.dropFirst().compactMap { + $0.value(forHTTPHeaderField: "DPoP") + } + XCTAssertEqual(proofs.count, 3) + XCTAssertEqual(Set(proofs).count, 3) + XCTAssertTrue(requests.dropFirst().allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "DPoP relay-mobile" + }) + } + + func testRelayRoutesReplaceConfiguredBasePathAndQuery() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { request, ordinal in + switch ordinal { + case 1: + XCTAssertEqual(request.url?.path, "/v1/client/dpop-token") + XCTAssertNil(request.url?.query) + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2: + XCTAssertEqual(request.url?.path, "/v1/mobile/devices") + XCTAssertNil(request.url?.query) + return (Data(#"{"ok":true}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example/stale/base?old=true")! + ), + transport: transport, + signer: signer + ) + + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + } + + func testRelayRejectsFalseOKResponse() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, ordinal in + switch ordinal { + case 1: + return (.relayToken("relay-mobile", scope: "mobile:registration"), 200) + case 2: + return (Data(#"{"ok":false}"#.utf8), 200) + default: + throw T3ConnectTestError.unexpectedPath(nil) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + + do { + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A false success envelope was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + + func testRelayRejectsMalformedAccessTokenContracts() async throws { + let invalidTokens = [ + Data.relayToken("", scope: "mobile:registration"), + Data( + #"{"access_token":"token","issued_token_type":"wrong","token_type":"DPoP","expires_in":300,"scope":"mobile:registration"}"#.utf8 + ), + Data( + #"{"access_token":"token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":0,"scope":"mobile:registration"}"#.utf8 + ), + ] + + for token in invalidTokens { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, _ in (token, 200) } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + do { + try await relay.registerDevice( + testDeviceRegistration(), + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A malformed relay access token was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + } + + func testRelayRejectsBlankBootstrapCredential() async throws { + let signer = try testSigner() + let transport = T3ConnectScriptedHTTPTransport { _, ordinal in + switch ordinal { + case 1: + return (.relayToken("relay-connect", scope: "environment:connect"), 200) + case 2: + return ( + Data( + #"{"environmentId":"managed-1","endpoint":{"httpBaseUrl":"https://managed.example","wsBaseUrl":"wss://managed.example","providerKind":"t3_relay"},"credential":"","expiresAt":""}"#.utf8 + ), + 200 + ) + default: + throw T3ConnectTestError.unexpectedPath(nil) + } + } + let relay = T3ConnectRelayClient( + configuration: T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ), + transport: transport, + signer: signer + ) + let environment = T3ConnectRelayEnvironment( + environmentId: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + linkedAt: "2026-08-01T12:00:00.000Z" + ) + + do { + _ = try await relay.connect( + to: environment, + clerkToken: clerkJWT(subject: "mobile-account") + ) + XCTFail("A blank environment bootstrap credential was accepted") + } catch T3ConnectRelayError.invalidResponse { + // Expected contract rejection. + } + } + + private func testDeviceRegistration() -> T3ConnectDeviceRegistration { + T3ConnectDeviceRegistration( + deviceID: "phone-1", + label: "Big O", + iosMajorVersion: 26, + bundleID: "com.t3tools.t3code.swiftui", + apsEnvironment: .sandbox, + pushToken: "apns-token", + pushToStartToken: "start-token" + ) + } + + private func refreshFixture( + savedThumbprint: String?, + expiresAt: Date + ) async throws -> T3ConnectRefreshFixture { + let signer = try testSigner() + let currentThumbprint = try await signer.thumbprint() + let environment = managedEnvironment(descriptor: descriptor()) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: .managedDPoP( + accessToken: "saved-token", + expiresAt: expiresAt, + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: environment.id, + proofKeyThumbprint: savedThumbprint ?? currentThumbprint + ), + ]) + let transport = T3ConnectScriptedHTTPTransport { request, _ in + switch request.url?.path { + case "/.well-known/t3/environment": + return (.descriptor, 200) + case "/oauth/token": + return (.token(scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes + .joined(separator: " ")), 200) + case "/api/auth/session": + return (.authSession, 200) + default: + throw T3ConnectTestError.unexpectedPath(request.url?.path) + } + } + let bootstrap = T3ConnectBootstrapSource( + credential: try await bootstrapCredential(signer: signer) + ) + let authorizer = T3ConnectManagedEnvironmentAuthorizer( + transport: transport, + signer: signer + ) + let runtimeAuthorization = T3ConnectRuntimeAuthorization( + authorizer: authorizer, + bootstrapProvider: { id in try await bootstrap.value(for: id) } + ) + return T3ConnectRefreshFixture( + signer: signer, + environment: environment, + credentials: credentials, + transport: transport, + bootstrap: bootstrap, + api: EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ), + secondAPI: EnvironmentAPI( + transport: transport, + credentials: credentials, + managedAuthorization: runtimeAuthorization + ) + ) + } + + private func assertFailedManagedSavePreservesNewerCredential( + previousCredential: EnvironmentCredential?, + replacementTiming: ManagedPersistenceCredentialStore.ReplacementTiming + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-managed-credential-race-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + let environment = managedEnvironment(descriptor: descriptor()) + let newerCredential = managedCredential(accessToken: "newer-managed-token") + let credentials = ManagedPersistenceCredentialStore( + previousCredential: previousCredential, + newerCredential: newerCredential, + replacementTiming: replacementTiming + ) + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: credentials + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + do { + _ = try await runtime.saveManagedEnvironment( + environment, + credential: managedCredential(accessToken: "pairing-managed-token") + ) + XCTFail("Managed pairing unexpectedly updated a read-only environment catalog") + } catch { + let saved = await credentials.credential(for: environment.id) + XCTAssertEqual(saved, newerCredential) + } + } + + private func managedCredential(accessToken: String) -> EnvironmentCredential { + .managedDPoP( + accessToken: accessToken, + expiresAt: Date(timeIntervalSince1970: 2_000_000_000), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: "managed-1", + proofKeyThumbprint: "proof-key" + ) + } + + private func testSigner() throws -> T3ConnectDPoPSigner { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 7 + return try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + } + + private func descriptor(environmentID: String = "managed-1") -> EnvironmentDescriptor { + try! JSONDecoder.t3.decode(EnvironmentDescriptor.self, from: .descriptor(environmentID)) + } + + private func managedEnvironment(descriptor: EnvironmentDescriptor) -> Environment { + Environment( + id: descriptor.environmentId, + label: descriptor.label, + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP, + descriptor: descriptor + ) + } + + private func bootstrapCredential( + signer: T3ConnectDPoPSigner + ) async throws -> T3ConnectManagedEnvironmentCredential { + T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2026-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + } + + private func ticket(in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first(where: { $0.name == "wsTicket" })?.value + } + + private func clerkJWT(subject: String) -> String { + let header = Data(#"{"alg":"none"}"#.utf8).testBase64URL() + let payload = Data(#"{"sub":"\#(subject)"}"#.utf8).testBase64URL() + return "\(header).\(payload).signature" + } +} + +private struct T3ConnectRefreshFixture { + let signer: T3ConnectDPoPSigner + let environment: Environment + let credentials: InMemoryCredentialStore + let transport: T3ConnectScriptedHTTPTransport + let bootstrap: T3ConnectBootstrapSource + let api: EnvironmentAPI + let secondAPI: EnvironmentAPI +} + +private enum T3ConnectTestError: Error { + case unexpectedRefresh + case unexpectedPath(String?) +} + +private actor ManagedPersistenceCredentialStore: CredentialStore { + enum ReplacementTiming { + case beforeInstallation + case afterInstallation + } + + private var storedCredential: EnvironmentCredential? + private let newerCredential: EnvironmentCredential + private let replacementTiming: ReplacementTiming + private var hasInsertedNewerCredential = false + + init( + previousCredential: EnvironmentCredential?, + newerCredential: EnvironmentCredential, + replacementTiming: ReplacementTiming + ) { + storedCredential = previousCredential + self.newerCredential = newerCredential + self.replacementTiming = replacementTiming + } + + func credential(for environmentID: String) -> EnvironmentCredential? { + let currentCredential = storedCredential + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + return currentCredential + } + + func setCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) { + storedCredential = credential + if replacementTiming == .afterInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + } + + func swapCredential( + _ credential: EnvironmentCredential, + for environmentID: String + ) -> EnvironmentCredential? { + if replacementTiming == .beforeInstallation, !hasInsertedNewerCredential { + hasInsertedNewerCredential = true + storedCredential = newerCredential + } + let previousCredential = storedCredential + setCredential(credential, for: environmentID) + return previousCredential + } + + func replaceCredential( + _ credential: EnvironmentCredential, + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = credential + return true + } + + func removeCredential(for environmentID: String) { + storedCredential = nil + } + + func removeCredential( + ifMatching expected: EnvironmentCredential, + for environmentID: String + ) -> Bool { + guard storedCredential == expected else { return false } + storedCredential = nil + return true + } +} + +private actor T3ConnectBootstrapSource { + private let credential: T3ConnectManagedEnvironmentCredential + private(set) var calls = 0 + + init(credential: T3ConnectManagedEnvironmentCredential) { + self.credential = credential + } + + func value(for environmentID: String) throws -> T3ConnectManagedEnvironmentCredential { + guard environmentID == credential.environmentID else { + throw T3ConnectTestError.unexpectedPath(environmentID) + } + calls += 1 + return credential + } +} + +private actor BlockingT3ConnectBootstrapSource { + private let credential: T3ConnectManagedEnvironmentCredential + private var released = false + private var releaseWaiters: [CheckedContinuation] = [] + private var callWaiters: [(Int, CheckedContinuation)] = [] + private(set) var calls = 0 + + init(credential: T3ConnectManagedEnvironmentCredential) { + self.credential = credential + } + + func value(for environmentID: String) async throws + -> T3ConnectManagedEnvironmentCredential + { + guard environmentID == credential.environmentID else { + throw T3ConnectTestError.unexpectedPath(environmentID) + } + calls += 1 + let ready = callWaiters.filter { calls >= $0.0 } + callWaiters.removeAll { calls >= $0.0 } + ready.forEach { $0.1.resume() } + if !released { + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + return credential + } + + func waitUntilCallCount(_ count: Int) async { + guard calls < count else { return } + await withCheckedContinuation { continuation in + callWaiters.append((count, continuation)) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor AsyncTestMarker { + private var marked = false + private var waiters: [CheckedContinuation] = [] + + func mark() { + marked = true + let pending = waiters + waiters.removeAll() + pending.forEach { $0.resume() } + } + + func waitUntilMarked() async { + guard !marked else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private actor T3ConnectScriptedHTTPTransport: HTTPTransport { + typealias Handler = @Sendable (URLRequest, Int) throws -> (Data, Int) + + private let handler: Handler + private(set) var requests: [URLRequest] = [] + + init(handler: @escaping Handler) { + self.handler = handler + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + let (data, status) = try handler(request, requests.count) + return (data, response(request, status: status)) + } +} + +private actor T3ConnectStaggered401Transport: HTTPTransport { + private let credentialStore: InMemoryCredentialStore + private let environmentID: String + private let newerCredential: EnvironmentCredential + private(set) var requests: [URLRequest] = [] + + init( + credentialStore: InMemoryCredentialStore, + environmentID: String, + newerCredential: EnvironmentCredential + ) { + self.credentialStore = credentialStore + self.environmentID = environmentID + self.newerCredential = newerCredential + } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + if requests.count == 1 { + await credentialStore.setCredential(newerCredential, for: environmentID) + return (Data(#"{"message":"expired"}"#.utf8), response(request, status: 401)) + } + return (.authSession, response(request, status: 200)) + } +} + +private actor T3ConnectReconnectConnector: WebSocketConnecting { + private(set) var urls: [URL] = [] + private var waiters: [(Int, CheckedContinuation)] = [] + + func connect(to url: URL) -> any WebSocketConnection { + urls.append(url) + let ready = waiters.filter { urls.count >= $0.0 } + waiters.removeAll { urls.count >= $0.0 } + ready.forEach { $0.1.resume() } + return T3ConnectFailingReceiveConnection() + } + + func waitForConnectionCount(_ count: Int) async { + guard urls.count < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor T3ConnectFailingReceiveConnection: WebSocketConnection { + func send(_: Data) {} + func receive() throws -> Data { throw URLError(.networkConnectionLost) } + func close() {} +} + +private extension Data { + static var authSession: Data { + Data(#"{"authenticated":true,"scopes":[],"sessionMethod":"dpop","expiresAt":null}"#.utf8) + } + + static var descriptor: Data { descriptor("managed-1") } + + static func descriptor(_ environmentID: String) -> Data { + Data( + """ + { + "environmentId": "\(environmentID)", + "label": "Managed Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """.utf8 + ) + } + + static func token( + accessToken: String = "fresh-environment-token", + issuedTokenType: String = "urn:ietf:params:oauth:token-type:access_token", + expiresIn: Double = 300, + scopes: String + ) -> Data { + Data( + """ + { + "access_token": "\(accessToken)", + "issued_token_type": "\(issuedTokenType)", + "token_type": "DPoP", + "expires_in": \(expiresIn), + "scope": "\(scopes)" + } + """.utf8 + ) + } + + static func webSocketTicket(_ ticket: String) -> Data { + Data( + #"{"ticket":"\#(ticket)","expiresAt":"2026-08-01T12:05:00.000Z"}"#.utf8 + ) + } + + static func relayToken(_ token: String, scope: String = "environment:status") -> Data { + Data( + """ + { + "access_token": "\(token)", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "DPoP", + "expires_in": 300, + "scope": "\(scope)" + } + """.utf8 + ) + } + + static var relayStatus: Data { + Data( + """ + { + "environmentId": "managed-1", + "endpoint": { + "httpBaseUrl": "https://managed.example", + "wsBaseUrl": "wss://managed.example", + "providerKind": "t3_relay" + }, + "status": "online", + "checkedAt": "2026-08-01T12:00:00.000Z", + "descriptor": null, + "error": null, + "traceId": null + } + """.utf8 + ) + } + + func testBase64URL() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private func response(_ request: URLRequest, status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} diff --git a/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift new file mode 100644 index 000000000000..95a19f437cf4 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/TransportReliabilityTests.swift @@ -0,0 +1,1040 @@ +import XCTest +@testable import T3Code + +@MainActor +final class TransportReliabilityTests: XCTestCase { + func testMobileClientMetadataIncludesOSVersionAndDeviceModel() { + XCTAssertGreaterThan(MobileClientMetadata.osMajorVersion, 0) + XCTAssertFalse(MobileClientMetadata.deviceModel.isEmpty) + } + + func testHTTPPolicyOffersGzipWithoutOverwritingCallerPreference() { + var request = URLRequest(url: URL(string: "https://studio.example/api")!) + let prepared = HTTPRequestPolicy.prepare(request) + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + XCTAssertEqual(prepared.value(forHTTPHeaderField: "Accept"), "application/json") + + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + XCTAssertEqual( + HTTPRequestPolicy.prepare(request).value(forHTTPHeaderField: "Accept-Encoding"), + "identity" + ) + } + + func testEnvironmentAPIDecodesURLSessionDecompressedGzipResponse() async throws { + let transport = RecordingHTTPTransport { request in + let body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """ + return ( + Data(body.utf8), + transportResponse( + request, + headers: [ + "Content-Type": "application/json", + // URLSession retains this response header while + // returning the already decompressed body. + "Content-Encoding": "gzip", + ] + ) + ) + } + let api = EnvironmentAPI( + transport: transport, + credentials: InMemoryCredentialStore() + ) + + let descriptor = try await api.descriptor( + at: URL(string: "https://studio.example")! + ) + XCTAssertEqual(descriptor.environmentId, "environment-1") + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept-Encoding"), "gzip") + } + + func testPrismUsesEnvironmentAuthenticationAndDecodesAdditiveAccountHealth() async throws { + let environment = Environment( + id: "environment-prism", label: "PC", + httpBaseURL: URL(string: "https://pc.example")!, + webSocketBaseURL: URL(string: "wss://pc.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "test-environment-token"), + ]) + let transport = RecordingHTTPTransport { request in + let body = #"{"accounts":[{"id":"claude.json","provider":"anthropic","label":"Claude","disabled":false,"lifecycle":{"requiresLogin":true,"expiresAt":"2026-09-05T00:00:00Z"}},{"id":"old.json","provider":"codex","label":"Older gateway","disabled":false}]}"# + return (Data(body.utf8), transportResponse(request)) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + let response = try await api.prism( + PrismRequest("/accounts/claude.json", method: "PATCH", body: ["disabled": .bool(true)]), + environment: environment + ) + XCTAssertEqual(response.accounts?.first?.lifecycle?.requiresLogin, true) + XCTAssertNil(response.accounts?.last?.lifecycle) + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.url?.absoluteString, "https://pc.example/api/fork/prism/accounts/claude.json") + XCTAssertEqual(request.httpMethod, "PATCH") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-environment-token") + let body = try JSONDecoder().decode([String: Bool].self, from: XCTUnwrap(request.httpBody)) + XCTAssertEqual(body, ["disabled": true]) + } + + func testShellSnapshotAppliesBoundedStartupTimeout() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = #"{"snapshotSequence":0,"projects":[],"threads":[],"updatedAt":"2026-08-05T12:00:00.000Z"}"# + return (Data(body.utf8), transportResponse(request)) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + _ = try await api.shellSnapshot(for: environment, timeoutInterval: 6) + + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.timeoutInterval, 6) + } + + func testThreadSnapshotSendsPaginationWindowAndDecodesCursor() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let body = try JSONEncoder.t3.encode( + OrchestrationThreadDetailSnapshot( + snapshotSequence: 42, + thread: paginationThreadFixture(), + page: OrchestrationThreadDetailPage( + beforeCursor: "next-cursor", + hasMore: true, + snapshotSequence: 42, + threadSequence: 40 + ) + ) + ) + let transport = RecordingHTTPTransport { request in + (body, transportResponse(request)) + } + let api = EnvironmentAPI(transport: transport, credentials: credentials) + + let snapshot = try await api.threadSnapshot( + id: "thread-1", + environment: environment, + turnLimit: 20, + beforeCursor: "current-cursor" + ) + + XCTAssertEqual(snapshot.page?.beforeCursor, "next-cursor") + XCTAssertEqual(snapshot.page?.threadSequence, 40) + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)) + .queryItems + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: (query ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }), + ["turnLimit": "20", "beforeCursor": "current-cursor"] + ) + } + + func testWebSocketHandshakeOffersPerMessageDeflate() { + let url = URL(string: "wss://studio.example/ws?wsTicket=secret")! + let compressed = WebSocketHandshakeRequest.make(url: url) + XCTAssertEqual( + compressed.value(forHTTPHeaderField: "Sec-WebSocket-Extensions"), + "permessage-deflate; client_max_window_bits" + ) + XCTAssertNil( + WebSocketHandshakeRequest.make( + url: url, + offersPerMessageDeflate: false + ).value(forHTTPHeaderField: "Sec-WebSocket-Extensions") + ) + } + + func testBootstrapUsesCanonicalWebSocketRPCDispatch() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = """ + { + "ticket": "websocket-ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """ + return (Data(body.utf8), transportResponse(request)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + + let result = try await client.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Native first send", + text: "Start from this message", + model: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess, + commandID: "stable-command", + messageID: "stable-message", + createdAt: "2026-07-30T12:00:00.000Z" + ) + await client.disconnect() + + XCTAssertEqual(result.sequence, 42) + let requests = await connection.requests() + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests.first?["tag"]?.stringValue, "orchestration.dispatchCommand") + XCTAssertEqual( + requests.first?["payload"]?["bootstrap"]?["createThread"]?["projectId"]?.stringValue, + "project-1" + ) + XCTAssertEqual(requests.first?["payload"]?["commandId"]?.stringValue, "stable-command") + XCTAssertEqual( + requests.first?["payload"]?["message"]?["messageId"]?.stringValue, + "stable-message" + ) + + let httpRequests = await transport.requests + XCTAssertEqual(httpRequests.map(\.url?.path), ["/api/auth/websocket-ticket"]) + + let socketURLs = await connection.connectionURLs() + let socketURL = try XCTUnwrap(socketURLs.first) + let metadata = Dictionary( + uniqueKeysWithValues: (URLComponents(url: socketURL, resolvingAgainstBaseURL: false)? + .queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + XCTAssertEqual(metadata["clientSurface"], "mobile") + XCTAssertEqual(metadata["clientOs"], "iOS") + XCTAssertEqual(metadata["clientOsMajorVersion"], String(MobileClientMetadata.osMajorVersion)) + XCTAssertEqual(metadata["clientDeviceModel"], MobileClientMetadata.deviceModel) + } + + func testModernServersUploadImageBytesBeforeDispatchingTheTurn() async throws { + let descriptor = try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"attachmentUploads": true} + } + """.utf8 + ) + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-1", + projectID: "project-1", + title: "Image task", + text: "Inspect this image", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [image] + ) + await client.disconnect() + + let socketRequests = await connection.requests() + XCTAssertEqual(socketRequests.map { $0["tag"]?.stringValue }, [ + "attachments.createUploadUrl", + "orchestration.dispatchCommand", + ]) + XCTAssertEqual( + socketRequests[0]["payload"]?["sizeBytes"], + .number(4) + ) + guard case let .array(attachments)? = socketRequests[1]["payload"]?["message"]?["attachments"], + let attachment = attachments.first else { + return XCTFail("Expected an uploaded attachment") + } + XCTAssertEqual(attachment["id"]?.stringValue, "uploaded-attachment-1") + XCTAssertNil(attachment["dataUrl"]) + + let httpRequests = await transport.requests + XCTAssertEqual(httpRequests.map(\.url?.path), [ + "/api/auth/websocket-ticket", + "/api/attachments/upload/signed-token", + ]) + XCTAssertEqual(httpRequests[1].httpBody, Data([0x89, 0x50, 0x4e, 0x47])) + XCTAssertNil(httpRequests[1].value(forHTTPHeaderField: "Authorization")) + } + + func testOlderServersKeepInlineImageAttachments() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let image = try UploadChatImageAttachment( + data: Data([0x89, 0x50, 0x4e, 0x47]), + name: "screenshot.png", + mimeType: "image/png" + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-1", + projectID: "project-1", + title: "Image task", + text: "Inspect this image", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [image] + ) + await client.disconnect() + + let requests = await connection.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, ["orchestration.dispatchCommand"]) + guard case let .array(attachments)? = requests[0]["payload"]?["message"]?["attachments"] else { + return XCTFail("Expected an inline image") + } + XCTAssertEqual(attachments.first?["dataUrl"]?.stringValue, "data:image/png;base64,iVBORw==") + } + + func testExpiredSavedAttachmentIsUploadedAgain() async throws { + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":52428800}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let socket = RecordingWebSocketConnection( + assetErrorMessage: "Attachment saved-id was not found." + ) + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: socket) + ) + let attachment = try UploadChatAttachment( + data: Data([1]), + name: "image.png", + mimeType: "image/png", + uploadedReference: .init( + environmentID: environment.id, + attachmentID: "saved-id" + ) + ) + + let reference = try await client.prepareAttachment(attachment) + await client.disconnect() + + XCTAssertEqual(reference?.attachmentID, "uploaded-attachment-1") + let requests = await socket.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "assets.createUrl", + "attachments.createUploadUrl", + ]) + } + + func testSavedAttachmentAuthErrorDoesNotUploadAgain() async throws { + let descriptor = try attachmentDescriptor(#"{"attachmentUploads":true}"#) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let socket = RecordingWebSocketConnection(assetErrorMessage: "Unauthorized") + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: socket) + ) + let attachment = try UploadChatAttachment( + data: Data([1]), + name: "image.png", + mimeType: "image/png", + uploadedReference: .init( + environmentID: environment.id, + attachmentID: "saved-id" + ) + ) + + do { + _ = try await client.prepareAttachment(attachment) + XCTFail("Expected the authorization error") + } catch { + XCTAssertTrue(error.localizedDescription.contains("Unauthorized")) + } + await client.disconnect() + let requests = await socket.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "assets.createUrl", + ]) + } + + func testGenericFileUsesTypedUploadAndRawPostBody() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("notes.txt") + let fileData = Data("review notes".utf8) + try fileData.write(to: fileURL) + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":52428800}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let transport = RecordingHTTPTransport { request in + if request.url?.path == "/api/auth/websocket-ticket" { + return ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + return (Data(), transportResponse(request, status: 204)) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]), + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: fileData.count + ) + + _ = try await client.createThreadAndSend( + threadID: "thread-file", + projectID: "project-1", + title: "File task", + text: "Review this file", + model: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + attachments: [file] + ) + await client.disconnect() + + let socketRequests = await connection.requests() + XCTAssertEqual(socketRequests[0]["payload"]?["type"]?.stringValue, "file") + guard case let .array(sent)? = socketRequests[1]["payload"]?["message"]?["attachments"] else { + return XCTFail("Expected an uploaded file reference") + } + XCTAssertEqual(sent.first?["type"]?.stringValue, "file") + XCTAssertEqual(sent.first?["mimeType"]?.stringValue, "text/plain") + XCTAssertNil(sent.first?["dataUrl"]) + + let requests = await transport.requests + let upload = try XCTUnwrap(requests.last) + XCTAssertEqual(upload.httpMethod, "POST") + XCTAssertEqual(upload.httpBody, fileData) + XCTAssertEqual(upload.value(forHTTPHeaderField: "Content-Type"), "text/plain") + } + + func testGenericFileRejectsAnOlderServerBeforeDispatch() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("unsupported-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + try Data("file".utf8).write(to: fileURL) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore(), + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 4 + ) + + do { + _ = try await client.sendTurn( + threadID: "thread-1", + text: "Review", + runtimeMode: .fullAccess, + attachments: [file] + ) + XCTFail("Expected unsupported file rejection") + } catch FileAttachmentError.unsupported { + let requests = await connection.requests() + XCTAssertTrue(requests.isEmpty) + } + } + + func testGenericFileUsesTheAdvertisedByteLimit() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("large-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: fileURL) } + try Data("four".utf8).write(to: fileURL) + let descriptor = try attachmentDescriptor( + #"{"attachmentUploads":true,"fileAttachments":{"maxUploadBytes":3}}"# + ) + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")!, + descriptor: descriptor + ) + let client = T3Client( + environment: environment, + credentialStore: InMemoryCredentialStore() + ) + let file = try UploadChatAttachment( + fileURL: fileURL, + name: "large.txt", + mimeType: "text/plain", + sizeBytes: 4 + ) + + do { + _ = try await client.sendTurn( + threadID: "thread-1", + text: "Review", + runtimeMode: .fullAccess, + attachments: [file] + ) + XCTFail("Expected the advertised limit to reject the file") + } catch let FileAttachmentError.tooLarge(actualBytes, maximumBytes) { + XCTAssertEqual(actualBytes, 4) + XCTAssertEqual(maximumBytes, 3) + } + } + + func testFeedbackRPCUsesTheThreadAndOptionalReason() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore(credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ]) + let transport = RecordingHTTPTransport { request in + ( + Data(#"{"ticket":"websocket-ticket","expiresAt":"2026-07-30T12:05:00.000Z"}"#.utf8), + transportResponse(request) + ) + } + let connection = RecordingWebSocketConnection() + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: StaticWebSocketConnector(connection: connection) + ) + + let withReason = try await client.uploadFeedback( + threadID: "thread-1", + reason: "The agent stopped early." + ) + let withoutReason = try await client.uploadFeedback(threadID: "thread-2") + await client.disconnect() + + XCTAssertEqual(withReason.feedbackId, "codex-thread-1") + XCTAssertEqual(withoutReason.feedbackId, "codex-thread-1") + let requests = await connection.requests() + XCTAssertEqual(requests.map { $0["tag"]?.stringValue }, [ + "provider.uploadFeedback", + "provider.uploadFeedback", + ]) + XCTAssertEqual(requests[0]["payload"]?["threadId"]?.stringValue, "thread-1") + XCTAssertEqual(requests[0]["payload"]?["reason"]?.stringValue, "The agent stopped early.") + XCTAssertEqual(requests[1]["payload"]?["threadId"]?.stringValue, "thread-2") + XCTAssertNil(requests[1]["payload"]?["reason"]) + } + + func testUnsentCommandsFallBackToHTTPButBootstrapDoesNot() async throws { + let environment = Environment( + id: "environment-1", + label: "Studio", + httpBaseURL: URL(string: "https://studio.example")!, + webSocketBaseURL: URL(string: "wss://studio.example")! + ) + let credentials = InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "access-token"), + ] + ) + let transport = RecordingHTTPTransport { request in + let body = if request.url?.path == "/api/auth/websocket-ticket" { + """ + { + "ticket": "websocket-ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """ + } else { + """ + {"sequence": 9} + """ + } + return (Data(body.utf8), transportResponse(request)) + } + let client = T3Client( + environment: environment, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: FailingWebSocketConnector(), + rpcConnectionWaitTimeout: .milliseconds(30) + ) + + let rename = try await client.rename(threadID: "thread-1", title: "Renamed") + XCTAssertEqual(rename.sequence, 9) + + do { + _ = try await client.createThreadAndSend( + threadID: "thread-first-send", + projectID: "project-1", + title: "Native first send", + text: "Start from this message", + model: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess + ) + XCTFail("Bootstrap must not use the HTTP endpoint that cannot expand it.") + } catch let error as RPCError { + guard case .connectionUnavailable = error else { + return XCTFail("Unexpected RPC error: \(error)") + } + } + await client.disconnect() + + let requests = await transport.requests + let dispatchRequests = requests.filter { + $0.url?.path == "/api/orchestration/dispatch" + } + XCTAssertEqual(dispatchRequests.count, 1) + let command = try JSONDecoder.t3.decode( + JSONValue.self, + from: try XCTUnwrap(dispatchRequests.first?.httpBody) + ) + XCTAssertEqual(command["type"]?.stringValue, "thread.meta.update") + } + + /// Set `T3_SWIFT_WS_DEFLATE_ECHO_URL` to a WebSocket endpoint that rejects + /// non-deflate handshakes and echoes binary frames. This is intentionally + /// opt-in because XCTest does not own a Node process. A successful round + /// trip proves URLSession accepted the server's compressed frame. + func testLivePerMessageDeflateRoundTripWhenConfigured() async throws { + guard let value = ProcessInfo.processInfo.environment[ + "T3_SWIFT_WS_DEFLATE_ECHO_URL" + ], let url = URL(string: value) else { + throw XCTSkip("Set T3_SWIFT_WS_DEFLATE_ECHO_URL for live compression proof.") + } + let connection = try await URLSessionWebSocketConnector().connect(to: url) + defer { Task { await connection.close() } } + let payload = Data(repeating: 0x54, count: 64 * 1024) + try await connection.send(payload) + let echoed = try await connection.receive() + XCTAssertEqual(echoed, payload) + } + + func testPairingInputParsesClipboardQRHostedAndLooseFormats() throws { + let direct = try PairingURL.parseFields( + " https://studio.example:3773/pair#token=N735%4BQXJ " + ) + XCTAssertEqual(direct.host, "https://studio.example:3773") + XCTAssertEqual(direct.pairingCode, "N735KQXJ") + + let hosted = try PairingURL.parseFields( + "https://app.t3.codes/pair?host=http%3A%2F%2F192.168.1.7%3A18773" + + "&label=Big%20O#token=PAIRING" + ) + XCTAssertEqual(hosted.host, "http://192.168.1.7:18773") + XCTAssertEqual(hosted.pairingCode, "PAIRING") + XCTAssertEqual(hosted.label, "Big O") + + let loose = try PairingURL.parseFields("192.168.1.7:18773 N735KQXJ5SJW") + XCTAssertEqual(loose.host, "https://192.168.1.7:18773") + XCTAssertEqual(loose.pairingCode, "N735KQXJ5SJW") + + let wrapped = try PairingURL.pairingURL( + fromQRCode: "t3code://pair?pairingUrl=https%3A%2F%2Fstudio.example" + + "%2Fpair%23token%3DQR-CODE" + ) + XCTAssertEqual(wrapped, "https://studio.example/pair#token=QR-CODE") + XCTAssertEqual(try PairingURL.parseFields(wrapped).pairingCode, "QR-CODE") + } + + func testSplitPairingFieldsAcceptCompleteURLInHostField() throws { + let target = try PairingURL.resolve( + host: "http://192.168.1.7:18773/pair#token=FROM-URL", + pairingCode: "" + ) + XCTAssertEqual(target.credential, "FROM-URL") + XCTAssertEqual(target.httpBaseURL.absoluteString, "http://192.168.1.7:18773/") + XCTAssertEqual(target.webSocketBaseURL.absoluteString, "ws://192.168.1.7:18773/") + } + + func testLocalNetworkProbeClassificationDistinguishesFailureModes() { + XCTAssertTrue(LocalNetworkProbe.isLocalHost("192.168.20.4")) + XCTAssertTrue(LocalNetworkProbe.isLocalHost("studio.local")) + XCTAssertFalse(LocalNetworkProbe.isLocalHost("app.t3.codes")) + + let denied = NSError( + domain: NSURLErrorDomain, + code: URLError.notConnectedToInternet.rawValue, + userInfo: [ + NSUnderlyingErrorKey: NSError( + domain: NSPOSIXErrorDomain, + code: 13 + ), + ] + ) + XCTAssertEqual( + LocalNetworkProbe.classify(denied, host: "192.168.20.4", isLocal: true), + .likelyLocalNetworkDenied("192.168.20.4") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.timedOut), + host: "studio.local", + isLocal: true + ), + .timeout("studio.local") + ) + XCTAssertEqual( + LocalNetworkProbe.classify( + URLError(.cannotConnectToHost), + host: "studio.local", + isLocal: true + ), + .unavailableHost("studio.local") + ) + } + + func testLocalNetworkProbeAcceptsWebSocketPairingSchemes() async throws { + let transport = RecordingHTTPTransport { request in + let body = """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {} + } + """ + return (Data(body.utf8), transportResponse(request)) + } + let result = try await LocalNetworkProbe(transport: transport).probe( + address: "wss://studio.example" + ) + + XCTAssertEqual(result.baseURL.absoluteString, "https://studio.example/") + let requests = await transport.requests + let request = try XCTUnwrap(requests.first) + XCTAssertEqual(request.url?.scheme, "https") + XCTAssertEqual(request.url?.path, "/.well-known/t3/environment") + } + +} + +private func paginationThreadFixture() -> OrchestrationThread { + OrchestrationThread( + id: "thread-1", + projectId: "project-1", + title: "Long native thread", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "main", + worktreePath: nil, + latestTurn: nil, + createdAt: "2026-08-06T12:00:00.000Z", + updatedAt: "2026-08-06T12:00:00.000Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: [], + activities: [], + checkpoints: [], + session: nil + ) +} + +private func transportResponse( + _ request: URLRequest, + status: Int = 200, + headers: [String: String] = ["Content-Type": "application/json"] +) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: headers + )! +} + +private actor RecordingHTTPTransport: HTTPTransport { + typealias Handler = @Sendable (URLRequest) throws -> (Data, HTTPURLResponse) + + private(set) var requests: [URLRequest] = [] + private let handler: Handler + + init(handler: @escaping Handler) { + self.handler = handler + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + return try handler(request) + } +} + +private func attachmentDescriptor(_ capabilities: String) throws -> EnvironmentDescriptor { + try JSONDecoder.t3.decode( + EnvironmentDescriptor.self, + from: Data( + """ + { + "environmentId": "environment-1", + "label": "Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": \(capabilities) + } + """.utf8 + ) + ) +} + +private struct StaticWebSocketConnector: WebSocketConnecting { + let connection: RecordingWebSocketConnection + + func connect(to url: URL) async throws -> any WebSocketConnection { + await connection.recordConnectionURL(url) + return connection + } +} + +private struct FailingWebSocketConnector: WebSocketConnecting { + func connect(to _: URL) async throws -> any WebSocketConnection { + throw URLError(.cannotConnectToHost) + } +} + +private actor RecordingWebSocketConnection: WebSocketConnection { + private let assetErrorMessage: String? + private var recordedRequests: [JSONValue] = [] + private var recordedConnectionURLs: [URL] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + init(assetErrorMessage: String? = nil) { + self.assetErrorMessage = assetErrorMessage + } + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + recordedRequests.append(request) + guard case let .number(rawID) = request["id"] else { return } + if request["tag"]?.stringValue == "assets.createUrl", let assetErrorMessage { + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(rawID), + "exit": .object([ + "_tag": .string("Failure"), + "cause": .array([.object([ + "_tag": .string("Fail"), + "error": .object(["message": .string(assetErrorMessage)]), + ])]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + return + } + let value: JSONValue + switch request["tag"]?.stringValue { + case "attachments.createUploadUrl": + value = .object([ + "attachmentId": .string("uploaded-attachment-1"), + "relativeUrl": .string("/api/attachments/upload/signed-token"), + "expiresAt": .number(1_785_466_800_000), + ]) + case "provider.uploadFeedback": + value = .object(["feedbackId": .string("codex-thread-1")]) + case "assets.createUrl": + value = .object([ + "relativeUrl": .string("/api/assets/attachment"), + "expiresAt": .number(1_785_466_800_000), + ]) + default: + value = .object(["sequence": .number(42)]) + } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(rawID), + "exit": .object([ + "_tag": .string("Success"), + "value": value, + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func requests() -> [JSONValue] { + recordedRequests + } + + func recordConnectionURL(_ url: URL) { + recordedConnectionURLs.append(url) + } + + func connectionURLs() -> [URL] { + recordedConnectionURLs + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift b/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift new file mode 100644 index 000000000000..2cc7a01cbe23 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/UsageContractTests.swift @@ -0,0 +1,67 @@ +import Foundation +import XCTest +@testable import T3Code + +final class UsageContractTests: XCTestCase { + func testUsageSummaryDecodesCurrentWireContract() throws { + let data = Data( + #""" + { + "contractVersion": 5, + "readAt": "2026-08-09T12:00:00.000Z", + "timeZone": "America/Los_Angeles", + "sinceDay": "2026-08-03", + "untilDay": "2026-08-09", + "buckets": [{ + "day": "2026-08-09", + "hourStart": "2026-08-09T12:00:00.000Z", + "provider": "grok", + "model": "grok-code-fast-1", + "totals": { + "uncachedInputTokens": 100, + "cachedInputTokens": 200, + "cacheCreationTokens": 30, + "outputTokens": 40, + "reasoningTokens": 10 + }, + "costUsd": 1.25, + "cacheSavingsUsd": 2.5, + "costSource": "modelPriced", + "records": 2, + "unpricedRecords": 0, + "sessions": 1 + }], + "sources": [{ + "fingerprint": { + "hostId": "mac-1", + "provider": "grok", + "resolvedHomePath": "/Users/theo/.grok", + "volumeId": "1:2" + }, + "status": "ok", + "scannedFiles": 3, + "skippedFiles": 0, + "malformedRecords": 0, + "distinctSessions": 1, + "message": null + }], + "pricing": { + "status": "fresh", + "source": "LiteLLM", + "fetchedAt": "2026-08-09T11:00:00.000Z", + "knownModels": 200 + }, + "scanDurationMs": 14 + } + """#.utf8 + ) + + let summary = try JSONDecoder.t3.decode(UsageSummary.self, from: data) + + XCTAssertEqual(summary.contractVersion, usageContractVersion) + XCTAssertEqual(summary.buckets.first?.provider, .grok) + XCTAssertEqual(summary.sources.first?.fingerprint.provider, .grok) + XCTAssertEqual(summary.buckets.first?.totals.cachedInputTokens, 200) + XCTAssertEqual(summary.sources.first?.fingerprint.volumeId, "1:2") + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift b/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift new file mode 100644 index 000000000000..8118eb3700b2 --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WebSocketRPCRaceTests.swift @@ -0,0 +1,1461 @@ +import XCTest +@testable import T3Code + +@MainActor +final class WebSocketRPCRaceTests: XCTestCase { + func testResponseDeadlineStartsAfterConnectionAndSend() async throws { + let connection = AutoReplyConnection() + let connector = GatedConnector(connection: connection) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(1), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.repliesAfterConnect", as: JSONValue.self) + } + await connector.waitUntilConnectStarted() + try await Task.sleep(for: .milliseconds(80)) + await connector.release() + + let response = try await request.value + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testSendFailureDropsDeadSocketAndReconnects() async throws { + let failed = SendFailingConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [failed, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + do { + _ = try await client.request("server.firstSendFails", as: JSONValue.self) + XCTFail("A failed socket send must fail its unary request.") + } catch let error as RPCError { + guard case .disconnected = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + await connector.waitUntilConnectionCount(2) + let discardedReceiveCount = await failed.receiveCallCount() + XCTAssertEqual( + discardedReceiveCount, + 0, + "Setup must not start receiving on a socket discarded by a queued send." + ) + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected) + let response = try await client.request("server.afterReconnect", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testUnansweredKeepaliveReconnectsAHalfOpenSocket() async throws { + let silent = BlockingReceiveConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [silent, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(1), + keepaliveInterval: .milliseconds(10), + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilConnectionCount(2) + + let response = try await client.request("server.afterKeepalive", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testValidInboundTrafficSatisfiesKeepaliveWithoutPong() async { + let connection = SubscriptionTrafficConnection(respondsToPingsWithChunks: true) + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + keepaliveInterval: .milliseconds(10), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connection.waitUntilPingCount(3) + + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected, "Valid stream traffic proves that the socket is still alive.") + _ = stream + await client.stop() + } + + func testSubscriptionOverflowReconnectsAndRestoresLiveEventsWithoutAcknowledgingDroppedEvents() async throws { + let overflowing = OverflowingSubscriptionConnection() + let recovered = SubscriptionTrafficConnection() + let connector = SequencedConnector(connections: [overflowing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + subscriptionBufferLimit: 2, + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connector.waitUntilConnectionCount(2) + await recovered.waitUntilSubscriptionStarted() + + var iterator = stream.makeAsyncIterator() + let firstValue = try await iterator.next() + let secondValue = try await iterator.next() + XCTAssertEqual(firstValue, .string("first")) + XCTAssertEqual(secondValue, .string("second")) + try await recovered.sendSubscriptionValue(.string("recovered")) + let recoveredValue = try await iterator.next() + XCTAssertEqual(recoveredValue, .string("recovered")) + + let acknowledgementCount = await overflowing.acknowledgementCount() + XCTAssertEqual(acknowledgementCount, 0) + + let response = try await client.request("server.afterOverflow", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + let recoveredRequestTags = await recovered.requestTags() + XCTAssertEqual(recoveredRequestTags, ["thread.events", "server.afterOverflow"]) + await client.stop() + } + + func testOneShotSubscriptionOverflowFailsWithoutReplayingItsCommand() async throws { + let overflowing = OverflowingSubscriptionConnection() + let recovered = SubscriptionTrafficConnection() + let connector = SequencedConnector(connections: [overflowing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + subscriptionBufferLimit: 2, + reconnectBackoff: { _ in .zero }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe( + "git.runAction", + reconnect: false, + as: JSONValue.self + ) + await connector.waitUntilConnectionCount(2) + + var iterator = stream.makeAsyncIterator() + let firstValue = try await iterator.next() + let secondValue = try await iterator.next() + XCTAssertEqual(firstValue, .string("first")) + XCTAssertEqual(secondValue, .string("second")) + do { + _ = try await iterator.next() + XCTFail("An overflowing command stream must finish with its protocol error.") + } catch let error as RPCError { + guard case .protocolViolation = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + let acknowledgementCount = await overflowing.acknowledgementCount() + XCTAssertEqual(acknowledgementCount, 0) + + let response = try await client.request("server.afterOneShotOverflow", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + let recoveredRequestTags = await recovered.requestTags() + XCTAssertEqual(recoveredRequestTags, ["server.afterOneShotOverflow"]) + await client.stop() + } + + func testTerminatedSubscriptionDoesNotDisconnectSharedSocket() async throws { + let connection = SubscriptionTrafficConnection(sendsInvalidSubscriptionValue: true) + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let stream = await client.subscribe("thread.events", as: Int.self) + var iterator = stream.makeAsyncIterator() + do { + _ = try await iterator.next() + XCTFail("An invalid stream event must terminate its subscription.") + } catch is DecodingError {} + + let wasInterrupted = await connection.waitUntilSubscriptionEnded() + XCTAssertTrue(wasInterrupted, "The subscription should end without closing the socket.") + + let response = try await client.request("server.afterSubscriptionFailure", as: JSONValue.self) + XCTAssertEqual(response, .object(["ok": .bool(true)])) + await client.stop() + } + + func testHungSendTimesOutAndReconnectsWithAFreshResponseWindow() async throws { + let hung = HungSendConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [hung, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let first = Task { + try await client.request("server.sendNeverReturns", as: JSONValue.self) + } + await hung.waitUntilSending() + do { + _ = try await first.value + XCTFail("A send that never completes must have an in-flight deadline.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + + await connector.waitUntilConnectionCount(2) + let response = try await client.request("server.afterHungSend", as: JSONValue.self) + XCTAssertEqual(response, .object([:])) + await client.stop() + } + + func testSentUnaryTimesOutAndLateTrafficCannotCompleteItTwice() async throws { + let connection = DeadlineWebSocketConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + connectionWaitTimeout: .seconds(2), + responseTimeout: .milliseconds(40), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let first = Task { + try await client.request("server.neverReplies", as: JSONValue.self) + } + await connection.waitUntilRequestCount(1) + + do { + _ = try await first.value + XCTFail("A sent unary request must have a response deadline.") + } catch let error as RPCError { + guard case .responseTimedOut = error else { + await client.stop() + return XCTFail("Unexpected RPC error: \(error)") + } + } + await connection.waitUntilInterruptCount(1) + + // A response for the expired request is harmless, and the same socket + // remains able to serve a subsequent unary call. + try await connection.replyToRequest(at: 0) + let second = try await client.request("server.replies", as: JSONValue.self) + XCTAssertEqual(second, .object(["ok": .bool(true)])) + await client.stop() + } + + func testCancellingUnaryRemovesItAndInterruptsSentWork() async throws { + let connection = DeadlineWebSocketConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + connectionWaitTimeout: .seconds(2), + responseTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.cancelled", as: JSONValue.self) + } + await connection.waitUntilRequestCount(1) + request.cancel() + + do { + _ = try await request.value + XCTFail("Cancelling the caller must cancel its unary continuation.") + } catch is CancellationError {} + await connection.waitUntilInterruptCount(1) + + try await connection.replyToRequest(at: 0) + let next = try await client.request("server.stillHealthy", as: JSONValue.self) + XCTAssertEqual(next, .object(["ok": .bool(true)])) + await client.stop() + } + + func testRequestEnteringAlreadyCancelledNeverInstallsOrSends() async throws { + let connection = AutoReplyConnection() + let gate = RequestCancellationGate() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + let request = Task { + await gate.wait() + return try await client.request("server.cancelledBeforeInstall", as: JSONValue.self) + } + await gate.waitUntilEntered() + request.cancel() + await gate.release() + + do { + _ = try await request.value + XCTFail("An already-cancelled request must fail before installation") + } catch is CancellationError {} + let sentRequestCount = await connection.sentRequestCount() + XCTAssertEqual(sentRequestCount, 0) + await client.stop() + } + + func testDisconnectWhileUnarySendIsSuspendedFailsWithoutReplay() async throws { + let first = SuspendedSendConnection() + let second = AutoReplyConnection() + let connector = SequencedConnector(connections: [first, second]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await first.waitUntilReceiving() + + let request = Task { + do { + let value = try await client.request( + "thread.rename", + as: JSONValue.self + ) + return Result.success(value) + } catch { + return Result.failure(error) + } + } + + await first.waitUntilSending() + await first.failReceive() + + let outcome = await request.value + guard case let .failure(error) = outcome, + case .disconnected = error as? RPCError + else { + await client.stop() + await first.releaseSend() + return XCTFail("An ambiguous unary send must fail as disconnected.") + } + + await connector.waitUntilConnectionCount(2) + let replayedRequestCount = await second.sentRequestCount() + XCTAssertEqual( + replayedRequestCount, + 0, + "A unary request that crossed a broken socket must not be replayed." + ) + + await client.stop() + await first.releaseSend() + } + + func testStopDuringConnectClosesTheLateSocketWithoutPublishingIt() async { + let lateConnection = CloseTrackingConnection() + let connector = GatedConnector(connection: lateConnection) + let client = WebSocketRPCClient( + connector: connector, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilConnectStarted() + await client.stop() + await connector.release() + await lateConnection.waitUntilClosed() + + let isConnected = await client.isConnected() + XCTAssertFalse(isConnected, "A socket returned after stop must never become active.") + let receiveCount = await lateConnection.receiveCallCount() + XCTAssertEqual(receiveCount, 0) + } + + func testConnectionLoopDoesNotRetainReleasedClient() async { + let connection = BlockingStopConnection() + let connector = SequencedConnector(connections: [connection]) + var client: WebSocketRPCClient? = WebSocketRPCClient( + connector: connector, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + weak var releasedClient: WebSocketRPCClient? + releasedClient = client + + await client?.start() + await connection.waitUntilReceiving() + client = nil + + XCTAssertNil(releasedClient, "The reconnect task must not own the RPC client.") + await connection.waitUntilCloseStarted() + await connection.releaseClose() + } + + func testRestartWhileOldSocketClosesKeepsTheNewConnection() async throws { + let closing = BlockingStopConnection() + let recovered = AutoReplyConnection() + let connector = SequencedConnector(connections: [closing, recovered]) + let client = WebSocketRPCClient( + connector: connector, + connectionWaitTimeout: .seconds(2), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await closing.waitUntilReceiving() + + let stop = Task { await client.stop() } + await closing.waitUntilCloseStarted() + let request = Task { + try await client.request("server.afterRestart", as: JSONValue.self) + } + await connector.waitUntilConnectionCount(2) + await closing.releaseClose() + await stop.value + + let response = try await request.value + XCTAssertEqual(response, .object([:])) + let isConnected = await client.isConnected() + XCTAssertTrue(isConnected, "Completing an old stop must not clear a newer socket.") + await client.stop() + } + + func testSubscriptionRoutesChunkBeforeSuspendedSendReturns() async throws { + let connection = SubscriptionSendRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connection.waitUntilReceiveCount(1) + let stream = await client.subscribe("thread.events", as: JSONValue.self) + var iterator = stream.makeAsyncIterator() + await connection.waitUntilRequestSuspended() + await connection.waitUntilReceiveCount(2) + await connection.releaseRequest() + + let value = try await iterator.next() + XCTAssertEqual(value, .object(["event": .string("ready")])) + await client.stop() + } + + func testCancellingSubscriptionWhileSendIsSuspendedInterruptsWithoutResurrection() async { + let connection = SubscriptionSendRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connection.waitUntilReceiveCount(1) + var stream: AsyncThrowingStream? = await client.subscribe( + "thread.events", + as: JSONValue.self + ) + let consumer = Task { + var iterator = stream!.makeAsyncIterator() + return try await iterator.next() + } + await connection.waitUntilRequestSuspended() + consumer.cancel() + stream = nil + _ = try? await consumer.value + + let observedInterrupt = await connection.observesInterrupt() + XCTAssertTrue(observedInterrupt) + await connection.releaseRequest() + await client.stop() + } + + func testConnectionSetupAndSubscribeRaceSendsOneWireRequest() async throws { + let connection = SetupSubscriptionRaceConnection() + let client = WebSocketRPCClient( + connector: SequencedConnector(connections: [connection]), + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + let request = Task { + try await client.request("server.setupBarrier", as: JSONValue.self) + } + await connection.waitUntilUnarySendSuspends() + + let stream = await client.subscribe("thread.events", as: JSONValue.self) + await connection.waitUntilSubscriptionSendSuspends() + await connection.releaseUnarySend() + _ = try await request.value + + let subscriptionRequestCount = await connection.subscriptionRequestCount() + XCTAssertEqual( + subscriptionRequestCount, + 1, + "Connection setup and subscribe() must not both own the same subscription send." + ) + _ = stream + await connection.releaseSubscriptionSend() + await client.stop() + } + + func testReconnectBackoffResetsOnlyAfterValidInboundTraffic() async { + let connector = BackoffSequenceConnector() + let recorder = BackoffRecorder() + let client = WebSocketRPCClient( + connector: connector, + reconnectBackoff: { failureCount in + recorder.record(failureCount) + return .zero + }, + endpointProvider: { URL(string: "wss://studio.example/ws")! } + ) + + await client.start() + await connector.waitUntilAttemptCount(4) + + XCTAssertEqual( + recorder.values, + [1, 2, 1], + "Merely opening a socket must not reset backoff; a decoded server frame should." + ) + await client.stop() + } +} + +private final class BackoffRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Int] = [] + + var values: [Int] { + lock.withLock { recorded } + } + + func record(_ value: Int) { + lock.withLock { recorded.append(value) } + } +} + +private actor BackoffSequenceConnector: WebSocketConnecting { + private let provenConnection = PongThenFailConnection() + private let finalConnection = BlockingReceiveConnection() + private var attemptCount = 0 + private var waiters: [(Int, CheckedContinuation)] = [] + + func connect(to _: URL) throws -> any WebSocketConnection { + attemptCount += 1 + let ready = waiters.filter { attemptCount >= $0.0 } + waiters.removeAll { attemptCount >= $0.0 } + ready.forEach { $0.1.resume() } + switch attemptCount { + case 1, 2: + throw URLError(.cannotConnectToHost) + case 3: + return provenConnection + default: + return finalConnection + } + } + + func waitUntilAttemptCount(_ count: Int) async { + guard attemptCount < count else { return } + await withCheckedContinuation { continuation in + waiters.append((count, continuation)) + } + } +} + +private actor PongThenFailConnection: WebSocketConnection { + private var sentPong = false + + func send(_: Data) {} + + func receive() throws -> Data { + guard !sentPong else { throw URLError(.networkConnectionLost) } + sentPong = true + return try JSONEncoder.t3.encode(JSONValue.object(["_tag": .string("Pong")])) + } + + func close() {} +} + +private actor BlockingReceiveConnection: WebSocketConnection { + private var continuation: CheckedContinuation? + + func send(_: Data) {} + + func receive() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + func close() { + continuation?.resume(throwing: CancellationError()) + continuation = nil + } +} + +private actor SubscriptionTrafficConnection: WebSocketConnection { + private let respondsToPingsWithChunks: Bool + private let sendsInvalidSubscriptionValue: Bool + private var subscriptionRequestID: Int? + private var sentRequestTags: [String] = [] + private var subscriptionStartWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var pingCount = 0 + private var pingWaiters: [(Int, CheckedContinuation)] = [] + private var subscriptionEnded: Bool? + private var subscriptionEndWaiters: [CheckedContinuation] = [] + + init( + respondsToPingsWithChunks: Bool = false, + sendsInvalidSubscriptionValue: Bool = false + ) { + self.respondsToPingsWithChunks = respondsToPingsWithChunks + self.sendsInvalidSubscriptionValue = sendsInvalidSubscriptionValue + } + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) else { return } + let requestTag = envelope["tag"]?.stringValue ?? "" + sentRequestTags.append(requestTag) + if requestTag == "thread.events" { + subscriptionRequestID = requestID + let waiters = subscriptionStartWaiters + subscriptionStartWaiters.removeAll() + waiters.forEach { $0.resume() } + if sendsInvalidSubscriptionValue { + enqueue(try chunk(requestID: requestID, value: .string("not an integer"))) + } + } else { + enqueue(try success(requestID: requestID)) + } + case "Ping": + pingCount += 1 + if respondsToPingsWithChunks, let subscriptionRequestID { + enqueue(try chunk(requestID: subscriptionRequestID, value: .string("alive"))) + } + let ready = pingWaiters.filter { pingCount >= $0.0 } + pingWaiters.removeAll { pingCount >= $0.0 } + ready.forEach { $0.1.resume() } + case "Interrupt": + finishSubscription(interrupted: true) + default: + break + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + finishSubscription(interrupted: false) + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilPingCount(_ count: Int) async { + guard pingCount < count else { return } + await withCheckedContinuation { continuation in + pingWaiters.append((count, continuation)) + } + } + + func waitUntilSubscriptionEnded() async -> Bool { + if let subscriptionEnded { return subscriptionEnded } + return await withCheckedContinuation { continuation in + subscriptionEndWaiters.append(continuation) + } + } + + func waitUntilSubscriptionStarted() async { + guard subscriptionRequestID == nil else { return } + await withCheckedContinuation { continuation in + subscriptionStartWaiters.append(continuation) + } + } + + func sendSubscriptionValue(_ value: JSONValue) throws { + guard let subscriptionRequestID else { return } + enqueue(try chunk(requestID: subscriptionRequestID, value: value)) + } + + func requestTags() -> [String] { + sentRequestTags + } + + private func finishSubscription(interrupted: Bool) { + guard subscriptionEnded == nil else { return } + subscriptionEnded = interrupted + let waiters = subscriptionEndWaiters + subscriptionEndWaiters.removeAll() + waiters.forEach { $0.resume(returning: interrupted) } + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func chunk(requestID: Int, value: JSONValue) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(Double(requestID)), + "values": .array([value]), + ]) + ) + } + + private func success(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["ok": .bool(true)]), + ]), + ]) + ) + } +} + +private actor OverflowingSubscriptionConnection: WebSocketConnection { + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var acknowledgements = 0 + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if envelope["_tag"]?.stringValue == "Ack" { + acknowledgements += 1 + return + } + guard envelope["_tag"]?.stringValue == "Request", + case let .number(requestID)? = envelope["id"] else { return } + let chunk = JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(requestID), + "values": .array([ + .string("first"), + .string("second"), + .string("overflow"), + ]), + ]) + let response = try JSONEncoder.t3.encode(chunk) + if let receiver { + self.receiver = nil + receiver.resume(returning: response) + } else { + queuedResponses.append(response) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func acknowledgementCount() -> Int { + acknowledgements + } +} + +private actor SetupSubscriptionRaceConnection: WebSocketConnection { + private var unaryRequestID: Int? + private var unarySendContinuation: CheckedContinuation? + private var unaryWaiters: [CheckedContinuation] = [] + private var subscriptionSends = 0 + private var subscriptionSendContinuation: CheckedContinuation? + private var subscriptionWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) async throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard envelope["_tag"]?.stringValue == "Request", + case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) + else { return } + + if envelope["tag"]?.stringValue == "server.setupBarrier" { + unaryRequestID = requestID + let waiters = unaryWaiters + unaryWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { unarySendContinuation = $0 } + enqueue(try success(requestID: requestID)) + } else { + subscriptionSends += 1 + let waiters = subscriptionWaiters + subscriptionWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { subscriptionSendContinuation = $0 } + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { return queuedResponses.removeFirst() } + return try await withCheckedThrowingContinuation { receiver = $0 } + } + + func close() { + unarySendContinuation?.resume() + unarySendContinuation = nil + subscriptionSendContinuation?.resume() + subscriptionSendContinuation = nil + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilUnarySendSuspends() async { + guard unaryRequestID == nil else { return } + await withCheckedContinuation { unaryWaiters.append($0) } + } + + func waitUntilSubscriptionSendSuspends() async { + guard subscriptionSends == 0 else { return } + await withCheckedContinuation { subscriptionWaiters.append($0) } + } + + func releaseUnarySend() { + unarySendContinuation?.resume() + unarySendContinuation = nil + } + + func releaseSubscriptionSend() { + subscriptionSendContinuation?.resume() + subscriptionSendContinuation = nil + } + + func subscriptionRequestCount() -> Int { subscriptionSends } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func success(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object([:]), + ]), + ]) + ) + } +} + +private actor RequestCancellationGate { + private var entered = false + private var released = false + private var releaseContinuation: CheckedContinuation? + private var entryWaiters: [CheckedContinuation] = [] + + func wait() async { + entered = true + let waiters = entryWaiters + entryWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor SubscriptionSendRaceConnection: WebSocketConnection { + private var requestID: Int? + private var requestContinuation: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + private var receiveContinuation: CheckedContinuation? + private var receiveCount = 0 + private var receiveWaiters: [(Int, CheckedContinuation)] = [] + private var queuedResponses: [Data] = [] + private var interruptCount = 0 + private var interruptWaiters: [CheckedContinuation] = [] + + func send(_ data: Data) async throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard requestID == nil, + case let .number(rawID)? = envelope["id"], + let id = Int(exactly: rawID) else { return } + requestID = id + enqueue(try chunk(requestID: id)) + let waiters = requestWaiters + requestWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { continuation in + requestContinuation = continuation + } + enqueue(try exit(requestID: id)) + case "Interrupt": + interruptCount += 1 + let waiters = interruptWaiters + interruptWaiters.removeAll() + waiters.forEach { $0.resume() } + default: + return + } + } + + func receive() async throws -> Data { + receiveCount += 1 + let ready = receiveWaiters.filter { receiveCount >= $0.0 } + receiveWaiters.removeAll { receiveCount >= $0.0 } + ready.forEach { $0.1.resume() } + if !queuedResponses.isEmpty { return queuedResponses.removeFirst() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + requestContinuation?.resume() + requestContinuation = nil + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilRequestSuspended() async { + guard requestID == nil else { return } + await withCheckedContinuation { continuation in + requestWaiters.append(continuation) + } + } + + func waitUntilReceiveCount(_ count: Int) async { + guard receiveCount < count else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append((count, continuation)) + } + } + + func releaseRequest() { + requestContinuation?.resume() + requestContinuation = nil + } + + func observesInterrupt() async -> Bool { + if interruptCount > 0 { return true } + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + await self.waitUntilInterrupt() + return true + } + group.addTask { + try? await Task.sleep(for: .milliseconds(500)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + } + + private func waitUntilInterrupt() async { + guard interruptCount == 0 else { return } + await withCheckedContinuation { continuation in + interruptWaiters.append(continuation) + } + } + + private func enqueue(_ data: Data) { + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func chunk(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(Double(requestID)), + "values": .array([.object(["event": .string("ready")])]), + ]) + ) + } + + private func exit(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .null, + ]), + ]) + ) + } +} + +private actor CloseTrackingConnection: WebSocketConnection { + private var closed = false + private var closeWaiters: [CheckedContinuation] = [] + private var receives = 0 + + func send(_: Data) {} + + func receive() throws -> Data { + receives += 1 + throw URLError(.cannotLoadFromNetwork) + } + + func close() { + closed = true + let waiters = closeWaiters + closeWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitUntilClosed() async { + guard !closed else { return } + await withCheckedContinuation { continuation in + closeWaiters.append(continuation) + } + } + + func receiveCallCount() -> Int { + receives + } +} + +private actor BlockingStopConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + private var receiveWaiters: [CheckedContinuation] = [] + private var closeStarted = false + private var closeReleased = false + private var closeStartWaiters: [CheckedContinuation] = [] + private var closeReleaseWaiters: [CheckedContinuation] = [] + + func send(_: Data) throws { + throw URLError(.networkConnectionLost) + } + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() async { + if !closeStarted { + closeStarted = true + let waiters = closeStartWaiters + closeStartWaiters.removeAll() + waiters.forEach { $0.resume() } + } + if !closeReleased { + await withCheckedContinuation { continuation in + closeReleaseWaiters.append(continuation) + } + } + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func waitUntilCloseStarted() async { + guard !closeStarted else { return } + await withCheckedContinuation { continuation in + closeStartWaiters.append(continuation) + } + } + + func releaseClose() { + closeReleased = true + let waiters = closeReleaseWaiters + closeReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor DeadlineWebSocketConnection: WebSocketConnection { + private var requestIDs: [Int] = [] + private var interruptIDs: [Int] = [] + private var requestWaiters: [(Int, CheckedContinuation)] = [] + private var interruptWaiters: [(Int, CheckedContinuation)] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) throws { + let envelope = try JSONDecoder.t3.decode(JSONValue.self, from: data) + switch envelope["_tag"]?.stringValue { + case "Request": + guard case let .number(rawID)? = envelope["id"], + let requestID = Int(exactly: rawID) else { + return + } + requestIDs.append(requestID) + resumeRequestWaiters() + if requestIDs.count > 1 { + enqueue(try response(requestID: requestID)) + } + case "Interrupt": + guard case let .number(rawID)? = envelope["requestId"], + let requestID = Int(exactly: rawID) else { + return + } + interruptIDs.append(requestID) + resumeInterruptWaiters() + default: + break + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilRequestCount(_ count: Int) async { + guard requestIDs.count < count else { return } + await withCheckedContinuation { continuation in + requestWaiters.append((count, continuation)) + } + } + + func waitUntilInterruptCount(_ count: Int) async { + guard interruptIDs.count < count else { return } + await withCheckedContinuation { continuation in + interruptWaiters.append((count, continuation)) + } + } + + func replyToRequest(at index: Int) throws { + enqueue(try response(requestID: requestIDs[index])) + } + + private func response(requestID: Int) throws -> Data { + try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(Double(requestID)), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["ok": .bool(true)]), + ]), + ]) + ) + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } + + private func resumeRequestWaiters() { + let ready = requestWaiters.filter { requestIDs.count >= $0.0 } + requestWaiters.removeAll { requestIDs.count >= $0.0 } + ready.forEach { $0.1.resume() } + } + + private func resumeInterruptWaiters() { + let ready = interruptWaiters.filter { interruptIDs.count >= $0.0 } + interruptWaiters.removeAll { interruptIDs.count >= $0.0 } + ready.forEach { $0.1.resume() } + } +} + +private actor SequencedConnector: WebSocketConnecting { + private let connections: [any WebSocketConnection] + private var nextIndex = 0 + private var countWaiters: [(Int, CheckedContinuation)] = [] + + init(connections: [any WebSocketConnection]) { + self.connections = connections + } + + func connect(to _: URL) throws -> any WebSocketConnection { + guard nextIndex < connections.count else { + throw URLError(.cannotConnectToHost) + } + let connection = connections[nextIndex] + nextIndex += 1 + let completed = countWaiters.filter { nextIndex >= $0.0 } + countWaiters.removeAll { nextIndex >= $0.0 } + completed.forEach { $0.1.resume() } + return connection + } + + func waitUntilConnectionCount(_ count: Int) async { + guard nextIndex < count else { return } + await withCheckedContinuation { continuation in + countWaiters.append((count, continuation)) + } + } +} + +private actor GatedConnector: WebSocketConnecting { + private let connection: any WebSocketConnection + private var releaseContinuation: CheckedContinuation? + private var connectWaiters: [CheckedContinuation] = [] + private var connectStarted = false + private var released = false + + init(connection: any WebSocketConnection) { + self.connection = connection + } + + func connect(to _: URL) async -> any WebSocketConnection { + connectStarted = true + let waiters = connectWaiters + connectWaiters.removeAll() + waiters.forEach { $0.resume() } + if !released { + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + return connection + } + + func waitUntilConnectStarted() async { + guard !connectStarted else { return } + await withCheckedContinuation { continuation in + connectWaiters.append(continuation) + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor SendFailingConnection: WebSocketConnection { + private var closed = false + private var receives = 0 + private var receiver: CheckedContinuation? + + func send(_: Data) throws { + throw URLError(.networkConnectionLost) + } + + func receive() async throws -> Data { + receives += 1 + if closed { throw URLError(.networkConnectionLost) } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + closed = true + receiver?.resume(throwing: URLError(.networkConnectionLost)) + receiver = nil + } + + func receiveCallCount() -> Int { + receives + } +} + +private actor HungSendConnection: WebSocketConnection { + private var closed = false + private var sendContinuation: CheckedContinuation? + private var sendWaiters: [CheckedContinuation] = [] + + func send(_: Data) async throws { + let waiters = sendWaiters + sendWaiters.removeAll() + waiters.forEach { $0.resume() } + try await withCheckedThrowingContinuation { continuation in + sendContinuation = continuation + } + } + + func receive() throws -> Data { + throw URLError(closed ? .networkConnectionLost : .cannotLoadFromNetwork) + } + + func close() { + closed = true + sendContinuation?.resume(throwing: URLError(.networkConnectionLost)) + sendContinuation = nil + } + + func waitUntilSending() async { + guard sendContinuation == nil else { return } + await withCheckedContinuation { continuation in + sendWaiters.append(continuation) + } + } +} + +private actor SuspendedSendConnection: WebSocketConnection { + private var sendContinuation: CheckedContinuation? + private var receiveContinuation: CheckedContinuation? + private var sendWaiters: [CheckedContinuation] = [] + private var receiveWaiters: [CheckedContinuation] = [] + + func send(_: Data) async throws { + let waiters = sendWaiters + sendWaiters.removeAll() + waiters.forEach { $0.resume() } + try await withCheckedThrowingContinuation { continuation in + sendContinuation = continuation + } + } + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func waitUntilSending() async { + guard sendContinuation == nil else { return } + await withCheckedContinuation { continuation in + sendWaiters.append(continuation) + } + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func failReceive() { + receiveContinuation?.resume(throwing: URLError(.networkConnectionLost)) + receiveContinuation = nil + } + + func releaseSend() { + sendContinuation?.resume() + sendContinuation = nil + } +} + +private actor AutoReplyConnection: WebSocketConnection { + private var sentRequests = 0 + private var queuedResponses: [Data] = [] + private var receiveContinuation: CheckedContinuation? + + func send(_ data: Data) throws { + sentRequests += 1 + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard case let .number(requestID) = request["id"] else { return } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": .object([:]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + func sentRequestCount() -> Int { + sentRequests + } + + private func enqueue(_ data: Data) { + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift new file mode 100644 index 000000000000..69383fd40cad --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WireFixtureContractTests.swift @@ -0,0 +1,164 @@ +import Foundation +import XCTest +@testable import T3Code + +final class WireFixtureContractTests: XCTestCase { + func testGeneratedContractFixturesDecodeInSwift() throws { + let shell = try decodeFixture( + "shell-snapshot", + as: OrchestrationShellSnapshot.self + ) + XCTAssertEqual(shell.snapshotSequence, 42) + XCTAssertEqual(shell.projects.map(\.id), ["project-fixture"]) + XCTAssertEqual(shell.threads.map(\.id), ["thread-fixture"]) + XCTAssertEqual(shell.threads.first?.modelSelection.instanceId, "codex") + + let detail = try decodeFixture( + "thread-detail-snapshot", + as: OrchestrationThreadDetailSnapshot.self + ) + XCTAssertEqual(detail.thread.messages.map(\.id), ["message-fixture"]) + XCTAssertEqual(detail.page?.beforeCursor, "fixture-cursor") + XCTAssertEqual(detail.page?.threadSequence, 40) + + let shellItem = try decodeFixture( + "shell-stream-snapshot", + as: ShellStreamItem.self + ) + guard case let .snapshot(streamShell) = shellItem else { + return XCTFail("Expected a shell snapshot stream item") + } + XCTAssertEqual(streamShell.snapshotSequence, shell.snapshotSequence) + + let threadItem = try decodeFixture( + "thread-stream-snapshot", + as: ThreadStreamItem.self + ) + guard case let .snapshot(streamDetail) = threadItem else { + return XCTFail("Expected a thread snapshot stream item") + } + XCTAssertEqual(streamDetail.thread.id, detail.thread.id) + } + + func testSnapshotsDropOnlyUnknownArrayElements() throws { + let known = try fixtureObject("shell-snapshot") + var payload = try XCTUnwrap(known as? [String: Any]) + payload["projects"] = [ + ["id": "future-project", "kind": "not-yet-supported"], + try XCTUnwrap((payload["projects"] as? [Any])?.first), + ] + payload["threads"] = [ + ["id": "future-thread", "runtimeMode": "future-mode"], + try XCTUnwrap((payload["threads"] as? [Any])?.first), + ] + + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + ) + + XCTAssertEqual(snapshot.projects.map(\.id), ["project-fixture"]) + XCTAssertEqual(snapshot.threads.map(\.id), ["thread-fixture"]) + } + + func testThreadSnapshotsPreserveDurablePullRequestLinks() throws { + var shell = try XCTUnwrap(try fixtureObject("shell-snapshot") as? [String: Any]) + var shellThread = try XCTUnwrap((shell["threads"] as? [[String: Any]])?.first) + let link: [String: Any] = [ + "projectId": "project-fixture", + "repository": "pingdotgg/t3code", + "number": 5178, + "url": "https://github.com/pingdotgg/t3code/pull/5178", + ] + shellThread["linkedPullRequest"] = link + shell["threads"] = [shellThread] + let snapshot = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, + from: JSONSerialization.data(withJSONObject: shell) + ) + XCTAssertEqual(snapshot.threads.first?.linkedPullRequest?.number, 5178) + + var detail = try XCTUnwrap(try fixtureObject("thread-detail-snapshot") as? [String: Any]) + var detailThread = try XCTUnwrap(detail["thread"] as? [String: Any]) + detailThread["linkedPullRequest"] = link + detail["thread"] = detailThread + let threadSnapshot = try JSONDecoder.t3.decode( + OrchestrationThreadDetailSnapshot.self, + from: JSONSerialization.data(withJSONObject: detail) + ) + XCTAssertEqual(threadSnapshot.thread.linkedPullRequest?.repository, "pingdotgg/t3code") + } + + func testReopenTimestampsRoundTripAndRemainOptionalForOlderServers() throws { + var shell = try decodeFixture("shell-snapshot", as: OrchestrationShellSnapshot.self) + var detail = try decodeFixture("thread-detail-snapshot", as: OrchestrationThreadDetailSnapshot.self) + XCTAssertNil(shell.threads.first?.unsettledAt) + XCTAssertNil(detail.thread.unsettledAt) + + let timestamp = "2026-08-27T12:00:00.000Z" + shell.threads[0].unsettledAt = timestamp + var thread = detail.thread + thread.unsettledAt = timestamp + detail = OrchestrationThreadDetailSnapshot( + snapshotSequence: detail.snapshotSequence, + thread: thread, + page: detail.page + ) + let decodedShell = try JSONDecoder.t3.decode( + OrchestrationShellSnapshot.self, from: JSONEncoder.t3.encode(shell) + ) + let decodedDetail = try JSONDecoder.t3.decode( + OrchestrationThreadDetailSnapshot.self, from: JSONEncoder.t3.encode(detail) + ) + XCTAssertEqual(decodedShell.threads.first?.unsettledAt, timestamp) + XCTAssertEqual(decodedDetail.thread.unsettledAt, timestamp) + } + + func testUnknownStreamItemsRequestRefreshWithoutEndingDecoding() throws { + let shell = try JSONDecoder.t3.decode( + ShellStreamItem.self, + from: Data(#"{"kind":"future-shell-delta","sequence":43}"#.utf8) + ) + guard case .refreshRequired = shell else { + return XCTFail("Expected an authoritative shell refresh") + } + + let malformedKnownShell = try JSONDecoder.t3.decode( + ShellStreamItem.self, + from: Data(#"{"kind":"thread-upserted","sequence":43,"thread":{"id":"future"}}"#.utf8) + ) + guard case .refreshRequired = malformedKnownShell else { + return XCTFail("Expected malformed known deltas to refresh") + } + + let thread = try JSONDecoder.t3.decode( + ThreadStreamItem.self, + from: Data(#"{"kind":"future-thread-delta","sequence":43}"#.utf8) + ) + guard case let .event(event) = thread else { + return XCTFail("Expected the detail reducer compatibility path") + } + XCTAssertEqual(event, .null) + } + + private func decodeFixture( + _ name: String, + as type: Value.Type + ) throws -> Value { + try JSONDecoder.t3.decode(type, from: fixtureData(name)) + } + + private func fixtureObject(_ name: String) throws -> Any { + try JSONSerialization.jsonObject(with: fixtureData(name)) + } + + private func fixtureData(_ name: String) throws -> Data { + let testsDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + return try Data( + contentsOf: testsDirectory + .appendingPathComponent("Fixtures/Wire/\(name).json") + ) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift new file mode 100644 index 000000000000..39cc9653818a --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/WorkspaceContractTests.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import T3Code + +@MainActor +final class WorkspaceContractTests: XCTestCase { + func testVCSStatusSnapshotDecodesTaggedEffectRPCShape() throws { + let data = Data( + """ + { + "_tag": "snapshot", + "local": { + "isRepo": true, + "sourceControlProvider": { + "kind": "github", + "name": "GitHub", + "baseUrl": "https://github.com" + }, + "hasPrimaryRemote": true, + "isDefaultRef": false, + "refName": "feat/swift", + "hasWorkingTreeChanges": true, + "workingTree": { + "files": [{"path":"Core/T3Client.swift","insertions":12,"deletions":2}], + "insertions": 12, + "deletions": 2 + } + }, + "remote": { + "hasUpstream": true, + "aheadCount": 1, + "behindCount": 0, + "aheadOfDefaultCount": 3, + "pr": null + } + } + """.utf8 + ) + + let event = try JSONDecoder.t3.decode(VCSStatusEvent.self, from: data) + guard case let .snapshot(local, remote) = event else { + return XCTFail("Expected snapshot") + } + XCTAssertEqual(local.refName, "feat/swift") + XCTAssertEqual(local.workingTree.files.first?.insertions, 12) + XCTAssertEqual(remote?.aheadCount, 1) + } + + func testTerminalAttachEventsDecodeSnapshotAndOutputShapes() throws { + let snapshotData = Data( + """ + { + "type": "snapshot", + "snapshot": { + "threadId": "thread-1", + "terminalId": "term-1", + "cwd": "/workspace", + "worktreePath": null, + "status": "running", + "pid": 42, + "history": "$ ", + "exitCode": null, + "exitSignal": null, + "label": "Shell", + "updatedAt": "2026-07-30T12:00:00.000Z", + "sequence": 4 + } + } + """.utf8 + ) + let outputData = Data( + """ + { + "type": "output", + "threadId": "thread-1", + "terminalId": "term-1", + "sequence": 5, + "data": "hello\\r\\n" + } + """.utf8 + ) + + let snapshot = try JSONDecoder.t3.decode(TerminalEvent.self, from: snapshotData) + let output = try JSONDecoder.t3.decode(TerminalEvent.self, from: outputData) + XCTAssertEqual(snapshot.snapshot?.pid, 42) + XCTAssertEqual(snapshot.snapshot?.sequence, 4) + XCTAssertEqual(output.data, "hello\r\n") + XCTAssertEqual(output.sequence, 5) + } + + func testReviewAndProjectFileResultsDecodeExactServerFields() throws { + let reviewData = Data( + """ + { + "cwd": "/workspace", + "generatedAt": "2026-07-30T12:00:00.000Z", + "sources": [{ + "id": "working-tree", + "kind": "working-tree", + "title": "Working tree", + "baseRef": null, + "headRef": null, + "diff": "diff --git a/file b/file", + "diffHash": "abc123", + "truncated": false + }] + } + """.utf8 + ) + let fileData = Data( + """ + { + "relativePath": "README.md", + "contents": "# T3", + "byteLength": 4, + "truncated": false + } + """.utf8 + ) + + let review = try JSONDecoder.t3.decode(ReviewDiffPreview.self, from: reviewData) + let file = try JSONDecoder.t3.decode(ProjectReadFileResult.self, from: fileData) + XCTAssertEqual(review.sources.first?.kind, "working-tree") + XCTAssertEqual(review.sources.first?.diffHash, "abc123") + XCTAssertEqual(file.relativePath, "README.md") + XCTAssertFalse(file.truncated) + } + + func testWorkspaceRPCMethodNamesMatchContractConstants() { + XCTAssertEqual(RPCMethod.projectsListEntries.rawValue, "projects.listEntries") + XCTAssertEqual(RPCMethod.vcsRefreshStatus.rawValue, "vcs.refreshStatus") + XCTAssertEqual(RPCMethod.reviewDiffPreview.rawValue, "review.getDiffPreview") + XCTAssertEqual( + RPCMethod.getArchivedShellSnapshot.rawValue, + "orchestration.getArchivedShellSnapshot" + ) + XCTAssertEqual(RPCMethod.terminalAttach.rawValue, "terminal.attach") + XCTAssertEqual(RPCMethod.subscribeTerminalEvents.rawValue, "subscribeTerminalEvents") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift b/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift new file mode 100644 index 000000000000..48d0b20151c8 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/AttachmentPreparationTests.swift @@ -0,0 +1,180 @@ +import Foundation +import Testing +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Attachment preparation") +struct AttachmentPreparationTests { + @Test + func providerLoaderReadsImageDataRepresentation() async throws { + let expected = Data([0x01, 0x02, 0x03]) + let provider = NSItemProvider( + item: expected as NSData, + typeIdentifier: UTType.jpeg.identifier + ) + + #expect(try await FeatureImageItemProviderLoader.data(from: provider) == expected) + } + + @Test + @MainActor + func providerLoadCanStartBeforeItsDataIsAwaited() async throws { + let expected = Data([0x01, 0x02, 0x03]) + let provider = NSItemProvider( + item: expected as NSData, + typeIdentifier: UTType.jpeg.identifier + ) + + let load = try FeatureImageItemProviderLoader.start(from: provider) + + #expect(try await load.data() == expected) + } + + @Test + func providerLoaderRejectsProvidersWithoutImageRepresentations() async { + await #expect(throws: FeatureImageAttachmentError.self) { + try await FeatureImageItemProviderLoader.data(from: NSItemProvider()) + } + } + + @Test + func overlappingPreparationOnlyFinishesAfterEveryOperation() { + var state = FeatureAttachmentPreparationState() + let firstID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let secondID = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! + let first = state.begin(itemCount: 2, id: firstID) + let second = state.begin(itemCount: 1, id: secondID) + + #expect(state.isPreparing) + #expect(state.pendingItemCount == 3) + #expect(state.statusLabel == "Preparing 3 attachments…") + + state.finish(first) + + #expect(state.isPreparing) + #expect(state.pendingItemCount == 1) + #expect(state.statusLabel == "Preparing attachment…") + + state.finish(second) + + #expect(!state.isPreparing) + #expect(state.pendingItemCount == 0) + } + + @Test + func textOnlySubmissionWaitsForSelectedImagePreparation() { + var state = FeatureAttachmentPreparationState() + let operation = state.begin(itemCount: 1) + + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "Explain this screenshot", + attachmentCount: 0, + imagesAllowed: true, + isSending: false, + preparationState: state + )) + + state.finish(operation) + + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "Explain this screenshot", + attachmentCount: 1, + imagesAllowed: true, + isSending: false, + preparationState: state + )) + } + + @Test + func attachmentSubmissionStillRequiresImageCapableModel() { + let state = FeatureAttachmentPreparationState() + + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "", + attachmentCount: 1, + imagesAllowed: false, + isSending: false, + preparationState: state + )) + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "Text still works", + attachmentCount: 0, + imagesAllowed: false, + isSending: false, + preparationState: state + )) + } + + @Test + func fileSubmissionDoesNotRequireImageCapableModel() { + #expect(FeatureComposerSubmissionEligibility.canSend( + text: "", + attachmentCount: 1, + imagesAllowed: false, + filesAllowed: true, + containsImages: false, + containsFiles: true, + isSending: false, + preparationState: FeatureAttachmentPreparationState() + )) + } + + @Test + func unsupportedFileBlocksSubmission() { + #expect(!FeatureComposerSubmissionEligibility.canSend( + text: "Describe this file", + attachmentCount: 1, + imagesAllowed: true, + filesAllowed: false, + containsImages: false, + containsFiles: true, + isSending: false, + preparationState: FeatureAttachmentPreparationState() + )) + } + + @Test + func completionIdentityRejectsChangedOwnerOrEnvironment() { + let generation = UUID() + let identity = FeatureAttachmentOperationIdentity( + ownerID: "thread:one", + environmentID: "local", + generation: generation + ) + + #expect(identity.matches( + ownerID: "thread:one", + environmentID: "local", + generation: generation + )) + #expect(!identity.matches( + ownerID: "thread:two", + environmentID: "local", + generation: generation + )) + #expect(!identity.matches( + ownerID: "thread:one", + environmentID: "remote", + generation: generation + )) + } + + @Test + func attachmentPickerKeepsExistingThreadComposerMountedWhenFocusResigns() { + #expect(!FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: true, + attachmentsAreEmpty: true, + isAttachmentFlowActive: true, + isPreparingAttachments: false + )) + + #expect(FeatureComposerCollapsePolicy.shouldCollapse( + isFocused: false, + textIsEmpty: true, + attachmentsAreEmpty: true, + isAttachmentFlowActive: false, + isPreparingAttachments: false + )) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift new file mode 100644 index 000000000000..09183cafb726 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ComposerDraftStoreTests.swift @@ -0,0 +1,442 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer draft persistence") +struct ComposerDraftStoreTests { + @Test func staleComposerSavePreservesUploadedReferenceForSameContent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:stale-save" + let attachment = FeatureDraftAttachment( + data: Data([1, 2, 3]), + filename: "same.png", + mimeType: "image/png" + ) + try await store.setDraft( + FeatureComposerDraft(text: "before", attachments: [attachment]), + for: key + ) + let reference = FeatureUploadedAttachmentReference( + environmentID: "test", + attachmentID: "uploaded" + ) + #expect(try await store.setUploadedReference( + reference, + attachment: attachment, + for: key + )) + + try await store.setDraft( + FeatureComposerDraft(text: "after", attachments: [attachment]), + for: key + ) + + let saved = try #require(await store.draft(for: key)) + #expect(saved.text == "after") + #expect(saved.attachments.first?.uploadedReference == reference) + } + + @Test func uploadedReferenceCompareAndSetDoesNotRestoreRemovedAttachment() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:removed" + let attachment = FeatureDraftAttachment( + data: Data([1]), + filename: "removed.png", + mimeType: "image/png" + ) + try await store.setDraft( + FeatureComposerDraft(text: "keep", attachments: [attachment]), + for: key + ) + try await store.setDraft(FeatureComposerDraft(text: "keep"), for: key) + + let didSave = try await store.setUploadedReference( + FeatureUploadedAttachmentReference( + environmentID: "test", + attachmentID: "late" + ), + attachment: attachment, + for: key + ) + + #expect(!didSave) + #expect(try await store.draft(for: key)?.attachments.isEmpty == true) + } + + @Test func roundTripsThreadTextImagesAndSelection() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore(fileURL: fileURL) + let attachment = FeatureDraftAttachment( + data: Data([0x01, 0x02, 0x03]), + thumbnailData: Data([0x04]), + filename: "reference.png", + mimeType: "image/png" + ) + let draft = FeatureComposerDraft( + text: "Keep this work", + attachments: [attachment], + selection: FeatureSelection(providerID: "openai", modelID: "gpt-5.6"), + workspace: FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: nil, + startFromOrigin: true + ) + ) + + try await store.setDraft(draft, for: "environment:test:thread:one") + + let reloaded = FeatureComposerDraftStore(fileURL: fileURL) + #expect(try await reloaded.draft(for: "environment:test:thread:one") == draft) + } + + @Test func fileBackedDraftRoundTripUsesTheCurrentStorageRoot() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let sourceURL = directory.appendingPathComponent("provider-notes.txt") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("notes".utf8).write(to: sourceURL) + let attachmentID = UUID() + let firstRoot = directory.appendingPathComponent("first-root", isDirectory: true) + let firstFiles = ManagedAttachmentFileStore(rootURL: firstRoot) + let ownedFile = try firstFiles.copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: "notes.txt" + ) + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore( + fileURL: fileURL, + attachmentStorageRootURL: firstRoot + ) + let reference = FeatureUploadedAttachmentReference( + environmentID: "environment-1", + attachmentID: "server-attachment-1" + ) + try await store.setDraft( + FeatureComposerDraft(attachments: [ + FeatureDraftAttachment( + id: attachmentID, + ownedFile: ownedFile, + filename: "notes.txt", + mimeType: "text/plain", + uploadedReference: reference + ), + ]), + for: "environment:test:thread:file" + ) + + let movedRoot = directory.appendingPathComponent("moved-root", isDirectory: true) + try FileManager.default.moveItem(at: firstRoot, to: movedRoot) + let restored = try await FeatureComposerDraftStore( + fileURL: fileURL, + attachmentStorageRootURL: movedRoot + ).draft(for: "environment:test:thread:file")?.attachments.first + + #expect(restored?.id == attachmentID) + #expect(restored?.ownedFile?.url.deletingLastPathComponent() == movedRoot) + #expect(restored?.byteCount == 5) + #expect(restored?.data.isEmpty == true) + #expect(restored?.uploadedReference == reference) + let json = try #require(String(data: Data(contentsOf: fileURL), encoding: .utf8)) + #expect(!json.contains(Data("notes".utf8).base64EncodedString())) + } + + @Test func ownedAttachmentPathsRejectTraversalAndUnknownNames() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let files = ManagedAttachmentFileStore(rootURL: root) + + #expect(throws: ManagedAttachmentFileError.invalidFileName) { + try files.resolvedFile(fileName: "../outside.txt", byteCount: 1) + } + #expect(throws: ManagedAttachmentFileError.invalidFileName) { + try files.removeOwnedFile(fileName: "not-a-uuid.txt") + } + } + + @Test func restoresImageAttachmentWrittenBeforeFileBacking() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("drafts.json") + let attachmentID = UUID() + try Data( + """ + { + "version": 2, + "drafts": { + "environment:test:thread:old": { + "text": "Old image", + "attachments": [{ + "id": "\(attachmentID.uuidString)", + "data": "AQID", + "filename": "old.png", + "mimeType": "image/png" + }] + } + } + } + """.utf8 + ).write(to: fileURL) + + let attachment = try await FeatureComposerDraftStore(fileURL: fileURL) + .draft(for: "environment:test:thread:old")?.attachments.first + + #expect(attachment?.id == attachmentID) + #expect(attachment?.data == Data([1, 2, 3])) + #expect(attachment?.ownedFile == nil) + } + + @Test func emptyDraftRemovesPersistedEntry() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("drafts.json") + let store = FeatureComposerDraftStore(fileURL: fileURL) + let key = "environment:test:thread:one" + + try await store.setDraft(FeatureComposerDraft(text: "hello"), for: key) + try await store.setDraft(FeatureComposerDraft(), for: key) + + #expect(try await store.draft(for: key) == nil) + } + + @Test func clearingDraftPreservesImportedShareIdempotency() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:test:thread:one" + + _ = try await store.importSharedContent( + shareID: "share-1", + text: "Imported once", + attachments: [], + for: key + ) + try await store.setDraft(FeatureComposerDraft(), for: key) + let replayed = try await store.importSharedContent( + shareID: "share-1", + text: "Imported once", + attachments: [], + for: key + ) + + #expect(replayed.isEmpty) + #expect(try await store.draft(for: key) == nil) + } + + @Test func environmentRemovalLeavesOtherDraftsAlone() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + try await store.setDraft( + FeatureComposerDraft(text: "remove"), + for: "environment:first:thread:one" + ) + try await store.setDraft( + FeatureComposerDraft(text: "keep"), + for: "environment:second:new-task:two" + ) + + try await store.removeDrafts(environmentID: "first") + + #expect(try await store.draft(for: "environment:first:thread:one") == nil) + #expect( + try await store.draft(for: "environment:second:new-task:two")?.text == "keep" + ) + } + + @Test func environmentRemovalClearsItsGroupedProjectDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let removedKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/removed" + ) + let preservedKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/preserved" + ) + try await store.setDraft(FeatureComposerDraft(text: "remove"), for: removedKey) + try await store.setDraft(FeatureComposerDraft(text: "keep"), for: preservedKey) + + try await store.removeDrafts( + environmentID: "first", + logicalProjectIDs: ["github.com/t3/removed"] + ) + + #expect(try await store.draft(for: removedKey) == nil) + #expect(try await store.draft(for: preservedKey)?.text == "keep") + } + + @Test func migratesResolvedVersionOneNewTaskDefaultsBackToImplicit() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let fileURL = directory.appendingPathComponent("drafts.json") + try Data( + """ + { + "version": 1, + "drafts": { + "environment:test:new-task:project": { + "text": "Keep the prompt", + "attachments": [], + "selection": { + "providerID": "codex", + "modelID": "gpt-old", + "options": [] + }, + "workspace": { + "mode": "local", + "startFromOrigin": true + } + } + } + } + """.utf8 + ).write(to: fileURL) + + let store = FeatureComposerDraftStore(fileURL: fileURL) + let migrated = try await store.draft( + for: "environment:test:new-task:project" + ) + + #expect(migrated?.text == "Keep the prompt") + #expect(migrated?.selection == nil) + #expect(migrated?.workspace == nil) + let persisted = try JSONSerialization.jsonObject( + with: Data(contentsOf: fileURL) + ) as? [String: Any] + #expect(persisted?["version"] as? Int == 2) + } + + @Test func restorationPreservesLiveEditsAndRestoresUntouchedFields() { + let baseline = FeatureComposerDraft( + selection: FeatureSelection(providerID: "openai", modelID: "gpt-default"), + workspace: FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: true + ) + ) + let liveAttachment = FeatureDraftAttachment( + data: Data([0x01]), + filename: "live.png", + mimeType: "image/png" + ) + let current = FeatureComposerDraft( + text: "Typed while loading", + attachments: [liveAttachment], + selection: baseline.selection, + workspace: FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + let saved = FeatureComposerDraft( + text: "Older text", + attachments: [], + selection: FeatureSelection(providerID: "anthropic", modelID: "claude-opus"), + workspace: FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: "/tmp/worktree", + startFromOrigin: true + ) + ) + + let merged = FeatureComposerDraftRestoration.merge( + saved: saved, + baseline: baseline, + current: current + ) + + #expect(merged.text == "Typed while loading") + #expect(merged.attachments == [liveAttachment]) + #expect(merged.selection == saved.selection) + #expect(merged.workspace?.mode == .worktree) + #expect(merged.workspace?.branch == "main") + #expect(merged.workspace?.worktreePath == "/tmp/worktree") + #expect(merged.workspace?.startFromOrigin == false) + } + + @Test func restorationUsesFallbacksWithoutOverwritingLiveChoices() { + let baseline = FeatureComposerDraft() + let liveSelection = FeatureSelection(providerID: "anthropic", modelID: "claude-sonnet") + let current = FeatureComposerDraft(selection: liveSelection) + let fallbackSelection = FeatureSelection(providerID: "openai", modelID: "gpt-default") + let fallbackWorkspace = FeatureComposerWorkspaceDraft( + mode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: true + ) + + let merged = FeatureComposerDraftRestoration.merge( + saved: nil, + baseline: baseline, + current: current, + fallbackSelection: fallbackSelection, + fallbackWorkspace: fallbackWorkspace + ) + + #expect(merged.selection == liveSelection) + #expect(merged.workspace == fallbackWorkspace) + } + + @Test func successfulSubmissionFenceWaitsForCancelledDraftWrites() async { + let started = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let events = AsyncStream.makeStream() + let pendingWrite = Task { + started.continuation.yield() + for await _ in release.stream { break } + events.continuation.yield("write finished") + } + var startedIterator = started.stream.makeAsyncIterator() + _ = await startedIterator.next() + + let fencedRemoval = Task { + await NewTaskDraftWriteFence.cancelAndWait(pendingWrite) + events.continuation.yield("draft removed") + } + release.continuation.yield() + await fencedRemoval.value + + var eventIterator = events.stream.makeAsyncIterator() + #expect(await eventIterator.next() == "write finished") + #expect(await eventIterator.next() == "draft removed") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift b/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift new file mode 100644 index 000000000000..d77cb632d8a4 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ComposerImageIntakeTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Composer image intake") +struct ComposerImageIntakeTests { + private static func plan( + providerCount: Int, + attachmentCount: Int = 0, + pendingCount: Int = 0 + ) -> FeatureComposerImageIntakePlan? { + FeatureComposerImageIntakePlan.forProviders( + providerCount: providerCount, + attachmentCount: attachmentCount, + pendingCount: pendingCount + ) + } + + @Test + func emptyComposerAcceptsEveryIncomingImage() throws { + let plan = try #require(Self.plan(providerCount: 3)) + + #expect(plan.acceptedCount == 3) + #expect(plan.firstOrdinal == 1) + #expect(plan.droppedCount == 0) + } + + @Test + func ordinalsContinueAfterExistingAndInFlightAttachments() throws { + let plan = try #require( + Self.plan(providerCount: 1, attachmentCount: 2, pendingCount: 1) + ) + + // Two attached plus one still preparing means this image is number four. + #expect(plan.firstOrdinal == 4) + } + + @Test + func intakeIsRefusedOnceTheAttachmentCapIsReached() { + #expect(Self.plan(providerCount: 1, attachmentCount: 8) == nil) + } + + @Test + func inFlightPreparationCountsAgainstTheCap() { + // Seven attached plus one preparing already fills the eight-image budget. + #expect(Self.plan(providerCount: 1, attachmentCount: 7, pendingCount: 1) == nil) + } + + @Test + func overshootIsTruncatedAndCounted() throws { + let plan = try #require(Self.plan(providerCount: 5, attachmentCount: 6)) + + #expect(plan.acceptedCount == 2) + #expect(plan.droppedCount == 3) + } + + @Test + func anEmptyBatchYieldsNoPlan() { + #expect(Self.plan(providerCount: 0) == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift new file mode 100644 index 000000000000..9ec0dfab8447 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ConnectionDetailsTests.swift @@ -0,0 +1,138 @@ +import Testing +@testable import T3Code + +@Suite("Connection details") +struct ConnectionDetailsTests { + @Test + func parsesRawPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "http://192.168.1.42:3773/pair#token=PAIRCODE" + ) + + #expect(details.endpoint == "http://192.168.1.42:3773") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test + func parsesHostedPairingURL() throws { + let details = try ConnectionDetailsParser.parse( + "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.tailnet.ts.net%2F#token=PAIRCODE" + ) + + #expect(details.endpoint == "https://desktop.tailnet.ts.net") + #expect(details.pairingCode == "PAIRCODE") + } + + @Test(arguments: [ + "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code:?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code-swiftui://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + "t3code-swiftui-dev://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token", + ]) + func unwrapsMobileQRCode(_ payload: String) throws { + let details = try ConnectionDetailsParser.parse(payload) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "pairing-token") + } + + @Test + func extractsPairingURLFromSurroundingText() throws { + let details = try ConnectionDetailsParser.parse( + "Pairing URL: http://10.0.0.8:18773/pair#token=ABC123\nOpen this on your phone." + ) + + #expect(details.endpoint == "http://10.0.0.8:18773") + #expect(details.pairingCode == "ABC123") + } + + @Test(arguments: [ + "token", + "pairing_token", + "pairingToken", + "pairing_code", + "pairingCode", + "code", + ]) + func acceptsEveryPairingCodeQueryAlias(_ alias: String) throws { + let details = try ConnectionDetailsParser.parse( + "https://remote.example.com/pair?\(alias)=ABC123" + ) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "ABC123") + } + + @Test + func removesPunctuationCopiedWithAProseLink() throws { + let details = try ConnectionDetailsParser.parse( + "Connect with (https://remote.example.com/pair?code=ABC123). Then return." + ) + + #expect(details.endpoint == "https://remote.example.com") + #expect(details.pairingCode == "ABC123") + } + + @Test + func keepsBalancedIPv6BracketsWhileTrimmingProse() throws { + let details = try ConnectionDetailsParser.parse( + "Use http://[fe80::1]:3773/pair?code=ABC123!" + ) + + #expect(details.endpoint == "http://[fe80::1]:3773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func splitsManualAddressAndCode() throws { + let details = try ConnectionDetailsParser.parse("192.168.20.2:3773 ABC123") + + #expect(details.endpoint == "http://192.168.20.2:3773") + #expect(details.pairingCode == "ABC123") + } + + @Test + func mapsCancellationToUsefulCopy() { + let message = ConnectionErrorCopy.message(for: "cancelled") + + #expect(!message.lowercased().contains("cancelled")) + #expect(message.contains("Make sure T3 Code is running")) + } +} + +@Suite("Local endpoint detection") +struct LocalEndpointDetectionTests { + @Test(arguments: [ + "localhost", + "studio.local", + "127.0.0.1", + "10.20.30.40", + "172.20.10.2", + "192.168.213.171", + "[::1]", + "::1", + "[fe80::aede:48ff:fe00:1122]:3773", + "fd12:3456:789a::1", + "fc00::1", + ]) + func recognizesLocalHosts(_ host: String) { + #expect(EndpointNetworkScope.isLocalHost(host)) + } + + @Test(arguments: [ + "8.8.8.8", + "172.32.0.1", + "example.com", + "2001:4860:4860::8888", + ]) + func rejectsPublicHosts(_ host: String) { + #expect(!EndpointNetworkScope.isLocalHost(host)) + } + + @Test + func bracketsBareIPv6DuringNormalization() throws { + let endpoint = try ConnectionDetailsParser.normalizedEndpoint("::1") + + #expect(endpoint == "http://[::1]") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift b/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift new file mode 100644 index 000000000000..d48e10dfe76b --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ConnectionHubPresentationTests.swift @@ -0,0 +1,281 @@ +import Testing +@testable import T3Code + +@Suite("Environment management presentation") +struct ConnectionHubPresentationTests { + @Test + func directSectionContainsOnlyDirectConnections() { + let direct = environment(id: "direct", source: .direct) + let managed = environment(id: "managed", source: .t3Connect) + + #expect( + ConnectionHubPresentation.directEnvironments(in: [managed, direct]) == [direct] + ) + } + + @Test + func directSectionShowsEnabledConnectionsBeforeDisabledConnections() { + let disabledFirst = environment( + id: "disabled-first", + source: .direct, + isEnabled: false + ) + let enabledFirst = environment(id: "enabled-first", source: .direct) + let disabledSecond = environment( + id: "disabled-second", + source: .direct, + isEnabled: false + ) + let enabledSecond = environment(id: "enabled-second", source: .direct) + + #expect( + ConnectionHubPresentation.directEnvironments( + in: [disabledFirst, enabledFirst, disabledSecond, enabledSecond] + ).map(\.id) == [ + "enabled-first", + "enabled-second", + "disabled-first", + "disabled-second", + ] + ) + } + + @Test + func t3ConnectSectionJoinsSavedAndAccountMachinesWithoutDuplicates() { + let saved = [ + environment( + id: "shared", + name: "Old saved label", + source: .t3Connect, + isEnabled: true, + connectionState: .connected + ), + environment(id: "saved-only", source: .t3Connect, isEnabled: false), + environment(id: "direct", source: .direct), + ] + let linked = [ + cloudEnvironment(id: "linked-only", name: "Travel Mac", isOnline: true), + cloudEnvironment(id: "shared", name: "Big O", isOnline: false), + ] + + let rows = ConnectionHubPresentation.t3ConnectEnvironments( + saved: saved, + linked: linked + ) + + #expect(rows.map(\.id) == ["linked-only", "shared", "saved-only"]) + #expect(rows[0].name == "Travel Mac") + #expect(!rows[0].isEnabled) + #expect(rows[0].isOnline) + #expect(rows[1].name == "Big O") + #expect(rows[1].isEnabled) + #expect(rows[1].isOnline) + #expect(rows[2].savedEnvironment?.id == "saved-only") + #expect(rows[2].linkedEnvironment == nil) + } + + @Test + func directlySavedMachineDoesNotHideItsT3ConnectEntry() { + let direct = environment(id: "same-machine", source: .direct) + let linked = cloudEnvironment(id: "same-machine", name: "Big O", isOnline: true) + + let directRows = ConnectionHubPresentation.directEnvironments(in: [direct]) + let managedRows = ConnectionHubPresentation.t3ConnectEnvironments( + saved: [direct], + linked: [linked] + ) + + #expect(directRows.map(\.id) == ["same-machine"]) + #expect(managedRows.map(\.id) == ["same-machine"]) + #expect(managedRows.first?.savedEnvironment == nil) + } + + @Test( + arguments: [ + (FeatureConnection.State.connected, ConnectionHubStatus.online), + (.connecting, .connecting), + (.reconnecting, .connecting), + (.disconnected, .offline), + ] + ) + func savedEnvironmentStatusMatchesConnectionState( + connectionState: FeatureConnection.State, + expectedStatus: ConnectionHubStatus + ) { + let saved = environment( + id: "direct", + source: .direct, + connectionState: connectionState + ) + + #expect(ConnectionHubPresentation.status(for: saved) == expectedStatus) + } + + @Test + func disabledEnvironmentDoesNotAppearOfflineOrOnline() { + let saved = environment( + id: "disabled", + source: .direct, + isEnabled: false, + connectionState: .connected + ) + + #expect(ConnectionHubPresentation.status(for: saved) == .disabled) + #expect(ConnectionHubPresentation.status(for: saved, pendingEnabled: true) == .connecting) + } + + @Test + func environmentWithoutAReachabilityProbeIsChecking() { + let saved = environment(id: "pending", source: .direct) + + #expect(ConnectionHubPresentation.status(for: saved) == .checking) + #expect(ConnectionHubPresentation.status(for: saved, pendingEnabled: false) == .disabled) + } + + @Test + func managedEnvironmentUsesSavedConnectionStateBeforeCloudAvailability() { + let linked = cloudEnvironment(id: "managed", name: "Studio", isOnline: true) + let disabled = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment( + id: "managed", + source: .t3Connect, + isEnabled: false, + connectionState: .connected + ) + ) + let offline = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment( + id: "managed", + source: .t3Connect, + connectionState: .disconnected + ) + ) + + #expect(disabled.status == .disabled) + #expect(offline.status == .offline) + #expect(!disabled.isOnline) + #expect(!offline.isOnline) + } + + @Test + func linkedEnvironmentShowsCloudStatusUntilItIsSaved() { + let online = T3ConnectEnvironmentPresentation( + linkedEnvironment: cloudEnvironment(id: "online", name: "Studio", isOnline: true), + savedEnvironment: nil + ) + let offline = T3ConnectEnvironmentPresentation( + linkedEnvironment: cloudEnvironment(id: "offline", name: "Studio", isOnline: false), + savedEnvironment: nil + ) + + #expect(online.status == .online) + #expect(offline.status == .offline) + #expect(online.connectionStatus(pendingEnabled: true) == .connecting) + #expect(offline.connectionStatus(isConnecting: true) == .connecting) + } + + @Test + func linkedEnvironmentSeparatesUnknownStatusFromFailedReachability() { + let linked = cloudEnvironment(id: "linked", name: "Studio", isOnline: true) + let unchecked = T3ConnectEnvironmentPresentation( + linkedEnvironment: T3ConnectCloudEnvironment(environment: linked.environment), + savedEnvironment: nil + ) + let failed = T3ConnectEnvironmentPresentation( + linkedEnvironment: T3ConnectCloudEnvironment( + environment: linked.environment, + statusError: "Connection failed" + ), + savedEnvironment: nil + ) + + #expect(unchecked.status == .checking) + #expect(failed.status == .offline) + } + + @Test + func duplicateMachineNamesShowOnlyTheirSanitizedHostAndPort() { + let names = ["leftbook", "LeftBook", "studio"] + + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://agent:secret@leftbook.tailnet.ts.net:8443/work?token=private#code", + for: "leftbook", + among: names + ) == "leftbook.tailnet.ts.net:8443" + ) + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://second.tailnet.ts.net/private?token=hidden", + for: "LeftBook", + among: names + ) == "second.tailnet.ts.net" + ) + #expect( + ConnectionHubPresentation.disambiguatingEndpoint( + "https://studio.example/", + for: "studio", + among: names + ) == nil + ) + } + + @Test + func managedEnvironmentUsesLinkedEndpointForDisambiguation() { + let linked = cloudEnvironment(id: "linked", name: "leftbook", isOnline: true) + let row = T3ConnectEnvironmentPresentation( + linkedEnvironment: linked, + savedEnvironment: environment(id: "saved", name: "leftbook", source: .t3Connect) + ) + + #expect(row.endpoint == "https://linked.example") + } + + private func environment( + id: String, + name: String? = nil, + source: FeatureEnvironment.Source, + isEnabled: Bool = true, + connectionState: FeatureConnection.State? = nil + ) -> FeatureEnvironment { + FeatureEnvironment( + id: id, + name: name ?? id, + endpoint: "https://\(id).example", + isEnabled: isEnabled, + source: source, + connectionState: connectionState + ) + } + + private func cloudEnvironment( + id: String, + name: String, + isOnline: Bool + ) -> T3ConnectCloudEnvironment { + let endpoint = T3ConnectManagedEndpoint( + httpBaseUrl: "https://\(id).example", + wsBaseUrl: "wss://\(id).example", + providerKind: .t3Relay + ) + return T3ConnectCloudEnvironment( + environment: T3ConnectRelayEnvironment( + environmentId: id, + label: name, + endpoint: endpoint, + linkedAt: "2026-08-14T00:00:00.000Z" + ), + status: T3ConnectRelayEnvironmentStatus( + environmentId: id, + endpoint: endpoint, + status: isOnline ? .online : .offline, + checkedAt: "2026-08-14T00:00:00.000Z", + descriptor: nil, + error: nil, + traceId: nil + ) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift new file mode 100644 index 000000000000..f562e99eddf3 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXModelPickerTests.swift @@ -0,0 +1,1192 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Model picker") +struct DailyUXModelPickerTests { + @Test + func providerBrandsResolveFromDriversAndFallbackIDs() { + #expect( + ProviderBrand.resolve(driver: "codex", providerID: "work-openai") == .openAI + ) + #expect( + ProviderBrand.resolve(driver: "claudeAgent", providerID: "work-claude") == .claude + ) + #expect(ProviderBrand.resolve(driver: "cursor", providerID: "cursor") == .cursor) + #expect(ProviderBrand.resolve(driver: "grok", providerID: "grok") == .grok) + #expect(ProviderBrand.resolve(driver: "opencode", providerID: "opencode") == .openCode) + #expect(ProviderBrand.resolve(driver: "", providerID: "claude") == .claude) + #expect(ProviderBrand.resolve(driver: "custom", providerID: "custom") == nil) + } + + @Test + func catalogPreservesFavoritesRecentsAndProviderGroups() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5", name: "GPT-5", supportsImages: true), + FeatureModel(id: "gpt-5-mini", name: "GPT-5 Mini"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "sonnet", name: "Sonnet", supportsReasoning: true)] + ), + ] + let favorite = DailyUXModelOption.key(providerID: "claude", modelID: "sonnet") + let recent = DailyUXModelOption.key(providerID: "codex", modelID: "gpt-5") + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [favorite], + recentIDs: [recent] + ) + + #expect(catalog.favorites.map(\.id) == [favorite]) + #expect(catalog.recents.map(\.id) == [recent]) + #expect(catalog.providerGroups.map(\.provider.id) == ["codex", "claude"]) + } + + @Test + func catalogDeduplicatesRepeatedProviderAndModelIDs() { + let repeated = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5.6-sol", name: "Sol"), + FeatureModel(id: "gpt-5.6-sol", name: "Sol again"), + ] + ) + let catalog = DailyUXModelCatalog( + providers: [repeated, repeated], + query: "", + favoriteIDs: [], + recentIDs: [] + ) + + #expect(catalog.all.map(\.id) == ["codex::gpt-5.6-sol"]) + #expect(catalog.providerGroups.map(\.provider.id) == ["codex"]) + #expect(catalog.providerGroups.first?.models.count == 1) + } + + @Test + func searchIncludesCapabilitiesAndProviderNames() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "vision", name: "Visual", supportsImages: true), + FeatureModel(id: "plain", name: "Plain"), + ] + ), + ] + + let catalog = DailyUXModelCatalog( + providers: providers, + query: "images", + favoriteIDs: [], + recentIDs: [] + ) + + #expect(catalog.all.map(\.model.id) == ["vision"]) + } + + @Test + func optionDefaultsUseTypedDescriptorDefaults() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + FeatureModelOptionDescriptor( + id: "effort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High", isDefault: true), + ] + ), + FeatureModelOptionDescriptor( + id: "fast", + label: "Fast mode", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ) + + let defaults = DailyUXModelOptions.defaults(for: model) + + #expect(defaults == [ + FeatureModelOptionSelection(id: "effort", value: .string("high")), + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + ]) + } + + @Test + func updatingAnOptionReplacesOnlyItsPreviousValue() { + let initial = [ + FeatureModelOptionSelection(id: "effort", value: .string("low")), + FeatureModelOptionSelection(id: "fast", value: .boolean(false)), + ] + + let updated = DailyUXModelOptions.updating( + initial, + id: "effort", + value: .string("high") + ) + + #expect(updated.first { $0.id == "effort" }?.value == .string("high")) + #expect(updated.first { $0.id == "fast" }?.value == .boolean(false)) + #expect(updated.count == 2) + } + + @Test + func optionSummaryUsesChoiceLabelsAndEnabledBooleans() { + let model = FeatureModel( + id: "gpt-5", + name: "GPT-5", + options: [ + .init( + id: "effort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + .init(id: "fast", label: "Fast", kind: .boolean), + ] + ) + + let summary = DailyUXModelOptions.summary( + for: model, + selections: [ + .init(id: "effort", value: .string("high")), + .init(id: "fast", value: .boolean(true)), + ] + ) + + #expect(summary == "High · Fast") + } + + @Test + func compactReasoningSummaryIgnoresOtherModelOptions() { + let model = FeatureModel( + id: "gpt-5", + name: "A model name long enough to truncate", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning", + kind: .select, + choices: [.init(id: "xhigh", label: "Extra high")] + ), + .init(id: "fast", label: "Fast mode", kind: .boolean), + ] + ) + + let summary = DailyUXModelOptions.reasoningSummary( + for: model, + selections: [ + .init(id: "reasoningEffort", value: .string("xhigh")), + .init(id: "fast", value: .boolean(true)), + ] + ) + + #expect(summary == "Extra high") + } + + @Test + func preferredSelectionFindsDefaultAcrossProvidersAndIncludesDefaults() { + let providers = [ + FeatureProvider( + id: "first", + name: "First", + models: [.init(id: "basic", name: "Basic")] + ), + FeatureProvider( + id: "second", + name: "Second", + models: [ + .init( + id: "preferred", + name: "Preferred", + isDefault: true, + options: [ + .init( + id: "fast", + label: "Fast", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ), + ] + ), + ] + + let selection = DailyUXModelOptions.preferredSelection(in: providers) + + #expect(selection?.providerID == "second") + #expect(selection?.modelID == "preferred") + #expect(selection?.options == [ + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + ]) + } + + @Test + func projectDefaultWinsBeforeAppAndCatalogDefaults() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "project", name: "Project"), + .init(id: "app", name: "App"), + .init(id: "catalog", name: "Catalog", isDefault: true), + ] + ), + ] + + let selection = DailyUXModelOptions.initialSelection( + projectDefault: .init(providerID: "codex", modelID: "project"), + appDefault: .init(providerID: "codex", modelID: "app"), + providers: providers + ) + + #expect(selection?.modelID == "project") + } + + @Test + func missingAndStaleSelectionsMaterializeTheConcretePreferredModel() { + let providers = [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init( + id: "claude-sonnet-4", + name: "Sonnet 4", + isDefault: true, + isLegacy: true + ), + .init(id: "claude-opus-5", name: "Opus 5"), + ] + ), + ] + + #expect( + ProviderModelSelectionResolver.materialized(nil, in: providers)?.modelID + == "claude-opus-5" + ) + #expect( + ProviderModelSelectionResolver.materialized( + .init(providerID: "claude", modelID: "removed"), + in: providers + )?.modelID == "claude-opus-5" + ) + } + + @Test + func selectionWaitsForTheProviderCatalogBeforeMaterializing() { + let saved = FeatureSelection( + providerID: "claude", + modelID: "claude-opus-5" + ) + + #expect(ProviderModelSelectionResolver.materialized(saved, in: []) == saved) + #expect(ProviderModelSelectionResolver.materialized(nil, in: []) == nil) + } + + @Test + func threadComposerInheritsClaudeWithoutMaterializingTheCodexDefault() { + let inherited = FeatureSelection(providerID: "claude", modelID: "claude-opus-5") + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol", isDefault: true)] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "claude-opus-5", name: "Opus 5")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: providers + )?.providerID == "claude" + ) + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + nil, + inherited: inherited, + providers: providers + ) == nil + ) + } + + @Test + func unlockedThreadRejectsCrossProviderOverrideAndAllowsSameProviderModel() { + let inherited = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let crossProvider = FeatureSelection(providerID: "claude", modelID: "claude-opus-5") + let sameProvider = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-terra") + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-sol", name: "Sol", isDefault: true), + .init(id: "gpt-5.6-terra", name: "Terra"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "claude-opus-5", name: "Opus 5")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + crossProvider, + inherited: inherited, + providers: providers + ) == nil + ) + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + sameProvider, + inherited: inherited, + providers: providers + )?.modelID == "gpt-5.6-terra" + ) + #expect( + ProviderModelDraftPolicy.validated( + crossProvider, + providers: providers, + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + sameProvider, + providers: providers, + inheriting: inherited, + allowsProviderChange: false + )?.modelID == "gpt-5.6-terra" + ) + } + + @Test + func lockedProviderRejectsAChangeToAnotherModel() { + let inherited = FeatureSelection(providerID: "claude", modelID: "opus") + let alternate = FeatureSelection(providerID: "claude", modelID: "sonnet") + let configured = FeatureSelection( + providerID: "claude", + modelID: "opus", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + let provider = FeatureProvider( + id: "claude", + name: "Claude", + requiresNewThreadForModelChange: true, + models: [ + .init( + id: "opus", + name: "Opus", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [.init(id: "high", label: "High")] + ), + ] + ), + .init(id: "sonnet", name: "Sonnet"), + ] + ) + + #expect( + ThreadComposerModelSelectionPolicy.explicitSelection( + alternate, + inherited: inherited, + providers: [provider] + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + alternate, + providers: [provider], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + #expect( + ProviderModelDraftPolicy.validated( + configured, + providers: [provider], + inheriting: inherited, + allowsProviderChange: false + ) == configured + ) + } + + @Test + func pickerShowsAllProvidersForNewTasksAndOnlyTheThreadProviderOtherwise() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "sol", name: "Sol")] + ), + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "opus", name: "Opus")] + ), + ] + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: nil, + allowsProviderChange: true + ).map(\.id) == ["codex", "claude"] + ) + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + providers, + inherited: .init(providerID: "codex", modelID: "sol"), + allowsProviderChange: false + ).map(\.id) == ["codex"] + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "claude", modelID: "opus"), + providers: providers, + inheriting: nil, + allowsProviderChange: true + )?.providerID == "claude" + ) + } + + @Test + func missingInheritedProviderDoesNotUseAnotherProviderCatalog() { + let inherited = FeatureSelection(providerID: "claude", modelID: "opus") + let codex = FeatureProvider( + id: "codex", + name: "Codex", + models: [.init(id: "sol", name: "Sol", isDefault: true)] + ) + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [codex], + inherited: inherited, + allowsProviderChange: false + ).isEmpty + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex", modelID: "sol"), + providers: [codex], + inheriting: nil, + allowsProviderChange: false + ) == nil + ) + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [codex], + inherited: nil, + allowsProviderChange: false + ).isEmpty + ) + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [codex] + ) == inherited + ) + } + + @Test + func threadCatalogUsesThreadEnvironmentWhenProjectIsMissing() { + let codex = FeatureProvider( + id: "codex-work", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let snapshot = FeatureSnapshot( + providersByEnvironment: ["remote": [codex]] + ) + let thread = FeatureThread( + id: "thread", + projectID: "missing-project", + environmentID: "remote", + title: "Task", + providerID: "codex-work", + providerName: "Codex", + modelID: "current" + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + + #expect(providers == [codex]) + } + + @Test + func threadCatalogIgnoresAStaleProjectEnvironment() { + let local = FeatureProvider( + id: "claude-local", + name: "Claude", + driver: "claudeAgent", + models: [.init(id: "sonnet", name: "Sonnet")] + ) + let remote = FeatureProvider( + id: "codex-remote", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let snapshot = FeatureSnapshot( + projects: [ + .init( + id: "project", + environmentID: "local", + name: "Stale project", + path: "/tmp/project" + ), + ], + providersByEnvironment: [ + "local": [local], + "remote": [remote], + ] + ) + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "remote", + title: "Task", + providerID: "codex-remote", + providerName: "Codex", + modelID: "current" + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + + #expect(providers == [remote]) + } + + @Test + func threadCatalogKeepsCustomModelMissingFromDiscovery() { + let savedOptions = [ + FeatureModelOptionSelection(id: "reasoningEffort", value: .string("high")), + ] + let discovered = FeatureProvider( + id: "codex-work", + name: "Codex", + driver: "codex", + models: [.init(id: "current", name: "Current")] + ) + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "remote", + title: "Task", + providerID: "codex-work", + providerName: "Codex", + modelID: "custom-model", + modelOptions: savedOptions + ) + let snapshot = FeatureSnapshot( + providersByEnvironment: ["remote": [discovered]] + ) + + let providers = ThreadComposerProviderCatalog.providers(for: thread, in: snapshot) + let inherited = FeatureSelection( + providerID: "codex-work", + modelID: "custom-model", + options: savedOptions + ) + + #expect(providers[0].models.map(\.id) == ["current", "custom-model"]) + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: providers + ) == inherited + ) + } + + @Test + func threadCatalogKeepsSavedSelectionWhenProviderIsUnavailable() { + let inherited = FeatureSelection( + providerID: "codex-work", + modelID: "custom-model", + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + let unavailable = FeatureProvider( + id: "codex-work", + name: "Codex", + isAvailable: false, + driver: "codex", + models: [.init(id: "custom-model", name: "Custom model")] + ) + + #expect( + ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [unavailable] + ) == inherited + ) + #expect( + ProviderModelDraftPolicy.validated( + inherited, + providers: [unavailable], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func threadCatalogLocksToExactProviderInstanceButAllowsItsModels() { + let inherited = FeatureSelection(providerID: "codex-work", modelID: "current") + let work = FeatureProvider( + id: "codex-work", + name: "Codex work", + driver: "codex", + models: [ + .init(id: "current", name: "Current"), + .init(id: "alternate", name: "Alternate"), + ] + ) + let personal = FeatureProvider( + id: "codex-personal", + name: "Codex personal", + driver: "codex", + models: [ + .init(id: "current", name: "Current"), + .init(id: "alternate", name: "Alternate"), + ] + ) + + #expect( + ThreadComposerModelSelectionPolicy.pickerProviders( + [work, personal], + inherited: inherited, + allowsProviderChange: false + ).map(\.id) == ["codex-work"] + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex-work", modelID: "alternate"), + providers: [work, personal], + inheriting: inherited, + allowsProviderChange: false + )?.modelID == "alternate" + ) + #expect( + ProviderModelDraftPolicy.validated( + .init(providerID: "codex-personal", modelID: "alternate"), + providers: [work, personal], + inheriting: inherited, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func configurationMaterializesDisplayedDefaultsWithoutOverwritingSelections() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "effort", + label: "Effort", + kind: .select, + choices: [ + .init(id: "high", label: "High", isDefault: true), + ] + ), + .init( + id: "fast", + label: "Fast", + kind: .boolean, + defaultValue: .boolean(true) + ), + ] + ) + let existing = [ + FeatureModelOptionSelection(id: "effort", value: .string("custom")), + ] + + #expect( + ProviderModelConfiguration.materializedOptions( + for: model, + preserving: existing + ) == [ + .init(id: "effort", value: .string("custom")), + .init(id: "fast", value: .boolean(true)), + ] + ) + } + + @Test + func concreteSelectionIncludesTheOptionDefaultsShownByThePicker() { + let providers = [ + FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "effort", + label: "Effort", + kind: .select, + choices: [ + .init(id: "high", label: "High", isDefault: true), + ] + ), + ] + ), + ] + ), + ] + + #expect( + ProviderModelSelectionResolver.materialized( + .init(providerID: "codex", modelID: "gpt-5.6-sol"), + in: providers + )?.options == [ + .init(id: "effort", value: .string("high")), + ] + ) + } + + @Test + func configurationSeedsFromTheEffectiveSavedThreadSelection() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + .init(id: "fast", label: "Fast", kind: .boolean), + ] + ) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [model]) + let inherited = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [ + .init(id: "reasoningEffort", value: .string("high")), + .init(id: "fast", value: .boolean(true)), + ] + ) + let effective = ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: nil, + inherited: inherited, + providers: [provider] + ) + + let configured = ProviderModelConfiguration.selection( + for: DailyUXModelOption(provider: provider, model: model), + preserving: effective + ) + + #expect(configured == inherited) + } + + @Test + func explicitDraftOptionsWinOverTheInheritedThreadOptions() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + ] + ) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [model]) + let inherited = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [.init(id: "reasoningEffort", value: .string("low"))] + ) + let explicit = FeatureSelection( + providerID: "codex", + modelID: model.id, + options: [.init(id: "reasoningEffort", value: .string("high"))] + ) + + let effective = ThreadComposerModelSelectionPolicy.resolvedSelection( + explicit: explicit, + inherited: inherited, + providers: [provider] + ) + + #expect(effective == explicit) + } + + @Test + func unsupportedValuesAndUndescribedOptionsRemainVisibleAndPreserved() { + let model = FeatureModel( + id: "gpt-5.6-sol", + name: "Sol", + options: [ + .init( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low"), + .init(id: "high", label: "High"), + ] + ), + ] + ) + let saved = [ + FeatureModelOptionSelection( + id: "reasoningEffort", + value: .string("environment-custom") + ), + FeatureModelOptionSelection(id: "providerFlag", value: .boolean(true)), + ] + let descriptor = DailyUXModelOptions.reasoningDescriptor(for: model) + + #expect(descriptor?.choices.map(\.id) == ["low", "high"]) + #expect( + descriptor.map { + DailyUXModelOptions.isSupportedValue(saved[0].value, for: $0) + } == false + ) + #expect( + DailyUXModelOptions.undescribedSelections( + for: model, + selections: saved + ).map(\.id) == ["providerFlag"] + ) + #expect( + DailyUXModelOptions.reasoningSummary(for: model, selections: saved) + == "environment-custom" + ) + } + + @Test + func changingReasoningPreservesNonreasoningAndUndescribedOptions() { + let selections = [ + FeatureModelOptionSelection(id: "reasoningEffort", value: .string("low")), + FeatureModelOptionSelection(id: "fast", value: .boolean(true)), + FeatureModelOptionSelection(id: "providerFlag", value: .string("keep")), + ] + + let updated = DailyUXModelOptions.updating( + selections, + id: "reasoningEffort", + value: .string("high") + ) + + #expect(updated.first { $0.id == "reasoningEffort" }?.value == .string("high")) + #expect(updated.first { $0.id == "fast" }?.value == .boolean(true)) + #expect(updated.first { $0.id == "providerFlag" }?.value == .string("keep")) + } + + @Test + func modelDraftCachePreservesOptionsWhenReturningToASelection() { + let reasoning = FeatureModelOptionDescriptor( + id: "reasoningEffort", + label: "Reasoning effort", + kind: .select, + choices: [ + .init(id: "low", label: "Low", isDefault: true), + .init(id: "high", label: "High"), + ] + ) + let modelA = FeatureModel(id: "model-a", name: "Model A", options: [reasoning]) + let modelB = FeatureModel(id: "model-b", name: "Model B", options: [reasoning]) + let provider = FeatureProvider(id: "codex", name: "Codex", models: [modelA, modelB]) + let savedA = FeatureSelection( + providerID: provider.id, + modelID: modelA.id, + options: [ + .init(id: reasoning.id, value: .string("high")), + .init(id: "providerFlag", value: .boolean(true)), + ] + ) + let optionA = DailyUXModelOption(provider: provider, model: modelA) + let optionB = DailyUXModelOption(provider: provider, model: modelB) + let selectedB = ProviderModelDraftPolicy.selection( + for: optionB, + cached: nil, + current: savedA, + committed: savedA + ) + + let returnedA = ProviderModelDraftPolicy.selection( + for: optionA, + cached: savedA, + current: selectedB, + committed: savedA + ) + + #expect(returnedA == savedA) + } + + @Test + func liveSelectionChangesAndUnavailableModelsInvalidateEditedDrafts() { + let modelA = FeatureModel(id: "model-a", name: "Model A") + let modelB = FeatureModel(id: "model-b", name: "Model B") + let provider = FeatureProvider( + id: "codex", + name: "Codex", + requiresNewThreadForModelChange: true, + models: [modelA, modelB] + ) + let selectionA = FeatureSelection(providerID: provider.id, modelID: modelA.id) + let selectionB = FeatureSelection(providerID: provider.id, modelID: modelB.id) + + #expect( + ProviderModelDraftPolicy.canKeepEditedDraft( + base: selectionA, + currentCommitted: selectionB, + draft: selectionA, + providers: [provider], + inheriting: nil, + allowsProviderChange: true + ) == false + ) + #expect( + ProviderModelDraftPolicy.canKeepEditedDraft( + base: selectionA, + currentCommitted: selectionA, + draft: selectionA, + providers: [], + inheriting: nil, + allowsProviderChange: true + ) == false + ) + #expect( + ProviderModelDraftPolicy.validated( + selectionB, + providers: [provider], + inheriting: selectionA, + allowsProviderChange: false + ) == nil + ) + } + + @Test + func duplicateCatalogEntriesCollapseAndImplicitModelsDisappear() { + let normalized = ProviderModelCatalogNormalizer.normalized([ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "environment-auto", name: "Automatic (recommended)"), + .init(id: "opus", name: "Opus"), + ] + ), + FeatureProvider( + id: "claude", + name: "Claude duplicate", + models: [ + .init(id: "opus", name: "Opus duplicate"), + .init(id: "sonnet", name: "Sonnet"), + ] + ), + ]) + + #expect(normalized.map(\.id) == ["claude"]) + #expect(normalized[0].name == "Claude") + #expect(normalized[0].models.map(\.id) == ["opus", "sonnet"]) + #expect(normalized[0].models.map(\.name) == ["Opus", "Sonnet"]) + } + + @Test + func duplicateProvidersMergeComposerCommandsAndSkills() { + let normalized = ProviderModelCatalogNormalizer.normalized([ + FeatureProvider( + id: "claude", + name: "Claude", + models: [.init(id: "opus", name: "Opus")], + slashCommands: [.init(name: "review")], + skills: [.init(name: "deploy")] + ), + FeatureProvider( + id: "claude", + name: "Claude", + driver: "claudeAgent", + models: [.init(id: "sonnet", name: "Sonnet")], + slashCommands: [.init(name: "review"), .init(name: "compact")], + skills: [.init(name: "deploy"), .init(name: "fix-ci")] + ), + ]) + + #expect(normalized[0].driver == "claudeAgent") + #expect(normalized[0].slashCommands?.map(\.name) == ["review", "compact"]) + #expect(normalized[0].skills?.map(\.name) == ["deploy", "fix-ci"]) + } + + @Test + func favoritesAndRecentsDoNotRepeatInProviderSections() { + let providers = [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "claude-opus-5", name: "Opus 5"), + .init(id: "claude-sonnet-5", name: "Sonnet 5"), + .init(id: "claude-fable-5", name: "Fable 5"), + .init(id: "claude-haiku-3", name: "Haiku 3", isLegacy: true), + ] + ), + ] + let opus = DailyUXModelOption.key( + providerID: "claude", + modelID: "claude-opus-5" + ) + let sonnet = DailyUXModelOption.key( + providerID: "claude", + modelID: "claude-sonnet-5" + ) + let catalog = DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [opus], + recentIDs: [sonnet] + ) + + let remaining = ProviderModelDisplaySections(catalog: catalog) + + #expect( + remaining.currentProviderGroups.flatMap(\.models).map(\.model.id) + == ["claude-fable-5"] + ) + #expect(remaining.legacy.map(\.model.id) == ["claude-haiku-3"]) + } + + @Test + func legacyFavoritesStayPromotedAndReturnToLegacyWhenUnfavorited() { + let provider = FeatureProvider( + id: "claude", + name: "Claude", + models: [ + .init(id: "current", name: "Current"), + .init(id: "older", name: "Older", isLegacy: true), + ] + ) + let favoriteID = DailyUXModelOption.key(providerID: provider.id, modelID: "older") + for query in ["", "Older"] { + let sections = ProviderModelDisplaySections(catalog: DailyUXModelCatalog( + providers: [provider], query: query, favoriteIDs: [favoriteID], recentIDs: [favoriteID] + )) + #expect(sections.favorites.map(\.model.id) == ["older"]) + #expect(sections.recents.isEmpty) + #expect(sections.legacy.isEmpty) + } + let unfavorited = ProviderModelDisplaySections(catalog: DailyUXModelCatalog( + providers: [provider], query: "", favoriteIDs: [], recentIDs: [favoriteID] + )) + #expect(unfavorited.favorites.isEmpty) + #expect(unfavorited.legacy.map(\.model.id) == ["older"]) + } + + @Test + func serverLegacyMetadataIsAuthoritative() { + let codex = FeatureProvider(id: "work-openai", name: "Codex", driver: "codex") + let claude = FeatureProvider( + id: "work-claude", + name: "Anthropic", + driver: "claudeAgent" + ) + + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5.6-codex-luna", name: "Luna", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt_5_6_terra", name: "GPT 5.6 Terra", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5-6-sol", name: "Sol", isLegacy: false), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-fable-5-202607", name: "Fable 5", isLegacy: false), + provider: claude + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-opus-5", name: "OPUS 5", isLegacy: false), + provider: claude + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-sonnet-5", name: "Sonnet v5", isLegacy: false), + provider: claude + ) + ) + #expect( + !ProviderModelFamilyClassifier.isCurrent( + .init(id: "claude-opus-4-1", name: "Opus 4.1", isLegacy: true), + provider: claude + ) + ) + #expect( + !ProviderModelFamilyClassifier.isCurrent( + .init(id: "gpt-5.5-codex-sol", name: "GPT 5.5 Sol", isLegacy: true), + provider: codex + ) + ) + #expect( + ProviderModelFamilyClassifier.isCurrent( + .init(id: "custom-opus-4", name: "Custom Opus 4"), + provider: claude + ) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift new file mode 100644 index 000000000000..0207218e615e --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXNewTaskTests.swift @@ -0,0 +1,1174 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Message-first task creation") +struct DailyUXNewTaskTests { + @Test + func recentProjectRankingDrivesTheDefaultAndKeepsUnusedProjectsOut() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let unused = rankedProject("unused", name: "Unused") + let value = rankedSnapshot( + projects: [unused, beta, alpha], + threads: [ + rankedThread("older", projectID: alpha.id, activity: 10), + rankedThread("newer", projectID: beta.id, activity: 20), + ] + ) + + let ranking = DailyUXCreationContext.recentProjects(in: value) + + #expect(ranking.map(\.project.id) == [beta.id, alpha.id]) + #expect( + DailyUXCreationContext.initialProject(in: value, requestedProjectID: nil)?.id + == ranking.first?.project.id + ) + } + + @Test + func recentProjectRankingIsStableForTiesAndIgnoresMissingProjects() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot( + projects: [alpha, beta], + threads: [ + rankedThread("z-thread", projectID: alpha.id, activity: 20), + rankedThread("missing", projectID: "missing", activity: 30), + rankedThread("a-thread", projectID: beta.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [beta.id, alpha.id] + ) + } + + @Test + func recentProjectRankingUsesActivityInsteadOfMetadataChanges() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot( + projects: [alpha, beta], + threads: [ + rankedThread( + "metadata-change", + projectID: alpha.id, + updatedAt: 100, + lastActivityAt: 10 + ), + rankedThread("actual-use", projectID: beta.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [beta.id, alpha.id] + ) + } + + @Test + func archivedAndSettledThreadsStillRepresentProjectUse() { + let archived = rankedProject("archived", name: "Archived") + let settled = rankedProject("settled", name: "Settled") + let value = rankedSnapshot( + projects: [archived, settled], + threads: [ + rankedThread( + "archived-thread", + projectID: archived.id, + activity: 30, + isArchived: true + ), + rankedThread( + "settled-thread", + projectID: settled.id, + activity: 20, + isSettled: true + ), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value) + .map(\.project.id) == [archived.id, settled.id] + ) + } + + @Test + func explicitProjectWinsAndNoActivityFallsBackAlphabetically() { + let zulu = rankedProject("zulu", name: "Zulu") + let alpha = rankedProject("alpha", name: "Alpha") + let withActivity = rankedSnapshot( + projects: [zulu, alpha], + threads: [rankedThread("recent", projectID: zulu.id, activity: 20)] + ) + let withoutActivity = rankedSnapshot(projects: [zulu, alpha], threads: []) + + #expect( + DailyUXCreationContext.initialProject( + in: withActivity, + requestedProjectID: alpha.id + )?.id == alpha.id + ) + #expect(DailyUXCreationContext.recentProjects(in: withoutActivity).isEmpty) + #expect( + DailyUXCreationContext.initialProject( + in: withoutActivity, + requestedProjectID: nil + )?.id == alpha.id + ) + } + + @Test + func recentProjectRankingExcludesDisabledEnvironments() { + let enabled = rankedProject("enabled", name: "Enabled", environmentID: "enabled-env") + let disabled = rankedProject("disabled", name: "Disabled", environmentID: "disabled-env") + let value = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "enabled-env", + name: "Enabled", + endpoint: "http://enabled", + isEnabled: true + ), + FeatureEnvironment( + id: "disabled-env", + name: "Disabled", + endpoint: "http://disabled", + isEnabled: false + ), + ], + projects: [enabled, disabled], + threads: [ + rankedThread("enabled-thread", projectID: enabled.id, activity: 10), + rankedThread("disabled-thread", projectID: disabled.id, activity: 20), + ] + ) + + #expect( + DailyUXCreationContext.recentProjects(in: value).map(\.project.id) == [enabled.id] + ) + } + + @Test + func recentProjectRankingDeduplicatesARepositoryAcrossEnvironments() { + let local = rankedProject( + "local", + name: "Project", + environmentID: "local-env", + repositoryKey: "github.com/example/project" + ) + let remote = rankedProject( + "remote", + name: "Project", + environmentID: "remote-env", + repositoryKey: "github.com/example/project" + ) + let value = rankedSnapshot( + environments: [ + FeatureEnvironment( + id: "local-env", + name: "Local", + endpoint: "http://local" + ), + FeatureEnvironment( + id: "remote-env", + name: "Remote", + endpoint: "http://remote" + ), + ], + projects: [local, remote], + threads: [ + rankedThread( + "local-thread", + projectID: local.id, + environmentID: "local-env", + activity: 10 + ), + rankedThread( + "remote-thread", + projectID: remote.id, + environmentID: "remote-env", + activity: 20 + ), + ] + ) + + let ranking = DailyUXCreationContext.recentProjects(in: value) + #expect(ranking.count == 1) + #expect(ranking.first?.project.id == remote.id) + } + + @Test + func recentProjectRankingKeepsTheExactWorktreeUsedByTheThread() { + let root = rankedProject( + "root", + name: "Project", + repositoryKey: "github.com/example/project" + ) + let worktree = rankedProject( + "worktree", + name: "Project", + repositoryKey: "github.com/example/project" + ) + let value = rankedSnapshot( + projects: [root, worktree], + threads: [rankedThread("recent", projectID: worktree.id, activity: 20)] + ) + + #expect(DailyUXCreationContext.recentProjects(in: value).first?.project.id == worktree.id) + #expect( + DailyUXCreationContext.initialProject( + in: value, + requestedProjectID: worktree.id + )?.id == worktree.id + ) + } + + @Test + func automaticProjectAdoptionWaitsForRestoreAndStopsAfterExplicitChoices() { + #expect( + DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + + for explicitChoice in 0..<3 { + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: explicitChoice == 0, + modelSelectionIsExplicit: explicitChoice == 1, + workspaceSelectionIsExplicit: explicitChoice == 2, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + } + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: false + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "recent", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: true, + draftRestoreIsComplete: true + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: "fallback", + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + #expect( + !DailyUXCreationContext.shouldAdoptAutomaticProject( + currentProjectID: "fallback", + nextRecentProjectID: nil, + isAwaitingRecentActivity: true, + projectSelectionIsExplicit: false, + modelSelectionIsExplicit: false, + workspaceSelectionIsExplicit: false, + hasDraftContent: false, + draftRestoreIsComplete: true + ) + ) + } + + @Test + func requestPreservesLegacyPermissionAndKeepsImageBytes() { + let image = FeatureDraftAttachment( + data: Data([1, 2, 3]), + filename: "Image 1.jpg", + mimeType: "image/jpeg" + ) + let request = NewTaskRequest( + projectID: "project", + prompt: " Build it \n", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5"), + runtimeMode: .approvalRequired, + interactionMode: .plan, + attachments: [image] + ) + + #expect(request.trimmedPrompt == "Build it") + #expect(request.runtimeMode == .approvalRequired) + #expect(request.interactionMode == .standard) + #expect(request.workspaceMode == .local) + #expect(request.branch == nil) + #expect(request.worktreePath == nil) + #expect(!request.startFromOrigin) + #expect(request.attachments.first?.byteCount == 3) + } + + @Test + func worktreeRequestKeepsBaseBranchAndDropsExistingCheckoutPath() { + let request = NewTaskRequest( + projectID: "project", + prompt: "Build it", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .worktree, + branch: " main ", + worktreePath: "/existing/worktree", + startFromOrigin: true + ) + + #expect(request.branch == "main") + #expect(request.worktreePath == nil) + #expect(request.startFromOrigin) + } + + @Test + func workspaceDefaultsPreferCurrentCheckoutAndLocalDefaultBase() throws { + let branches = [ + FeatureWorkspaceBranch(name: "origin/main", isRemote: true, isDefault: true), + FeatureWorkspaceBranch(name: "feature", isCurrent: true), + FeatureWorkspaceBranch(name: "main", isDefault: true), + ] + + #expect(NewTaskWorkspaceDefaults.localBranch(in: branches)?.name == "feature") + #expect(NewTaskWorkspaceDefaults.worktreeBase(in: branches)?.name == "main") + + let root = FeatureWorkspaceBranch( + name: "feature", + worktreePath: "/repo/./" + ) + #expect( + NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: root, + projectPath: "/repo" + ) == nil + ) + + let linked = FeatureWorkspaceBranch( + name: "linked", + worktreePath: "/worktrees/linked" + ) + #expect( + NewTaskWorkspaceDefaults.normalizedWorktreePath( + for: linked, + projectPath: "/repo" + ) == "/worktrees/linked" + ) + } + + @Test + func mobileModeChoicesOnlyExposeSupportedValues() { + #expect(FeatureRuntimeMode.allCases == [.automatic, .fullAccess]) + #expect(FeatureInteractionMode.allCases == [.standard]) + } + + @Test + func newTasksDefaultToFullAccessBuildMode() { + let request = NewTaskRequest( + projectID: "project", + prompt: "Build it", + selection: nil + ) + + #expect(request.runtimeMode == .fullAccess) + #expect(request.interactionMode == .standard) + } + + @Test + func projectDraftRestoreNeverOverwritesTypingMadeWhileLoading() { + let savedAttachment = FeatureDraftAttachment( + data: Data([0x01]), + filename: "saved.png", + mimeType: "image/png" + ) + let context = NewTaskDraftRestoreContext( + projectID: "second-project", + baseline: FeatureComposerDraft() + ) + + let merged = context.merging( + saved: FeatureComposerDraft( + text: "Old saved prompt", + attachments: [savedAttachment] + ), + current: FeatureComposerDraft(text: "Typed while loading") + ) + + #expect(context.projectID == "second-project") + #expect(merged.text == "Typed while loading") + #expect(merged.attachments == [savedAttachment]) + } + + @Test + func passiveProjectsExposeTheirFullEnvironmentModelCatalogAndDefault() throws { + let passiveDefault = FeatureSelection( + providerID: "claudeAgent", + modelID: "claude-opus-4-1" + ) + let activeProject = FeatureProject( + id: "active-project", + environmentID: "active", + name: "Active", + path: "/active" + ) + let passiveProject = FeatureProject( + id: "passive-project", + environmentID: "passive", + name: "Passive", + path: "/passive", + defaultSelection: passiveDefault + ) + let snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "active", + name: "Active", + endpoint: "https://active.example", + isActive: true, + connectionState: .connected + ), + .init( + id: "passive", + name: "Passive", + endpoint: "https://passive.example", + connectionState: .connected + ), + .init( + id: "offline", + name: "Offline", + endpoint: "https://offline.example", + connectionState: .disconnected + ), + ], + projects: [ + activeProject, + passiveProject, + .init( + id: "offline-project", + environmentID: "offline", + name: "Offline", + path: "/offline" + ), + ], + providers: [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "GPT-5.6")] + ), + ], + providersByEnvironment: [ + "passive": [ + .init( + id: "claudeAgent", + name: "Claude", + models: [ + .init(id: "claude-opus-4-1", name: "Opus"), + .init(id: "claude-sonnet-4", name: "Sonnet"), + ] + ), + ], + ], + preferencesByEnvironment: [ + "active": .init( + defaultWorkspaceMode: .local, + newWorktreesStartFromOrigin: true + ), + "passive": .init( + defaultWorkspaceMode: .worktree, + newWorktreesStartFromOrigin: false + ), + ] + ) + + #expect( + DailyUXCreationContext.projects(in: snapshot).map(\.id) + == ["active-project", "passive-project", "offline-project"] + ) + let passiveProviders = DailyUXCreationContext.providers( + for: passiveProject, + in: snapshot + ) + #expect(passiveProviders.map(\.id) == ["claudeAgent"]) + #expect( + passiveProviders.first?.models.map(\.id) + == ["claude-opus-4-1", "claude-sonnet-4"] + ) + #expect( + DailyUXCreationContext.initialSelection(for: passiveProject, in: snapshot) + == passiveDefault + ) + #expect( + DailyUXCreationContext.environmentPreferences( + for: passiveProject, + in: snapshot + ) == FeatureEnvironmentPreferences( + defaultWorkspaceMode: .worktree, + newWorktreesStartFromOrigin: false + ) + ) + } + + @Test + func explicitEmptyProviderCatalogDoesNotRestoreAStaleProjectDefault() { + let project = FeatureProject( + id: "remote-project", + environmentID: "remote", + name: "Remote", + path: "/remote", + defaultSelection: .init(providerID: "claude", modelID: "old-model") + ) + let snapshot = FeatureSnapshot( + environments: [ + .init( + id: "remote", + name: "Remote", + endpoint: "https://remote.example", + isActive: false, + connectionState: .connected + ), + ], + projects: [project], + providers: [], + providersByEnvironment: ["remote": []] + ) + + #expect(DailyUXCreationContext.providers(for: project, in: snapshot).isEmpty) + } + + @Test + func projectGroupsOnlyOfferComputersThatContainTheSelectedRepository() throws { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/example", + displayName: "Example" + ) + let studio = FeatureProject( + id: "example-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let laptop = FeatureProject( + id: "example-laptop", + environmentID: "laptop", + name: "example-copy", + path: "/Users/test/example", + repositoryIdentity: identity + ) + let unrelated = FeatureProject( + id: "other-laptop", + environmentID: "laptop", + name: "other", + path: "/Users/test/other", + repositoryIdentity: .init(canonicalKey: "github.com/t3/other") + ) + + let groups = DailyUXProjectGrouping.groups(projects: [studio, unrelated, laptop]) + let group = try #require( + DailyUXProjectGrouping.group(containing: studio.id, in: groups) + ) + + #expect(group.name == "Example") + #expect(Set(group.projects.map(\.environmentID)) == ["studio", "laptop"]) + #expect(group.project(in: "laptop")?.id == laptop.id) + #expect(!group.memberProjectIDs.contains(unrelated.id)) + #expect(DailyUXProjectGrouping.logicalProjectID(for: studio) == group.id) + } + + @Test + func projectSelectionResolvesAgainstCurrentGroups() throws { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/example", + displayName: "Example" + ) + let staleStudio = FeatureProject( + id: "stale-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let currentStudio = FeatureProject( + id: "current-studio", + environmentID: "studio", + name: "example", + path: "/code/example", + repositoryIdentity: identity + ) + let laptop = FeatureProject( + id: "current-laptop", + environmentID: "laptop", + name: "example", + path: "/Users/test/example", + repositoryIdentity: identity + ) + let staleGroup = try #require( + DailyUXProjectGrouping.groups(projects: [staleStudio]).first + ) + let currentGroups = DailyUXProjectGrouping.groups( + projects: [currentStudio, laptop] + ) + + #expect( + DailyUXProjectGrouping.selectionTarget( + groupID: staleGroup.id, + preferredEnvironmentID: laptop.environmentID, + in: currentGroups + )?.id == laptop.id + ) + #expect( + DailyUXProjectGrouping.selectionTarget( + groupID: "removed-project", + preferredEnvironmentID: nil, + in: currentGroups + ) == nil + ) + } + + @Test + func projectsWithoutRepositoryIdentityNeverGroupAcrossComputers() { + let studio = FeatureProject( + id: "studio", + environmentID: "studio", + name: "Same name", + path: "/code/project" + ) + let laptop = FeatureProject( + id: "laptop", + environmentID: "laptop", + name: "Same name", + path: "/code/project" + ) + + let groups = DailyUXProjectGrouping.groups(projects: [studio, laptop]) + + #expect(groups.count == 2) + #expect(groups.allSatisfy { $0.projects.count == 1 }) + } + + @Test + func projectGroupingUsesFreshestPhysicalRowAndNormalizesTrailingSlash() throws { + let identity = FeatureRepositoryIdentity(canonicalKey: "github.com/t3/example") + let stale = FeatureProject( + id: "stale", + environmentID: "studio", + name: "stale", + path: "/code/example/", + repositoryIdentity: nil, + updatedAt: "2026-01-01T00:00:00.000Z" + ) + let current = FeatureProject( + id: "current", + environmentID: "studio", + name: "current", + path: "/code/example", + repositoryIdentity: identity, + updatedAt: "2026-01-02T00:00:00.000Z" + ) + let remote = FeatureProject( + id: "remote", + environmentID: "remote", + name: "remote", + path: "/srv/example", + repositoryIdentity: identity + ) + + let groups = DailyUXProjectGrouping.groups(projects: [stale, current, remote]) + let group = try #require( + DailyUXProjectGrouping.group(containing: stale.id, in: groups) + ) + + #expect(group.projects.map(\.id) == ["remote", "current"]) + #expect(group.memberProjectIDs == ["stale", "current", "remote"]) + + let snapshot = rankedSnapshot( + projects: [stale, current, remote], + threads: [rankedThread("recent", projectID: stale.id, activity: 20)] + ) + #expect( + DailyUXCreationContext.recentProjects(in: snapshot).first?.project.id + == current.id + ) + #expect( + DailyUXCreationContext.initialProject( + in: snapshot, + requestedProjectID: stale.id + )?.id == current.id + ) + } + + @Test + func logicalProjectDraftKeyDoesNotChangeWithComputer() { + let projectKey = "github.com/t3/example" + + #expect( + FeatureComposerDraftStore.newTaskKey(logicalProjectID: projectKey) + == "logical-project:github.com/t3/example:new-task" + ) + } + + @Test + func projectGroupingHonorsRepositoryPathAndSeparateModes() { + let identity = FeatureRepositoryIdentity( + canonicalKey: "github.com/t3/mono", + rootPath: "/code/mono" + ) + let app = FeatureProject( + id: "app", + environmentID: "studio", + name: "app", + path: "/code/mono/apps/app", + repositoryIdentity: identity + ) + let docs = FeatureProject( + id: "docs", + environmentID: "studio", + name: "docs", + path: "/code/mono/apps/docs", + repositoryIdentity: identity + ) + + #expect(DailyUXProjectGrouping.groups(projects: [app, docs]).count == 1) + #expect( + DailyUXProjectGrouping.groups( + projects: [app, docs], + mode: .repositoryPath + ).count == 2 + ) + #expect( + DailyUXProjectGrouping.groups( + projects: [app, docs], + mode: .separate + ).count == 2 + ) + } + + @Test + func projectDefaultWinsAndExplicitModelCarriesAcrossCompatibleProjects() throws { + let appDefault = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let explicit = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-luna") + let project = FeatureProject( + id: "project", + environmentID: "studio", + name: "Project", + path: "/project", + defaultSelection: .init(providerID: "codex", modelID: "gpt-5.6-terra") + ) + let snapshot = FeatureSnapshot( + projects: [project], + providers: [ + .init( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "Luna"), + .init(id: "gpt-5.6-terra", name: "Terra"), + .init(id: "gpt-5.6-sol", name: "Sol"), + ] + ), + ], + providersByEnvironment: [ + "studio": [ + .init( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "Luna"), + .init(id: "gpt-5.6-terra", name: "Terra"), + .init(id: "gpt-5.6-sol", name: "Sol"), + ] + ), + ], + ], + settings: .init(defaultSelection: appDefault) + ) + + #expect( + DailyUXCreationContext.initialSelection(for: project, in: snapshot) + == FeatureSelection(providerID: "codex", modelID: "gpt-5.6-terra") + ) + #expect( + DailyUXCreationContext.selection( + carrying: explicit, + to: project, + in: snapshot + ) == explicit + ) + } + + @Test @MainActor + func imageProcessorDownsamplesUploadAndBuildsSmallThumbnail() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 2_400, height: 1_200)) + .image { context in + UIColor.systemPink.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2_400, height: 1_200)) + } + let sourceData = try #require(source.pngData()) + + let attachment = try FeatureImageProcessor.attachment( + from: sourceData, + ordinal: 1 + ) + let prepared = try #require(UIImage(data: attachment.data)) + let thumbnail = try #require( + attachment.thumbnailData.flatMap(UIImage.init(data:)) + ) + + #expect(max(prepared.size.width, prepared.size.height) <= 2_048) + #expect(max(thumbnail.size.width, thumbnail.size.height) <= 160) + #expect(attachment.mimeType == "image/jpeg") + } + + @Test + func projectPickerLeadsWithRecentGroupsAndKeepsTheRestAlphabetical() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let gamma = rankedProject("gamma", name: "Gamma") + let delta = rankedProject("delta", name: "Delta") + let epsilon = rankedProject("epsilon", name: "Epsilon") + let value = rankedSnapshot( + projects: [gamma, alpha, epsilon, delta, beta], + threads: [ + rankedThread("delta-thread", projectID: delta.id, activity: 40), + rankedThread("beta-thread", projectID: beta.id, activity: 30), + rankedThread("epsilon-thread", projectID: epsilon.id, activity: 20), + rankedThread("alpha-thread", projectID: alpha.id, activity: 10), + ] + ) + let groups = DailyUXCreationContext.projectGroups(in: value) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: DailyUXCreationContext.recentProjects(in: value).map(\.group.id) + ) + + #expect(groups.map(\.name) == ["Alpha", "Beta", "Delta", "Epsilon", "Gamma"]) + #expect(sections.recents.map(\.name) == ["Delta", "Beta", "Epsilon"]) + #expect(sections.others.map(\.name) == ["Alpha", "Gamma"]) + #expect( + Set(sections.recents.map(\.id)) + .isDisjoint(with: Set(sections.others.map(\.id))) + ) + #expect(sections.recents.count + sections.others.count == groups.count) + } + + @Test + func projectPickerKeepsTheAlphabeticalListWhenNoProjectHasBeenUsed() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot(projects: [beta, alpha], threads: []) + let groups = DailyUXCreationContext.projectGroups(in: value) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: DailyUXCreationContext.recentProjects(in: value).map(\.group.id) + ) + + #expect(sections.recents.isEmpty) + #expect(sections.others.map(\.name) == ["Alpha", "Beta"]) + } + + @Test + func projectPickerRecentSectionIgnoresRepeatsAndProjectsThatAreGone() throws { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let value = rankedSnapshot(projects: [alpha, beta], threads: []) + let groups = DailyUXCreationContext.projectGroups(in: value) + let alphaGroup = try #require( + DailyUXProjectGrouping.group(containing: alpha.id, in: groups) + ) + let betaGroup = try #require( + DailyUXProjectGrouping.group(containing: beta.id, in: groups) + ) + + let sections = DailyUXProjectPickerSections( + groups: groups, + recentGroupIDs: [betaGroup.id, "removed-project-group", betaGroup.id, alphaGroup.id] + ) + + #expect(sections.recents.map(\.name) == ["Beta", "Alpha"]) + #expect(sections.others.isEmpty) + } + + @Test + func modelPickerSearchMatchesNamesAcrossSpacesAndPunctuation() { + let codex = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + .init(id: "gpt-5.6-luna", name: "GPT 5.6 Luna"), + .init(id: "gpt-5.6-terra", name: "GPT 5.6 Terra"), + ] + ) + let openCode = FeatureProvider( + id: "opencode", + name: "OpenCode", + models: [.init(id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna")] + ) + + let matching = ProviderModelSearch.matching( + [codex, openCode], + query: "GPT 5.6 Luna" + ) + + #expect(matching.map(\.id) == ["codex", "opencode"]) + #expect(matching.flatMap { $0.models.map(\.id) } == [ + "gpt-5.6-luna", + "openai/gpt-5.6-luna", + ]) + } + + @Test + func modelPickerDisambiguatesModelsWithTheSameVisibleDetails() { + let provider = FeatureProvider( + id: "opencode", + name: "OpenCode", + models: [ + .init(id: "kilo/openai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "kilo"), + .init(id: "kilo/xai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "kilo"), + .init(id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ) + let sections = ProviderModelDisplaySections( + catalog: DailyUXModelCatalog( + providers: [provider], + query: "", + favoriteIDs: [], + recentIDs: [] + ) + ) + + #expect(sections.disambiguatedModelIDs == Set([ + "opencode::kilo/openai/gpt-5.6-luna", + "opencode::kilo/xai/gpt-5.6-luna", + ])) + } + + @Test + func modelPickerDisambiguatesProvidersWithTheSameVisibleName() { + let providers = [ + FeatureProvider( + id: "opencode-work", + name: "OpenCode", + models: [ + .init(id: "work/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ), + FeatureProvider( + id: "opencode-personal", + name: "OpenCode", + models: [ + .init(id: "personal/gpt-5.6-luna", name: "GPT-5.6 Luna", detail: "openai"), + ] + ), + ] + let sections = ProviderModelDisplaySections( + catalog: DailyUXModelCatalog( + providers: providers, + query: "", + favoriteIDs: [], + recentIDs: [] + ) + ) + + #expect(sections.disambiguatedModelIDs == Set([ + "opencode-work::work/gpt-5.6-luna", + "opencode-personal::personal/gpt-5.6-luna", + ])) + } + + @Test + func projectPickerSearchMatchesNamesPathsAndEnvironmentNames() { + let studio = FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio" + ) + let laptop = FeatureEnvironment( + id: "laptop", + name: "Travel Laptop", + endpoint: "http://laptop" + ) + let alpha = rankedProject("ios-app", name: "Alpha", environmentID: studio.id) + let beta = rankedProject("web-client", name: "Beta", environmentID: laptop.id) + let groups = DailyUXProjectGrouping.groups(projects: [beta, alpha]) + + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "ALPHA", + environments: [studio, laptop] + ).map(\.name) == ["Alpha"] + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "web-client", + environments: [studio, laptop] + ).map(\.name) == ["Beta"] + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: "travel", + environments: [studio, laptop] + ).map(\.name) == ["Beta"] + ) + } + + @Test + func projectPickerSearchTrimsQueriesAndPreservesGroupOrder() { + let alpha = rankedProject("alpha", name: "Alpha") + let beta = rankedProject("beta", name: "Beta") + let groups = DailyUXProjectGrouping.groups(projects: [beta, alpha]) + + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: " \n ", + environments: [] + ).map(\.id) == groups.map(\.id) + ) + #expect( + NewTaskProjectPickerSearch.matching( + groups, + query: " BET ", + environments: [] + ).map(\.name) == ["Beta"] + ) + } + + @Test + func projectPickerSearchFindsSecondaryEnvironmentsAndKeepsRecentSections() throws { + let studio = FeatureEnvironment( + id: "studio", + name: "Studio Mac", + endpoint: "http://studio" + ) + let laptop = FeatureEnvironment( + id: "laptop", + name: "Travel Laptop", + endpoint: "http://laptop" + ) + let sharedOnStudio = rankedProject( + "studio-project", + name: "Shared", + environmentID: studio.id, + repositoryKey: "github.com/example/shared" + ) + let sharedOnLaptop = rankedProject( + "laptop-project", + name: "Shared", + environmentID: laptop.id, + repositoryKey: "github.com/example/shared" + ) + let unrelated = rankedProject( + "other-project", + name: "Other", + environmentID: studio.id + ) + let groups = DailyUXProjectGrouping.groups( + projects: [unrelated, sharedOnStudio, sharedOnLaptop] + ) + let sharedGroup = try #require( + DailyUXProjectGrouping.group(containing: sharedOnStudio.id, in: groups) + ) + let unrelatedGroup = try #require( + DailyUXProjectGrouping.group(containing: unrelated.id, in: groups) + ) + + let filtered = NewTaskProjectPickerSearch.matching( + groups, + query: "travel", + environments: [studio, laptop] + ) + let sections = DailyUXProjectPickerSections( + groups: filtered, + recentGroupIDs: [unrelatedGroup.id, sharedGroup.id] + ) + + #expect(filtered.map(\.id) == [sharedGroup.id]) + #expect(sections.recents.map(\.id) == [sharedGroup.id]) + #expect(sections.others.isEmpty) + } + + private func rankedProject( + _ id: String, + name: String, + environmentID: String = "environment", + repositoryKey: String? = nil + ) -> FeatureProject { + FeatureProject( + id: id, + environmentID: environmentID, + name: name, + path: "/\(id)", + repositoryIdentity: repositoryKey.map { + FeatureRepositoryIdentity(canonicalKey: $0) + } + ) + } + + private func rankedThread( + _ id: String, + projectID: String, + environmentID: String? = nil, + activity: TimeInterval? = nil, + updatedAt: TimeInterval? = nil, + lastActivityAt: TimeInterval? = nil, + isArchived: Bool = false, + isSettled: Bool = false + ) -> FeatureThread { + let updatedAt = updatedAt ?? activity ?? 0 + return FeatureThread( + id: id, + projectID: projectID, + environmentID: environmentID, + title: id, + updatedAt: Date(timeIntervalSince1970: updatedAt), + isArchived: isArchived, + isSettled: isSettled, + lastActivityAt: lastActivityAt.map(Date.init(timeIntervalSince1970:)) + ) + } + + private func rankedSnapshot( + environments: [FeatureEnvironment] = [], + projects: [FeatureProject], + threads: [FeatureThread] + ) -> FeatureSnapshot { + FeatureSnapshot(environments: environments, projects: projects, threads: threads) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift new file mode 100644 index 000000000000..087c6a7f7ad6 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DailyUXSidebarTests.swift @@ -0,0 +1,595 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Sidebar v2") +struct DailyUXSidebarTests { + private let now = Date(timeIntervalSince1970: 2_000_000) + + @Test + func snoozePresetsUseUsefulLocalClockBoundaries() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T17:00:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: now, calendar: calendar) + + #expect(presets.map(\.id) == [.hour, .threeHours, .evening, .tomorrow, .nextWeek]) + #expect(presets[0].until == now.addingTimeInterval(3_600)) + #expect(presets[1].until == now.addingTimeInterval(10_800)) + #expect(calendar.component(.hour, from: presets[2].until) == 18) + #expect(calendar.component(.hour, from: presets[3].until) == 9) + #expect(calendar.component(.weekday, from: presets[4].until) == 2) + #expect(calendar.component(.hour, from: presets[4].until) == 9) + } + + @Test + func snoozePresetsHideEveningWhenItIsTooClose() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-19T00:30:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: now, calendar: calendar) + + #expect(!presets.map(\.id).contains(.evening)) + } + + @Test + func sundaySnoozePresetsHaveUniqueWakeDates() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let sunday = try #require( + ISO8601DateFormatter().date(from: "2026-08-23T19:00:00Z") + ) + + let presets = DailyUXSnoozePresets.resolve(now: sunday, calendar: calendar) + + #expect(presets.map(\.id).contains(.tomorrow)) + #expect(!presets.map(\.id).contains(.nextWeek)) + #expect(Set(presets.map(\.until)).count == presets.count) + } + + @Test + func activeOrderUsesCreationTimeAndDoesNotJumpWithActivity() { + let olderCreationRecentActivity = thread( + id: "old", + created: -500, + updated: -5, + state: .working + ) + let newerCreationOlderActivity = thread( + id: "new", + created: -100, + updated: -80, + state: .working + ) + + let index = makeIndex([olderCreationRecentActivity, newerCreationOlderActivity]) + + #expect(index.active.map(\.id) == ["new", "old"]) + } + + @Test + func reopenedThreadsReturnToTheTopWithoutReorderingOnOrdinaryActivity() { + var reopened = thread(id: "old", created: -1_000, updated: -5) + reopened.unsettledAt = now.addingTimeInterval(-10) + let newer = thread(id: "new", created: -100, updated: 0, state: .working) + + #expect(makeIndex([newer, reopened]).active.map(\.id) == ["old", "new"]) + + reopened.unsettledAt = now.addingTimeInterval(-2_000) + #expect(makeIndex([newer, reopened]).active.map(\.id) == ["new", "old"]) + } + + @Test + func settlementShelfUsesOnlyTheServerOverride() { + var explicitlySettled = thread( + id: "explicit", + created: -10, + updated: -10 + ) + explicitlySettled.settlementFacts = facts(override: .settled) + let resting = thread( + id: "resting", + created: -400_000, + updated: -300_000, + state: .idle + ) + let oldButWorking = thread( + id: "working", + created: -400_000, + updated: -300_000, + state: .working + ) + var settledButWorking = thread( + id: "settled-working", + created: -400_000, + updated: -300_000, + state: .working + ) + settledButWorking.settlementFacts = facts( + override: .settled, + sessionStatus: "running", + hasPendingApprovals: true + ) + let oldButWaiting = thread( + id: "waiting", + created: -400_000, + updated: -300_000, + state: .waitingForApproval + ) + + let index = makeIndex([ + explicitlySettled, + resting, + oldButWorking, + settledButWorking, + oldButWaiting, + ]) + + #expect(Set(index.settled.map(\.id)) == ["explicit", "settled-working"]) + #expect(Set(index.active.map(\.id)) == ["resting", "working", "waiting"]) + } + + @Test + func explicitActiveOverridePreventsAutoSettlement() { + var reopened = thread( + id: "reopened", + created: -400_000, + updated: -300_000, + state: .idle + ) + reopened.keepsActive = true + + let index = makeIndex([reopened]) + + #expect(index.active.map(\.id) == ["reopened"]) + #expect(index.settled.isEmpty) + } + + @Test + func serverSettlementIsShownDespiteStaleActivityFacts() { + let messageAt = now.addingTimeInterval(-30) + var queued = thread(id: "queued", created: -100, updated: -30, state: .queued) + queued.settlementFacts = facts( + override: .settled, + sessionStatus: "running", + hasPendingApprovals: true, + latestUserMessageAt: messageAt, + latestTurn: .init(requestedAt: now.addingTimeInterval(-90)) + ) + queued.isSettled = true + + #expect(queued.hasQueuedTurnStart(at: now)) + #expect(queued.isEffectivelySettled()) + #expect(!queued.canSettleNow(at: now)) + #expect(HomeThreadSwipeAction.trailingActions( + for: queued, + isArchived: false, + at: now + ).first == .reopen) + #expect(queued.queuedSettlementBoundary(after: now) == now.addingTimeInterval(90.001)) + } + + @Test + func mergedPullRequestsAndAgeCannotHideUnsettledThreads() { + let oldThread = thread(id: "old", created: -400_000, updated: -300_000) + let merged = HomeThreadPullRequestPresentation( + number: 42, + state: .merged, + updatedAt: now.addingTimeInterval(-400) + ) + let index = DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: [oldThread]), + query: "", + now: now, + pullRequestsByThreadID: [oldThread.id: merged] + ) + + #expect(!oldThread.isEffectivelySettled()) + #expect(index.active.map(\.id) == ["old"]) + #expect(index.settled.isEmpty) + #expect(DailyUXSidebarRefresh.nextBoundary(for: [oldThread], after: now) == nil) + } + + @Test + func settledAndSnoozedThreadsStayInTheirShelvesWhenPinned() { + var pinnedSettled = thread( + id: "pinned-settled", + created: -100, + updated: -400_000, + state: .idle + ) + pinnedSettled.settlementFacts = facts(override: .settled) + pinnedSettled.pinnedAt = now.addingTimeInterval(-20) + + var pinnedSnoozed = thread( + id: "pinned-snoozed", + created: -50, + updated: -10 + ) + pinnedSnoozed.pinnedAt = now.addingTimeInterval(-10) + pinnedSnoozed.snoozedUntil = now.addingTimeInterval(3_600) + + let index = makeIndex([pinnedSettled, pinnedSnoozed]) + + #expect(index.pinned.isEmpty) + #expect(index.snoozed.map(\.id) == ["pinned-snoozed"]) + #expect(index.active.isEmpty) + #expect(index.settled.map(\.id) == ["pinned-settled"]) + #expect(DailyUXSidebarRefresh.nextBoundary(for: [pinnedSettled], after: now) == nil) + } + + @Test + func pinActionsRequireCapabilitiesAndKeepPinsReversible() { + var legacyDescriptor = thread(id: "legacy", created: -20, updated: -10) + legacyDescriptor.supportsPinning = nil + #expect(!legacyDescriptor.canTogglePin) + + var explicitlyUnsupported = thread(id: "unsupported", created: -20, updated: -10) + explicitlyUnsupported.supportsPinning = false + #expect(!explicitlyUnsupported.canTogglePin) + + explicitlyUnsupported.pinnedAt = now + #expect(explicitlyUnsupported.canTogglePin) + } + + @Test + func lifecycleActionsHonorCapabilitiesAndKeepReverseActionsReachable() { + var capabilityThread = thread(id: "capabilities", created: -20, updated: -10) + capabilityThread.supportsSettlement = false + capabilityThread.supportsSnooze = false + #expect(!capabilityThread.canToggleSettlement) + #expect(!capabilityThread.canToggleSnooze) + + capabilityThread.isSettled = true + capabilityThread.snoozedUntil = now.addingTimeInterval(3_600) + #expect(capabilityThread.canToggleSettlement) + #expect(capabilityThread.canToggleSnooze) + + var legacy = thread(id: "legacy-capabilities", created: -20, updated: -10) + legacy.supportsSettlement = nil + legacy.supportsSnooze = nil + #expect(!legacy.canToggleSettlement) + #expect(!legacy.canToggleSnooze) + } + + @Test + func snoozedThreadsHaveAReachableReverseState() { + var snoozed = thread(id: "snoozed", created: -20, updated: -10) + snoozed.snoozedUntil = now.addingTimeInterval(3_600) + var archived = thread(id: "archived", created: -30, updated: -20) + archived.isArchived = true + let visible = thread(id: "visible", created: -10, updated: -5) + + let index = makeIndex([snoozed, archived, visible]) + + #expect(index.active.map(\.id) == ["visible"]) + #expect(index.snoozed.map(\.id) == ["snoozed"]) + #expect(index.settled.isEmpty) + } + + @Test + func snoozeExpiresAtTheClockBoundary() { + var thread = thread(id: "timed", created: -20, updated: -10) + thread.snoozedUntil = now.addingTimeInterval(30) + + #expect(makeIndex([thread]).snoozed.map(\.id) == ["timed"]) + let expired = DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: [thread]), + query: "", + now: now.addingTimeInterval(31) + ) + #expect(expired.active.map(\.id) == ["timed"]) + } + + @Test + func parentRefreshIgnoresWorkingTimersAndTargetsShelfBoundaries() { + var working = thread( + id: "working", + created: -20, + updated: -10, + state: .working + ) + working.workingStartedAt = now.addingTimeInterval(-90) + + #expect(DailyUXSidebarRefresh.nextBoundary(for: [working], after: now) == nil) + + var laterSnooze = thread(id: "later", created: -30, updated: -20) + laterSnooze.snoozedUntil = now.addingTimeInterval(600) + var earlierSnooze = thread(id: "earlier", created: -40, updated: -30) + earlierSnooze.snoozedUntil = now.addingTimeInterval(120) + + #expect( + DailyUXSidebarRefresh.nextBoundary( + for: [working, laterSnooze, earlierSnooze], + after: now + ) == earlierSnooze.snoozedUntil + ) + } + + @Test + func parentRefreshIncludesQueuedEligibilityBoundary() { + let messageAt = now.addingTimeInterval(-30) + var queued = thread(id: "queued", created: -100, updated: -30, state: .queued) + queued.settlementFacts = facts(latestUserMessageAt: messageAt) + + #expect( + DailyUXSidebarRefresh.nextBoundary(for: [queued], after: now) + == now.addingTimeInterval(90.001) + ) + } + + @Test + func onlyFailuresRaisedAfterSnoozingWakeTheThread() { + var acknowledged = thread( + id: "acknowledged", + created: -30, + updated: -10, + state: .failed + ) + acknowledged.snoozedUntil = now.addingTimeInterval(3_600) + acknowledged.snoozedAt = now.addingTimeInterval(-10) + acknowledged.attentionAt = now.addingTimeInterval(-20) + + var fresh = acknowledged + fresh = FeatureThread( + id: "fresh", + projectID: fresh.projectID, + title: fresh.title, + createdAt: fresh.createdAt, + updatedAt: fresh.updatedAt, + state: .failed, + lastActivityAt: fresh.lastActivityAt, + snoozedUntil: fresh.snoozedUntil, + snoozedAt: fresh.snoozedAt, + attentionAt: now.addingTimeInterval(-5) + ) + + let index = makeIndex([acknowledged, fresh]) + + #expect(index.snoozed.map(\.id) == ["acknowledged"]) + #expect(index.active.map(\.id) == ["fresh"]) + } + + @Test + func projectFilterAndSearchUseRepositoryContext() { + let projects = [ + FeatureProject(id: "p1", environmentID: "e", name: "Mobile", path: "/work/mobile"), + FeatureProject(id: "p2", environmentID: "e", name: "Server", path: "/work/server"), + ] + let mobile = thread(id: "mobile", projectID: "p1", title: "Polish picker", created: -10, updated: -5) + let server = thread(id: "server", projectID: "p2", title: "Compression", created: -20, updated: -5) + let snapshot = FeatureSnapshot(projects: projects, threads: [mobile, server]) + + let filtered = DailyUXSidebarIndex(snapshot: snapshot, query: "", projectID: "p1", now: now) + let searched = DailyUXSidebarIndex(snapshot: snapshot, query: "server", now: now) + + #expect(filtered.active.map(\.id) == ["mobile"]) + #expect(searched.searchResults.map(\.id) == ["server"]) + } + + @Test + func searchHandlesScopedClonesAndLegacyDuplicateProjectIDs() { + let localProjectID = FeatureScopedID.project( + environmentID: "local", + wireID: "project-shared" + ) + let remoteProjectID = FeatureScopedID.project( + environmentID: "remote", + wireID: "project-shared" + ) + let projects = [ + FeatureProject( + id: localProjectID, + wireID: "project-shared", + environmentID: "local", + name: "Mobile", + path: "/work/mobile" + ), + FeatureProject( + id: remoteProjectID, + wireID: "project-shared", + environmentID: "remote", + name: "Server", + path: "/work/server" + ), + ] + let local = thread( + id: "local-thread", + projectID: localProjectID, + title: "Polish", + created: -10, + updated: -5 + ) + let remote = thread( + id: "remote-thread", + projectID: remoteProjectID, + title: "Compression", + created: -20, + updated: -5 + ) + let scoped = FeatureSnapshot(projects: projects, threads: [local, remote]) + + #expect( + DailyUXSidebarIndex(snapshot: scoped, query: "server", now: now) + .searchResults.map(\.id) == ["remote-thread"] + ) + + let legacyDuplicates = FeatureSnapshot( + projects: projects.map { + FeatureProject( + id: "project-shared", + environmentID: $0.environmentID, + name: $0.name, + path: $0.path + ) + }, + threads: [ + thread( + id: "legacy", + projectID: "project-shared", + title: "Legacy", + created: -10, + updated: -5 + ), + ] + ) + #expect( + DailyUXSidebarIndex(snapshot: legacyDuplicates, query: "server", now: now) + .searchResults.map(\.id) == ["legacy"] + ) + } + + @Test + func attentionScopesRemainFocusedSubsetsOfActive() { + let approval = thread( + id: "approval", + title: "Approve schema", + created: -10, + updated: -5, + state: .waitingForApproval + ) + let input = thread( + id: "input", + title: "Answer migration question", + created: -20, + updated: -5, + state: .waitingForInput + ) + let failed = thread( + id: "failed", + title: "Failed build", + created: -30, + updated: -5, + state: .failed + ) + let working = thread( + id: "working", + title: "Build application", + created: -40, + updated: -5, + state: .working + ) + + let snapshot = FeatureSnapshot(threads: [approval, input, failed, working]) + let index = DailyUXSidebarIndex(snapshot: snapshot, query: "", now: now) + + #expect(index.active.map(\.id) == ["approval", "input", "failed", "working"]) + #expect(index.needsInput.map(\.id) == ["approval", "input"]) + #expect(index.failed.map(\.id) == ["failed"]) + #expect( + DailyUXSidebarIndex.matchingThreads( + index.failed, + snapshot: snapshot, + query: "build" + ).map(\.id) == ["failed"] + ) + #expect( + DailyUXSidebarIndex.matchingThreads( + index.needsInput, + snapshot: snapshot, + query: "build" + ).isEmpty + ) + } + + @Test + func largeWorkingCollectionKeepsStableOrderWithoutParentTimerRefresh() { + let threads = (0..<5_000).map { offset in + thread( + id: "thread-\(offset)", + created: -Double(offset), + updated: -Double(offset), + state: .working + ) + } + + let index = makeIndex(threads) + + #expect(index.active.count == threads.count) + #expect(index.active.prefix(3).map(\.id) == ["thread-0", "thread-1", "thread-2"]) + #expect(index.active.last?.id == "thread-4999") + #expect(DailyUXSidebarRefresh.nextBoundary(for: threads, after: now) == nil) + } + + @Test + func compactRelativeAgeClampsFutureDatesAndUsesStableUnits() { + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(5), + now: now + ) == "now" + ) + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(-125), + now: now + ) == "2m" + ) + #expect( + SidebarRelativeAge.compact( + since: now.addingTimeInterval(-7_300), + now: now + ) == "2h" + ) + #expect( + SidebarRelativeAge.accessibility( + since: now.addingTimeInterval(-3_600), + now: now + ) == "Updated 1 hour ago" + ) + } + + private func makeIndex(_ threads: [FeatureThread]) -> DailyUXSidebarIndex { + DailyUXSidebarIndex( + snapshot: FeatureSnapshot(threads: threads), + query: "", + now: now + ) + } + + private func thread( + id: String, + projectID: String = "project", + title: String = "Task", + created: TimeInterval, + updated: TimeInterval, + state: FeatureThreadState = .idle + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: projectID, + title: title, + createdAt: now.addingTimeInterval(created), + updatedAt: now.addingTimeInterval(updated), + state: state, + lastActivityAt: now.addingTimeInterval(updated), + supportsSettlement: true, + supportsSnooze: true, + supportsPinning: true + ) + } + + private func facts( + override: FeatureThreadSettlementOverride? = nil, + sessionStatus: String? = nil, + hasPendingApprovals: Bool = false, + hasPendingUserInput: Bool = false, + latestUserMessageAt: Date? = nil, + latestTurn: FeatureThreadSettlementFacts.LatestTurn? = nil + ) -> FeatureThreadSettlementFacts { + FeatureThreadSettlementFacts( + settlementOverride: override, + sessionStatus: sessionStatus, + hasPendingApprovals: hasPendingApprovals, + hasPendingUserInput: hasPendingUserInput, + latestUserMessageAt: latestUserMessageAt, + latestTurn: latestTurn + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift new file mode 100644 index 000000000000..a8811b44399f --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/DeviceManagementTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Device management") +struct DeviceManagementTests { + @Test + func sortsCurrentThenOnlineThenRecent() { + let now = Date() + let current = session( + id: "current", + at: now.addingTimeInterval(-300), + isCurrent: true + ) + let online = session( + id: "online", + at: now.addingTimeInterval(-600), + isConnected: true + ) + let recent = session(id: "recent", at: now.addingTimeInterval(-60)) + let older = session(id: "older", at: now.addingTimeInterval(-3_600)) + + let sorted = FeatureDeviceSession.sortedForDisplay([older, recent, online, current]) + + #expect(sorted.map(\.id) == ["current", "online", "recent", "older"]) + } + + @Test + func usesSafeFallbackLabels() { + let current = session(id: "current", at: .now, isCurrent: true) + let desktop = session(id: "desktop", at: .now, deviceType: .desktop) + + #expect(current.displayName == "This device") + #expect(desktop.displayName == "Desktop") + } + + @Test + func mapsT3ConnectDeviceAsCurrentInstallation() { + let relayDevice = T3ConnectRelayDevice( + deviceId: "phone-1", + label: "Theo’s iPhone", + platform: "ios", + iosMajorVersion: 27, + appVersion: "1.0 (24)", + notifications: .init( + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true + ), + liveActivities: .init(enabled: true), + updatedAt: "2026-08-10T18:30:00.000Z" + ) + + let session = FeatureDeviceSession( + relayDevice: relayDevice, + currentDeviceID: "phone-1" + ) + + #expect(session.id == "phone-1") + #expect(session.displayName == "Theo’s iPhone") + #expect(session.deviceType == .mobile) + #expect(session.operatingSystem == "iOS 27") + #expect(session.browser == "T3 Code 1.0 (24)") + #expect(session.isCurrent) + #expect(session.lastConnectedAt == session.issuedAt) + } + + @Test @MainActor + func loadsT3ConnectDevicesWithoutEnvironmentAdminScope() async throws { + let manager = T3ConnectDeviceManagerStub( + devices: [relayDevice(id: "phone-1")], + currentDeviceID: "phone-1" + ) + let client = NativeFeatureClient(t3ConnectDeviceManager: manager) + + let sessions = try await client.loadDeviceSessions() + + #expect(sessions.map(\.id) == ["phone-1"]) + #expect(sessions[0].isCurrent) + #expect(manager.loadCount == 1) + } + + private func relayDevice(id: String) -> T3ConnectRelayDevice { + T3ConnectRelayDevice( + deviceId: id, + label: "Theo’s iPhone", + platform: "ios", + iosMajorVersion: 27, + appVersion: "1.0 (24)", + notifications: .init( + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true + ), + liveActivities: .init(enabled: true), + updatedAt: "2026-08-10T18:30:00.000Z" + ) + } + + private func session( + id: String, + at date: Date, + deviceType: FeatureDeviceType = .mobile, + isConnected: Bool = false, + isCurrent: Bool = false + ) -> FeatureDeviceSession { + FeatureDeviceSession( + sessionID: id, + deviceType: deviceType, + issuedAt: date.addingTimeInterval(-100), + expiresAt: date.addingTimeInterval(86_400), + lastConnectedAt: date, + isConnected: isConnected, + isCurrent: isCurrent + ) + } +} + +@MainActor +private final class T3ConnectDeviceManagerStub: T3ConnectDeviceManaging { + let hasActiveAccount = true + let currentRegisteredDeviceID: String? + private let devices: [T3ConnectRelayDevice] + private(set) var loadCount = 0 + + init(devices: [T3ConnectRelayDevice], currentDeviceID: String?) { + self.devices = devices + self.currentRegisteredDeviceID = currentDeviceID + } + + func registeredDevices() async throws -> [T3ConnectRelayDevice] { + loadCount += 1 + return devices + } + + func unregisterDevice(id: String) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift new file mode 100644 index 000000000000..7367c6b2fe2c --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureAttachmentUploadCoordinatorTests.swift @@ -0,0 +1,291 @@ +import Foundation +import Observation +import Testing +@testable import T3Code + +@Suite("Attachment pre-upload coordinator") +@MainActor +struct FeatureAttachmentUploadCoordinatorTests { + @Test func canceledTransferKeepsConcurrencySlotUntilItReturns() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = makeCoordinator(limit: 3, uploads: uploads) + let attachments = (0..<4).map { attachment(byte: UInt8($0)) } + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: attachments) + let started = [await uploads.nextStart(), await uploads.nextStart(), await uploads.nextStart()] + let canceledID = started[0] + + coordinator.syncOwner( + draftKey: "draft", + environmentID: "one", + attachments: attachments.filter { $0.id != canceledID } + ) + #expect(uploads.startCount == 3) + #expect(uploads.maximumActive == 3) + + uploads.complete(canceledID, environmentID: "one") + _ = await uploads.nextStart() + #expect(uploads.maximumActive == 3) + uploads.completeAll() + } + + @Test func removedAndReaddedUUIDRejectsLateOldResult() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = makeCoordinator(limit: 2, uploads: uploads) + let id = UUID() + let old = attachment(id: id, byte: 1) + let replacement = attachment(id: id, byte: 2) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [old]) + _ = await uploads.nextStart() + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: []) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [replacement]) + _ = await uploads.nextStart() + + uploads.complete(id, environmentID: "one", attachmentID: "old") + #expect(coordinator.state(environmentID: "one", attachmentID: id) == .uploading) + + uploads.complete(id, environmentID: "one", attachmentID: "new") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: id) + == .ready(.init(environmentID: "one", attachmentID: "new")) + } + } + + @Test func environmentSwitchDuringPersistenceCannotPublishOldReference() async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness(suspended: true) + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: persistence.persist + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + await persistence.nextCall() + + coordinator.syncOwner(draftKey: "draft", environmentID: "two", attachments: [value]) + persistence.resume(result: true) + _ = await uploads.nextStart() + #expect(coordinator.state(environmentID: "one", attachmentID: value.id) == nil) + #expect(coordinator.state(environmentID: "two", attachmentID: value.id) == .uploading) + uploads.completeAll() + } + + @Test func persistenceFailureBlocksReadyAndRetryCanSucceed() async throws { + let uploads = CoordinatorUploadHarness() + let persistence = CoordinatorPersistenceHarness(error: TestFailure.disk) + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: persistence.persist + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + await waitUntilObserved { + if case .failed = coordinator.state(environmentID: "one", attachmentID: value.id) { + return true + } + return false + } + + persistence.error = nil + coordinator.retry(environmentID: "one", attachmentID: value.id) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one", attachmentID: "retry") + await waitUntilObserved { + coordinator.state(environmentID: "one", attachmentID: value.id) + == .ready(.init(environmentID: "one", attachmentID: "retry")) + } + } + + @Test func rejectedCompareAndSetDoesNotStayUploading() async throws { + let uploads = CoordinatorUploadHarness() + let coordinator = FeatureAttachmentUploadCoordinator( + upload: uploads.upload, + persist: { _, _, _ in false } + ) + let value = attachment(byte: 1) + coordinator.syncOwner(draftKey: "draft", environmentID: "one", attachments: [value]) + _ = await uploads.nextStart() + uploads.complete(value.id, environmentID: "one") + + await waitUntilObserved { + if case .failed = coordinator.state(environmentID: "one", attachmentID: value.id) { + return true + } + return false + } + } + + private func makeCoordinator( + limit: Int, + uploads: CoordinatorUploadHarness + ) -> FeatureAttachmentUploadCoordinator { + FeatureAttachmentUploadCoordinator( + maximumConcurrentUploads: limit, + upload: uploads.upload, + persist: { _, _, _ in true } + ) + } + + private func attachment(id: UUID = UUID(), byte: UInt8) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: id, + data: Data([byte]), + filename: "\(byte).png", + mimeType: "image/png" + ) + } + + private func waitUntilObserved(_ condition: @escaping @MainActor () -> Bool) async { + await CoordinatorObservationWaiter(condition: condition).wait() + } +} + +@MainActor +private final class CoordinatorObservationWaiter { + private let condition: @MainActor () -> Bool + private var continuation: CheckedContinuation? + + init(condition: @escaping @MainActor () -> Bool) { + self.condition = condition + } + + func wait() async { + guard !condition() else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + check() + } + } + + private func check() { + guard continuation != nil else { return } + withObservationTracking { + guard condition(), let continuation else { return } + self.continuation = nil + continuation.resume() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + self?.check() + } + } + } +} + +@MainActor +private final class CoordinatorUploadHarness { + private struct Pending { + let id: UUID + let environmentID: String + let continuation: CheckedContinuation + } + + private var pending: [Pending] = [] + private(set) var startCount = 0 + private(set) var maximumActive = 0 + private var active = 0 + private var startedIDs: [UUID] = [] + private var startWaiters: [CheckedContinuation] = [] + + lazy var upload: FeatureAttachmentUploadCoordinator.Upload = { [weak self] attachment, env in + guard let self else { return nil } + self.startCount += 1 + self.active += 1 + self.maximumActive = max(self.maximumActive, self.active) + if startWaiters.isEmpty { + startedIDs.append(attachment.id) + } else { + startWaiters.removeFirst().resume(returning: attachment.id) + } + let result = await withCheckedContinuation { continuation in + self.pending.append(Pending( + id: attachment.id, + environmentID: env, + continuation: continuation + )) + } + self.active -= 1 + return result + } + + func complete(_ id: UUID, environmentID: String, attachmentID: String = "uploaded") { + guard let index = pending.firstIndex(where: { + $0.id == id && $0.environmentID == environmentID + }) else { + Issue.record("Upload was not pending") + return + } + pending.remove(at: index).continuation.resume(returning: .init( + environmentID: environmentID, + attachmentID: attachmentID + )) + } + + func nextStart() async -> UUID { + if !startedIDs.isEmpty { return startedIDs.removeFirst() } + return await withCheckedContinuation { startWaiters.append($0) } + } + + func completeAll() { + let values = pending + pending.removeAll() + for value in values { + value.continuation.resume(returning: .init( + environmentID: value.environmentID, + attachmentID: "drained" + )) + } + } +} + +@MainActor +private final class CoordinatorPersistenceHarness { + var error: (any Error)? + private var suspended: Bool + private var continuation: CheckedContinuation? + private(set) var callCount = 0 + private var callReceipts = 0 + private var callWaiters: [CheckedContinuation] = [] + + init(suspended: Bool = false, error: (any Error)? = nil) { + self.suspended = suspended + self.error = error + } + + lazy var persist: FeatureAttachmentUploadCoordinator.Persist = { [weak self] _, _, _ in + guard let self else { return false } + self.callCount += 1 + if callWaiters.isEmpty { + callReceipts += 1 + } else { + callWaiters.removeFirst().resume() + } + if let error = self.error { throw error } + if self.suspended { + return await withCheckedContinuation { self.continuation = $0 } + } + return true + } + + func resume(result: Bool) { + suspended = false + continuation?.resume(returning: result) + continuation = nil + } + + func nextCall() async { + if callReceipts > 0 { + callReceipts -= 1 + return + } + await withCheckedContinuation { callWaiters.append($0) } + } +} + +private enum TestFailure: LocalizedError { + case disk + + var errorDescription: String? { "Disk write failed." } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift new file mode 100644 index 000000000000..fe7536e83f1f --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureComposerPowerTests.swift @@ -0,0 +1,689 @@ +import SwiftUI +import Testing +import UIKit +import UniformTypeIdentifiers +@testable import T3Code + +@Suite("Composer power features") +struct FeatureComposerPowerTests { + @Test( + "Composer input grows past the former seven-line cap", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func composerTextInputGrowsBeyondSevenLines() { + let lineHeight: CGFloat = 22 + let sevenLines = FeatureComposerTextInputSizing.height( + fittingHeight: lineHeight * 7, + lineHeight: lineHeight + ) + let elevenLines = FeatureComposerTextInputSizing.height( + fittingHeight: lineHeight * 11, + lineHeight: lineHeight + ) + + #expect(sevenLines == lineHeight * 7) + #expect(elevenLines == lineHeight * 11) + } + + @Test( + "A very tall composer input caps at its line bound and scrolls inside", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func composerTextInputCapsAtItsLineBound() { + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 2_200, + lineHeight: 22 + ) == 22 * FeatureComposerTextInputSizing.maximumLines + ) + } + + @Test + func composerTextInputReservesRoomForControlsInAConstrainedViewport() { + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 150 + ) == 150 + ) + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 80 + ) == 80 + ) + #expect( + FeatureComposerTextInputSizing.height( + fittingHeight: 440, + lineHeight: 22, + availableHeight: 0 + ) == 0 + ) + } + + @Test + @MainActor + func compressedComposerViewportKeepsItsLastLineAboveTheFooter() throws { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 1) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = (1...30).map { "Attachment draft line \($0)" } + .joined(separator: "\n") + textView.selectedRange = NSRange(location: textView.text.utf16.count, length: 0) + + let measured = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + let font = try #require(textView.font) + let keyboardAndAttachmentBound: CGFloat = 80 + let viewportHeight = FeatureComposerTextInputSizing.height( + fittingHeight: measured.height, + lineHeight: font.lineHeight, + availableHeight: keyboardAndAttachmentBound + ) + textView.frame.size.height = viewportHeight + textView.setNeedsLayout() + textView.layoutIfNeeded() + textView.scrollSelectionIntoView() + + #expect(textView.bounds.height == keyboardAndAttachmentBound) + #expect(textView.contentOverflows) + let selection = try #require(textView.selectedTextRange) + let caret = textView.caretRect(for: selection.end) + let visibleTop = textView.contentOffset.y + let visibleBottom = visibleTop + textView.bounds.height + #expect(caret.minY >= visibleTop) + #expect(caret.maxY <= visibleBottom - 1) + } + + @Test + @MainActor + func uiTextViewMeasurementGrowsBeforeTheViewportCap() throws { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 1) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + let font = try #require(textView.font) + + textView.text = "First line\nSecond line" + let shortMeasurement = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + textView.text = (1...8).map { "Draft line \($0)" }.joined(separator: "\n") + let tallMeasurement = textView.sizeThatFits( + CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude) + ) + + let shortHeight = FeatureComposerTextInputSizing.height( + fittingHeight: shortMeasurement.height, + lineHeight: font.lineHeight, + availableHeight: 400 + ) + let tallHeight = FeatureComposerTextInputSizing.height( + fittingHeight: tallMeasurement.height, + lineHeight: font.lineHeight, + availableHeight: 400 + ) + + #expect(tallHeight > shortHeight) + #expect(tallHeight == tallMeasurement.height) + } + + @Test + func newTaskUsesCompactContextForDraftsAndAttachments() { + #expect(!NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: false, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: true, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "A draft", isFocused: false, hasAttachments: false + )) + #expect(NewThreadComposerLayout.usesCompactContext( + prompt: "", isFocused: false, hasAttachments: true + )) + } + + @Test + @MainActor + func longComposerDraftStaysClippedAndScrollsToItsLastLine() { + let textView = FeatureComposerUITextView( + frame: CGRect(x: 0, y: 0, width: 320, height: 110) + ) + textView.configureComposerViewport() + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = (1...40).map { "A long pasted draft line \($0)" } + .joined(separator: "\n") + textView.layoutIfNeeded() + textView.selectedRange = NSRange(location: textView.text.utf16.count, length: 0) + textView.scrollSelectionIntoView() + textView.layoutIfNeeded() + + #expect(textView.clipsToBounds) + #expect(textView.contentOverflows) + if let selection = textView.selectedTextRange { + let caret = textView.caretRect(for: selection.end) + #expect(caret.maxY <= textView.contentOffset.y + textView.bounds.height) + } else { + Issue.record("Expected a visible selection at the end of the pasted draft") + } + } + + @Test + func replacementCursorLandsAfterInsertedTextInUTF16() { + // "🧪 " occupies three characters but four UTF-16 units; the caret + // location must count the latter or it drifts on emoji-bearing drafts. + let original = "🧪 Use $dep please" + let range = 6..<10 + + #expect( + FeatureComposerTextSelectionPolicy.cursorLocation( + afterReplacing: range, + in: original, + with: "$dependency " + ) == "🧪 Use $dependency ".utf16.count + ) + } + + @Test + func restoredDraftPlacesCaretAtUTF16End() { + #expect( + FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: "", + newText: "🧪 restored draft", + selectedLocation: 0 + ) == "🧪 restored draft".utf16.count + ) + } + + @Test + func externalRewriteClampsCaretIntoTheNewText() { + #expect( + FeatureComposerTextSelectionPolicy.cursorLocationAfterBindingUpdate( + previousText: "a much longer draft", + newText: "short", + selectedLocation: 19 + ) == 5 + ) + } + + @Test + @MainActor + func imageCapableComposerAdvertisesImagesToTheNativePasteMenu() { + let textView = FeatureComposerUITextView() + + textView.acceptsImages = true + + #expect( + textView.pasteConfiguration?.acceptableTypeIdentifiers.contains( + UTType.image.identifier + ) == true + ) + #expect( + textView.pasteConfiguration?.acceptableTypeIdentifiers.contains( + UTType.text.identifier + ) == true + ) + + textView.acceptsImages = false + + #expect(textView.pasteConfiguration == nil) + } + + @Test + @MainActor + func textViewDeclinesImageDropsSoTheComposerSurfaceOwnsThem() { + let textView = FeatureComposerUITextView() + textView.acceptsImages = true + + let image = NSItemProvider() + image.registerDataRepresentation( + forTypeIdentifier: UTType.png.identifier, + visibility: .all + ) { completion in + completion(Data([0x89, 0x50, 0x4E, 0x47]), nil) + return nil + } + let text = NSItemProvider(object: "caption" as NSString) + + #expect(!textView.canPaste([image])) + #expect(!textView.canPaste([text, image])) + #expect(textView.canPaste([text])) + } + + @Test + func downwardDragDismissalRespectsDraftScrolling() { + #expect(FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: false, isAtTop: true + )) + // Scrolling back through a capped draft must not drop the keyboard… + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: true, isAtTop: false + )) + // …but a drag that begins at the top of the draft only rubber-bands, + // and is the capped composer's one escape hatch. + #expect(FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 2, translationY: 20, isScrollable: true, isAtTop: true + )) + // Mostly-horizontal drags are caret adjustments, not dismissals. + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 30, translationY: 12, isScrollable: false, isAtTop: true + )) + #expect(!FeatureComposerDragDismissPolicy.shouldDismiss( + translationX: 0, translationY: 8, isScrollable: false, isAtTop: true + )) + } + + @Test + func nativePasteDetectionUsesImageTypeConformance() { + let pasteboard = UIPasteboard.withUniqueName() + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.items = [ + [UTType.heic.identifier: Data([0x00])], + ] + + #expect(!pasteboard.hasImages) + #expect(FeatureComposerPasteboardPolicy.containsImage(in: pasteboard)) + } + + @Test + func nativePasteDetectionChecksEveryPasteboardItem() { + let pasteboard = UIPasteboard.withUniqueName() + defer { UIPasteboard.remove(withName: pasteboard.name) } + pasteboard.items = [ + [UTType.plainText.identifier: "caption"], + [UTType.png.identifier: Data([0x89, 0x50, 0x4E, 0x47])], + ] + + #expect(FeatureComposerPasteboardPolicy.containsImage(in: pasteboard)) + } + + @Test + func detectsCommandsModelsSkillsAndPathsAtTheCursor() { + #expect( + FeatureComposerTriggerParser.detect(in: "/re") + == FeatureComposerTrigger(kind: .slashCommand, query: "re", range: 0..<3) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "/model claude") + == FeatureComposerTrigger(kind: .model, query: "claude", range: 0..<13) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "Use $dep") + == FeatureComposerTrigger(kind: .skill, query: "dep", range: 4..<8) + ) + #expect( + FeatureComposerTriggerParser.detect(in: "Read @Sources/App") + == FeatureComposerTrigger(kind: .path, query: "Sources/App", range: 5..<17) + ) + + let editedText = "Use @Sources/App then continue" + #expect( + FeatureComposerTriggerParser.detect(in: editedText, cursorOffset: 16) + == FeatureComposerTrigger(kind: .path, query: "Sources/App", range: 4..<16) + ) + } + + @Test + func replacementsPreserveTextOutsideTheActiveTrigger() { + let text = "Review @Sources/App please" + let result = FeatureComposerTriggerParser.replacing( + 7..<19, + in: text, + with: "[App](Sources/App) " + ) + #expect(result == "Review [App](Sources/App) please") + } + + @Test + func fileLinksMatchTheSharedComposerFormat() { + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "path/to/package.json") + == "[package.json](path/to/package.json)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "docs/My File (draft).md") + == "[My File (draft).md](docs/My%20File%20%28draft%29.md)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "C:\\repo\\src\\index.ts") + == "[index.ts](C:%5Crepo%5Csrc%5Cindex.ts)" + ) + #expect( + FeatureComposerFileLinkSerializer.markdownLink(for: "@scope/package.json") + == "[package.json](@scope/package.json)" + ) + } + + @Test + func commandMenuIncludesProviderCommandsButNotRemovedMobileModes() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let powerFeatures = FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: "review", description: "Review changes"), + FeatureProviderSlashCommand(name: "plan", description: "Legacy mode"), + FeatureProviderSlashCommand(name: "default", description: "Legacy mode"), + ] + ) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: powerFeatures, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "/review"]) + } + + @Test + func slashMenuIncludesEnabledSkillsAndSuppressesMatchingCommands() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: "deploy", description: "Old command"), + FeatureProviderSlashCommand(name: "review", description: "Review changes"), + ], + skills: [ + FeatureProviderSkill(name: "deploy", displayName: "Deploy project"), + FeatureProviderSkill(name: "disabled", isEnabled: false), + ] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "/review", "Deploy project"]) + } + + @Test + func skillMenusDedupeEnabledNamesBeforeSearchAndSorting() throws { + let skills = [ + FeatureProviderSkill(name: " deploy ", displayName: "First deploy", isEnabled: false), + FeatureProviderSkill(name: "Deploy", displayName: "Enabled deploy"), + FeatureProviderSkill(name: " DEPLOY ", displayName: "Duplicate matching search"), + FeatureProviderSkill(name: "review", displayName: "Review"), + ] + let allSkillsTrigger = try #require(FeatureComposerTriggerParser.detect(in: "$")) + let searchedSkillsTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "$matching") + ) + let searchedSlashTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "/skill:matching") + ) + + let allItems = FeatureComposerMenuBuilder.items( + trigger: allSkillsTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + let searchedItems = FeatureComposerMenuBuilder.items( + trigger: searchedSkillsTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + let searchedSlashItems = FeatureComposerMenuBuilder.items( + trigger: searchedSlashTrigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures(skills: skills), + pathEntries: [] + ) + + #expect(allItems.map(\.label) == ["Enabled deploy", "Review"]) + #expect(searchedItems.isEmpty) + #expect(searchedSlashItems.isEmpty) + } + + @Test + func slashCommandsUseNormalizedNamesAndAllEnabledSkillsForSuppression() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [ + FeatureProviderSlashCommand(name: " DEPLOY "), + FeatureProviderSlashCommand(name: " MODEL "), + ], + skills: [FeatureProviderSkill(name: "deploy", displayName: "Release project")] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["/model", "Release project"]) + } + + @Test + func slashSkillPrefixFiltersSkillsWithoutProviderCommands() throws { + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/skill:fix")) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + slashCommands: [FeatureProviderSlashCommand(name: "fix")], + skills: [ + FeatureProviderSkill(name: "gh-fix-ci", displayName: "Fix CI"), + FeatureProviderSkill(name: "deploy"), + ] + ), + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Fix CI"]) + } + + @Test + func skillSourcesFollowProviderScopeAndPluginPaths() { + #expect(FeatureProviderSkill(name: "repo", scope: "repository").source == .repository) + #expect(FeatureProviderSkill(name: "local", scope: "workspace").source == .project) + #expect(FeatureProviderSkill(name: "mine", scope: "user").source == .personal) + #expect(FeatureProviderSkill(name: "built-in", scope: "system").source == .system) + #expect( + FeatureProviderSkill( + name: "plugin", + path: "/Users/theo/.codex/plugins/example/SKILL.md", + scope: "user" + ).source == .app + ) + } + + @Test + func appApprovalDecisionsKeepTheServerWireValues() { + let decisions: [(FeatureApprovalDecision, String)] = [ + (.allowOnce, "accept"), + (.allowForSession, "acceptForSession"), + (.allowAlways, "acceptAlways"), + (.deny, "decline"), + (.cancel, "cancel"), + ] + + for (decision, wireValue) in decisions { + #expect(decision.wireValue == wireValue) + #expect(FeatureApprovalDecision(wireValue: wireValue) == decision) + } + #expect(FeatureApprovalDecision(wireValue: "unsupported") == nil) + } + + @Test + func codexFeedbackCommandParsesOptionalReasonsWithoutMatchingOtherCommands() { + #expect(FeatureCodexFeedbackCommand.parse(" /feedback ")?.reason == nil) + #expect( + FeatureCodexFeedbackCommand.parse("/feedback The agent stopped early.")?.reason + == "The agent stopped early." + ) + #expect( + FeatureCodexFeedbackCommand.parse("/FEEDBACK First line\nSecond line")?.reason + == "First line\nSecond line" + ) + #expect(FeatureCodexFeedbackCommand.parse("/feedback-status") == nil) + #expect(FeatureCodexFeedbackCommand.parse("Please send /feedback") == nil) + } + + @Test + func modelAndSkillMenusFilterTheirCatalogs() throws { + let provider = FeatureProvider( + id: "claude", + name: "Claude", + models: [ + FeatureModel(id: "sonnet", name: "Sonnet"), + FeatureModel(id: "opus", name: "Opus"), + ] + ) + let modelTrigger = try #require( + FeatureComposerTriggerParser.detect(in: "/model op") + ) + let modelItems = FeatureComposerMenuBuilder.items( + trigger: modelTrigger, + providers: [provider], + currentSelection: nil, + threadSelection: nil, + powerFeatures: .disabled, + pathEntries: [] + ) + #expect(modelItems.map(\.label) == ["Opus"]) + + let skillTrigger = try #require(FeatureComposerTriggerParser.detect(in: "$fix")) + let skillItems = FeatureComposerMenuBuilder.items( + trigger: skillTrigger, + providers: [provider], + currentSelection: nil, + threadSelection: nil, + powerFeatures: FeatureComposerPowerFeatures( + skills: [ + FeatureProviderSkill( + name: "gh-fix-ci", + displayName: "Fix CI", + shortDescription: "Repair failing checks" + ), + FeatureProviderSkill(name: "deploy", displayName: "Deploy") + ] + ), + pathEntries: [] + ) + #expect(skillItems.map(\.label) == ["Fix CI"]) + } + + @Test + func modelCommandHonorsProvidersThatLockAThreadModel() throws { + let provider = FeatureProvider( + id: "locked", + name: "Locked provider", + requiresNewThreadForModelChange: true, + models: [ + FeatureModel(id: "current", name: "Current"), + FeatureModel(id: "other", name: "Other"), + ] + ) + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/model")) + let currentSelection = FeatureSelection( + providerID: "locked", + modelID: "current", + options: [FeatureModelOptionSelection(id: "reasoning", value: .string("high"))] + ) + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [provider], + currentSelection: currentSelection, + threadSelection: currentSelection, + powerFeatures: .disabled, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Current"]) + if case let .model(selection, _, _) = try #require(items.first) { + #expect(selection.options == currentSelection.options) + } else { + Issue.record("Expected a model menu item") + } + } + + @Test + func establishedThreadsKeepModelChoicesOnTheirProvider() throws { + let currentProvider = FeatureProvider( + id: "codex", + name: "Codex", + models: [ + FeatureModel(id: "current", name: "Current"), + FeatureModel(id: "other", name: "Other"), + ] + ) + let otherProvider = FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "sonnet", name: "Sonnet")] + ) + let selection = FeatureSelection(providerID: "codex", modelID: "current") + let trigger = try #require(FeatureComposerTriggerParser.detect(in: "/model")) + + let items = FeatureComposerMenuBuilder.items( + trigger: trigger, + providers: [currentProvider, otherProvider], + currentSelection: selection, + threadSelection: selection, + powerFeatures: .disabled, + pathEntries: [] + ) + + #expect(items.map(\.label) == ["Current", "Other"]) + } + + @Test + func changingInputQuestionsKeepsAValidActiveQuestionAndDropsStaleAnswers() { + #expect( + FeatureComposerQuestionReconciliation.index( + current: 2, + previousQuestionIDs: ["one", "two", "three"], + currentQuestionIDs: ["one"] + ) == 0 + ) + #expect( + FeatureComposerQuestionReconciliation.index( + current: 1, + previousQuestionIDs: ["one", "two", "three"], + currentQuestionIDs: ["three", "two"] + ) == 1 + ) + + let reconciled = FeatureComposerQuestionReconciliation.answers( + [ + "one": .text("keep"), + "removed": .text("drop"), + ], + currentQuestionIDs: ["one"] + ) + #expect(reconciled == ["one": .text("keep")]) + } + + @Test + func onlyTheExplicitComposerButtonCanSend() { + #expect( + FeatureComposerSubmissionPolicy.allowsSend(for: .explicitButton) + ) + #expect( + !FeatureComposerSubmissionPolicy.allowsSend(for: .returnKey) + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift new file mode 100644 index 000000000000..e3f680fe987e --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureOutboxStoreTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Durable mobile outbox") +struct FeatureOutboxStoreTests { + @Test + func roundTripPreservesStableWireIdentityAndAttachments() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "thread-wire", + commandID: "command-wire", + messageID: "message-wire", + createdAt: Date(timeIntervalSince1970: 42) + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "thread-scoped", + text: "Ship it", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .automatic, + interactionMode: .plan, + attachments: [ + .init(data: Data([0x01, 0x02]), name: "reference.png", mimeType: "image/png"), + ] + ) + + try await store.enqueue(submission) + let persistedURL = await store.fileURL + let restored = try await FeatureOutboxStore( + fileURL: persistedURL + ).submissions() + + #expect(restored.count == 1) + #expect(restored[0].identity == identity) + #expect(restored[0].runtimeMode == .automatic) + #expect(restored[0].interactionMode == .standard) + #expect(restored[0].attachments.first?.data == Data([0x01, 0x02])) + } + + @Test + func fileBackedRoundTripPreservesLocalAndUploadedIdentity() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-file-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let sourceURL = directory.appendingPathComponent("provider.json") + try Data("{}".utf8).write(to: sourceURL) + let attachmentID = UUID() + let storageRoot = directory.appendingPathComponent("attachments", isDirectory: true) + let ownedFile = try ManagedAttachmentFileStore(rootURL: storageRoot).copyOwnedFile( + from: sourceURL, + attachmentID: attachmentID, + originalFileName: "context.json" + ) + let uploadedReference = FeatureUploadedAttachmentReference( + environmentID: "environment-1", + attachmentID: "uploaded-1" + ) + let store = FeatureOutboxStore( + fileURL: directory.appendingPathComponent("outbox.json"), + attachmentStorageRootURL: storageRoot + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: "thread-1", + text: "Review", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + FeatureUploadAttachment( + id: attachmentID, + ownedFile: ownedFile, + name: "context.json", + mimeType: "application/json", + uploadedReference: uploadedReference + ), + ] + ) + + try await store.enqueue(submission) + let persistedURL = await store.fileURL + let restored = try await FeatureOutboxStore( + fileURL: persistedURL, + attachmentStorageRootURL: storageRoot + ).submissions().first + + #expect(restored?.uploads.first?.id == attachmentID) + #expect(restored?.uploads.first?.ownedFile?.url == ownedFile.url) + #expect(restored?.uploads.first?.byteCount == 2) + #expect(restored?.uploads.first?.uploadedReference == uploadedReference) + let json = try #require(String(data: Data(contentsOf: persistedURL), encoding: .utf8)) + #expect(!json.contains(Data("{}".utf8).base64EncodedString())) + } + + @Test + func restoreAcceptsImageAttachmentWithoutNewFileFields() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-old-image-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let fileURL = directory.appendingPathComponent("outbox.json") + let identity = FeatureSubmissionIdentity( + threadID: "thread-1", + commandID: "command-1", + messageID: "message-1", + createdAt: Date(timeIntervalSince1970: 42) + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "thread-1", + text: "Old image", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + FeatureUploadAttachment( + data: Data([1, 2, 3]), + name: "old.png", + mimeType: "image/png" + ), + ] + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let current = try JSONSerialization.jsonObject( + with: JSONEncoder.t3.encode(submission) + ) as! [String: Any] + var legacy = current + var attachments = legacy["attachments"] as! [[String: Any]] + attachments[0].removeValue(forKey: "id") + attachments[0].removeValue(forKey: "byteCount") + legacy["attachments"] = attachments + try JSONSerialization.data( + withJSONObject: ["version": 1, "submissions": [legacy]] + ).write(to: fileURL) + + let restored = try await FeatureOutboxStore(fileURL: fileURL).submissions().first + + #expect(restored?.uploads.first?.data == Data([1, 2, 3])) + #expect(restored?.uploads.first?.ownedFile == nil) + } + + @Test + func restorePreservesLegacyPermission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-feature-outbox-legacy-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + var submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: "thread-scoped", + text: "Retry this", + selection: nil, + runtimeMode: .automatic, + interactionMode: .standard, + attachments: [] + ) + submission.runtimeMode = .autoAcceptEdits + try await store.enqueue(submission) + let persistedURL = await store.fileURL + + let restored = try await FeatureOutboxStore(fileURL: persistedURL).submissions() + + #expect(restored.first?.runtimeMode == .autoAcceptEdits) + } + + @Test + func policySendsFollowUpsWhileWorkingAndWaitsWhenOffline() { + let thread = FeatureThread( + id: "thread-scoped", + projectID: "project-1", + environmentID: "environment-1", + title: "Working", + state: .working + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: thread.id, + text: "Queue this next", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + let environment = FeatureEnvironment( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ) + let connected = FeatureSnapshot( + connection: .init(state: .connected), + environments: [environment], + threads: [thread] + ) + var offline = connected + offline.connection.state = .disconnected + offline.environments[0].connectionState = .disconnected + + #expect(FeatureOutboxPolicy.decision(for: submission, snapshot: connected) == .send) + #expect(FeatureOutboxPolicy.decision(for: submission, snapshot: offline) == .wait) + } + + @Test + func existingThreadDoesNotProveItsFirstMessageWasDelivered() { + let thread = FeatureThread( + id: "thread-scoped", + projectID: "project-1", + environmentID: "environment-1", + title: "Created" + ) + let creation = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: FeatureSubmissionIdentity(), + threadID: thread.id, + text: "Create it", + selection: .init(providerID: "claude", modelID: "claude-opus-5"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + var snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [thread] + ) + + #expect(FeatureOutboxPolicy.decision(for: creation, snapshot: snapshot) == .send) + + snapshot.environments[0].connectionState = .disconnected + #expect(FeatureOutboxPolicy.decision(for: creation, snapshot: snapshot) == .wait) + snapshot.environments[0].connectionState = .connected + + var followUp = creation + followUp.creation = nil + snapshot.threads = [] + #expect(FeatureOutboxPolicy.decision(for: followUp, snapshot: snapshot) == .discard) + #expect( + FeatureOutboxPolicy.decision( + for: followUp, + snapshot: snapshot, + pendingCreationThreadIDs: [creation.threadID] + ) == .wait + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift new file mode 100644 index 000000000000..df80583e62ed --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -0,0 +1,3040 @@ +import Foundation +import Observation +import SwiftUI +import Testing +import UIKit +@testable import T3Code + +@MainActor +@Suite("Feature root model") +struct FeatureRootModelTests { + @Test + func appearanceAppliesImmediatelyAndPersistsWithoutSavingTheDraft() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + + let save = Task { await model.saveAppearance(.light) } + await Task.yield() + + #expect(model.snapshot.settings.appearance == .light) + #expect(await save.value) + #expect(client.savedSettings.last?.appearance == .light) + } + + @Test + func backgroundRefreshUsesTheBoundedClientPath() async { + let client = FeatureClientStub() + client.backgroundSnapshotValue = FeatureSnapshot( + connection: .init(state: .connected, environmentName: "Remote") + ) + let model = testRootModel(client: client) + + let succeeded = await model.refreshInBackground() + + #expect(succeeded) + #expect(client.backgroundSnapshotCallCount == 1) + #expect(client.initialSnapshotCallCount == 0) + #expect(model.snapshot.connection.environmentName == "Remote") + } + + @Test + func savedServersKeepWorkspaceNavigationAvailableWhileDisconnected() { + let savedEnvironment = FeatureEnvironment( + id: "offline-demo", + name: "Offline demo", + endpoint: "https://offline.example", + connectionState: .disconnected + ) + let snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [savedEnvironment] + ) + + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: snapshot, + isManagingConnections: true + ) + ) + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: snapshot, + isManagingConnections: false + ) + ) + #expect( + FeatureRootPresentation.showsWorkspace( + snapshot: FeatureSnapshot(connection: .init(state: .disconnected)), + isManagingConnections: true + ) + ) + #expect( + !FeatureRootPresentation.showsWorkspace( + snapshot: FeatureSnapshot(connection: .init(state: .disconnected)), + isManagingConnections: false + ) + ) + } + + @Test + func disconnectEndsConnectionManagement() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected, + connectionDetail: "Healthy" + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + model.setConnectionManagementPresented(true) + + await model.disconnect() + + #expect(!model.isManagingConnections) + #expect(model.snapshot.connection.state == .disconnected) + #expect(model.snapshot.environments.first?.connectionState == .disconnected) + #expect(model.snapshot.environments.first?.connectionDetail == nil) + } + + @Test + func restoredFollowUpWaitsForItsQueuedThreadCreation() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-dependent-outbox-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let threadID = "environment-1::thread::queued-thread" + let creationIdentity = FeatureSubmissionIdentity( + threadID: "queued-thread", + commandID: "create-command", + messageID: "create-message", + createdAt: Date(timeIntervalSince1970: 1) + ) + let creation = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: creationIdentity, + threadID: threadID, + text: "Create the task", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + let followUp = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: .init( + threadID: "queued-thread", + commandID: "follow-up-command", + messageID: "follow-up-message", + createdAt: Date(timeIntervalSince1970: 2) + ), + threadID: threadID, + text: "And add tests", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + try await store.enqueue(creation) + try await store.enqueue(followUp) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.timedOut) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + await model.disconnect() + + let restoredIDs = try await store.submissions().map(\.id) + #expect(restoredIDs.count == 2) + #expect(Set(restoredIDs) == Set([creation.id, followUp.id])) + #expect(client.sendMessageCallCount == 0) + } + + @Test + func restoredCreationWaitsForItsFirstMessageEvenWhenTheThreadExists() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-partial-creation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "created-thread", + commandID: "create-command", + messageID: "missing-message", + createdAt: Date(timeIntervalSince1970: 1) + ) + let threadID = "environment-1::thread::created-thread" + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Do not lose the first message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [ + .init(data: Data([0x01]), name: "reference.png", mimeType: "image/png"), + ], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [ + .init( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Created without a message" + ), + ] + ) + client.startTaskError = URLError(.timedOut) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + await model.disconnect() + + #expect(try await store.submissions() == [submission]) + } + + @Test + func cancellingAnOfflineTaskRemovesItsDurableSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-queued-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.notConnectedToInternet) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + let thread = try #require(await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: "Cancel this task", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + )) + + await model.cancelTurn(threadID: thread.id) + + #expect(try await store.submissions().isEmpty) + #expect(!model.snapshot.threads.contains(where: { $0.id == thread.id })) + #expect(client.cancelTurnCallCount == 0) + } + + @Test + func cancellingARestoredServerThreadAlsoInterruptsItsTurn() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-restored-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity(threadID: "created-thread") + let threadID = "environment-1::thread::created-thread" + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Already running on the server", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ], + threads: [ + .init( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Already running", + state: .working + ), + ] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + + await model.cancelTurn(threadID: threadID) + + #expect(client.cancelTurnCallCount == 1) + #expect(try await store.submissions().isEmpty) + #expect(model.snapshot.threads.contains(where: { $0.id == threadID })) + } + + @Test(arguments: [false, true]) + func cancellingAnAcknowledgedQueuedThreadInterruptsItsTurn( + acknowledgedBySnapshot: Bool + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cancel-acknowledged-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity(threadID: "acknowledged-thread") + let threadID = "environment-1::thread::acknowledged-thread" + try await store.enqueue( + FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: threadID, + text: "Accepted before the outbox cleared", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + let acknowledged = FeatureThread( + id: threadID, + wireID: identity.threadID, + projectID: "project-1", + environmentID: "environment-1", + title: "Accepted on the server", + state: .working + ) + if acknowledgedBySnapshot { + var snapshot = client.snapshot + snapshot.threads = [acknowledged] + client.emit(.snapshot(snapshot)) + } else { + client.emit(.thread(acknowledged)) + } + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + + await model.cancelTurn(threadID: threadID) + + #expect(client.cancelTurnCallCount == 1) + #expect(try await store.submissions().isEmpty) + #expect(model.snapshot.threads == [acknowledged]) + } + + @Test + func retryableCreationFailureReturnsTheAcknowledgedServerThread() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-acknowledged-creation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.startTaskError = URLError(.notConnectedToInternet) + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + client.beforeStartTask = { + var acknowledged = try #require(model.snapshot.threads.first) + acknowledged.title = "Accepted on the server" + acknowledged.state = .working + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.threads.first(where: { $0.id == acknowledged.id })?.state + } onChange: { + continuation.resume() + } + client.emit(.thread(acknowledged)) + } + } + let run = Task { await model.start() } + + let thread = await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: "Create this task once", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard + ) + ) + client.finishEvents() + await run.value + + #expect(thread?.title == "Accepted on the server") + #expect(thread?.state == .working) + #expect(try await store.submissions().count == 1) + } + + @Test + func testPairReloadsConnectedSnapshot() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot(connection: .init(state: .disconnected)) + client.snapshotAfterPair = FeatureSnapshot( + connection: .init( + state: .connected, + environmentName: "Studio", + endpoint: "https://studio.example" + ) + ) + let oldThread = FeatureThread(id: "same-id", projectID: "old-project", title: "Old") + client.threadDetail = FeatureThreadDetail( + thread: oldThread, + messages: [FeatureMessage(id: "old-message", role: .assistant, text: "Old")] + ) + let model = testRootModel(client: client) + _ = await model.detail(for: oldThread.id) + + let result = await model.pair(endpoint: "https://studio.example", token: "pair-token") + + #expect(result) + #expect(client.pairEndpoint == "https://studio.example") + #expect(client.pairToken == "pair-token") + #expect(model.snapshot.connection.state == .connected) + #expect(model.details.isEmpty) + } + + @Test + func togglingConnectionRefreshesItsIndependentEnabledState() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isEnabled: true, + connectionState: .connected + ), + ] + ) + client.snapshotAfterEnvironmentToggle = FeatureSnapshot( + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isEnabled: false, + connectionState: .disconnected + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + + let toggled = await model.setEnvironmentEnabled("studio", enabled: false) + + #expect(toggled) + #expect(client.enabledEnvironmentID == "studio") + #expect(client.environmentEnabledValue == false) + #expect(model.snapshot.environments.first?.isEnabled == false) + } + + @Test + func removingAnEnvironmentClearsItsPhysicalAndGroupedDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-draft-cleanup-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native", + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + ) + let physicalKey = "environment:environment-1:thread:one" + let logicalKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/native" + ) + let otherKey = "environment:environment-2:thread:two" + try await drafts.setDraft(FeatureComposerDraft(text: "remove physical"), for: physicalKey) + try await drafts.setDraft(FeatureComposerDraft(text: "remove logical"), for: logicalKey) + try await drafts.setDraft(FeatureComposerDraft(text: "keep"), for: otherKey) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example" + ), + ], + projects: [project] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot() + let model = FeatureRootModel( + client: client, + outboxStore: outbox, + draftStore: drafts + ) + await model.reload() + + await model.removeEnvironment("environment-1") + + #expect(try await drafts.draft(for: physicalKey) == nil) + #expect(try await drafts.draft(for: logicalKey) == nil) + #expect(try await drafts.draft(for: otherKey)?.text == "keep") + } + + @Test + func removingAnEnvironmentClearsDraftsWhenOutboxCleanupFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-cleanup-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let draftKey = "environment:environment-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Clear this draft"), for: draftKey) + let outbox = FeatureOutboxStore(fileURL: directory) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example" + ), + ] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot() + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.reload() + + await model.removeEnvironment("environment-1") + + #expect(try await drafts.draft(for: draftKey) == nil) + #expect(model.errorMessage?.contains("queued messages or drafts") == true) + } + + @Test + func signingOutClearsManagedOutboxEntriesAndGroupedDrafts() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-sign-out-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let project = FeatureProject( + id: "project-1", + environmentID: "managed-1", + name: "Native", + path: "/native", + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + ) + let groupedDraftKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: "github.com/t3/native" + ) + try await drafts.setDraft(FeatureComposerDraft(text: "Private prompt"), for: groupedDraftKey) + try await outbox.enqueue( + FeatureQueuedSubmission( + environmentID: "managed-1", + identity: FeatureSubmissionIdentity(), + threadID: "managed-1::thread::queued", + text: "Private queued message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect + ), + .init( + id: "manual-1", + name: "Manual", + endpoint: "https://manual.example" + ), + ], + projects: [project] + ) + let model = FeatureRootModel( + client: client, + outboxStore: outbox, + draftStore: drafts + ) + await model.reload() + + await model.signOutT3Connect() + + #expect(client.signOutCallCount == 1) + #expect(model.snapshot.environments.map(\.id) == ["manual-1"]) + #expect(try await outbox.submissions().isEmpty) + #expect(try await drafts.draft(for: groupedDraftKey) == nil) + } + + @Test + func signingOutPreservesGroupedDraftsUsedByDirectEnvironments() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-shared-draft-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let outbox = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureRepositoryIdentity(canonicalKey: "github.com/t3/native") + let groupedDraftKey = FeatureComposerDraftStore.newTaskKey( + logicalProjectID: identity.canonicalKey + ) + let managedDraftKey = "environment:managed-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Keep shared prompt"), for: groupedDraftKey) + try await drafts.setDraft(FeatureComposerDraft(text: "Remove managed prompt"), for: managedDraftKey) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect + ), + .init( + id: "manual-1", + name: "Manual", + endpoint: "https://manual.example" + ), + ], + projects: [ + .init( + id: "managed-project", + environmentID: "managed-1", + name: "Native", + path: "/managed/native", + repositoryIdentity: identity + ), + .init( + id: "manual-project", + environmentID: "manual-1", + name: "Native", + path: "/manual/native", + repositoryIdentity: identity + ), + ] + ) + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.reload() + + await model.signOutT3Connect() + + #expect(try await drafts.draft(for: groupedDraftKey)?.text == "Keep shared prompt") + #expect(try await drafts.draft(for: managedDraftKey) == nil) + #expect(model.snapshot.projects.map(\.id) == ["manual-project"]) + } + + @Test + func signingOutClearsDraftsWhenOutboxCleanupFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-sign-out-failure-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let drafts = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let draftKey = "environment:managed-1:thread:one" + try await drafts.setDraft(FeatureComposerDraft(text: "Clear this draft"), for: draftKey) + let outboxURL = directory.appendingPathComponent("outbox.json") + let outbox = FeatureOutboxStore(fileURL: outboxURL) + let threadID = "managed-1::thread::queued" + try await outbox.enqueue( + FeatureQueuedSubmission( + environmentID: "managed-1", + identity: FeatureSubmissionIdentity(threadID: "queued"), + threadID: threadID, + text: "Private queued message", + selection: nil, + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "managed-project", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + ) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "managed-1", + name: "Managed", + endpoint: "https://managed.example", + source: .t3Connect, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "managed-project", + environmentID: "managed-1", + name: "Native", + path: "/native" + ), + ] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: outbox, draftStore: drafts) + await model.start() + #expect(model.snapshot.threads.contains(where: { $0.id == threadID })) + #expect(model.details[threadID] != nil) + try FileManager.default.removeItem(at: outboxURL) + try FileManager.default.createDirectory( + at: outboxURL, + withIntermediateDirectories: false + ) + + await model.signOutT3Connect() + + #expect(try await drafts.draft(for: draftKey) == nil) + #expect(model.snapshot.threads.isEmpty) + #expect(model.details.isEmpty) + #expect(model.errorMessage?.contains("Could not clear saved T3 Connect data") == true) + } + + @Test + func disconnectedPairDoesNotReportConnectionSuccess() async { + let client = FeatureClientStub() + client.snapshotAfterPair = FeatureSnapshot( + connection: .init( + state: .disconnected, + environmentName: "New studio", + endpoint: "https://new.example" + ) + ) + let model = testRootModel(client: client) + + let paired = await model.pair(endpoint: "https://new.example", token: "pair-token") + + #expect(!paired) + #expect(model.snapshot.connection.state == .disconnected) + #expect(model.errorMessage?.contains("Could not connect") == true) + } + + @Test + func testCreateThreadOptimisticallyUpsertsIt() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Build native app", + providerID: "codex", + modelID: "gpt-5" + ) + client.createdThread = created + let model = testRootModel(client: client) + + let result = await model.createThread( + projectID: "project-1", + title: created.title, + selection: .init(providerID: "codex", modelID: "gpt-5") + ) + + #expect(result == created) + #expect(model.snapshot.threads == [created]) + } + + @Test + func testSendAddsQueuedMessageBeforeServerEvent() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + let model = testRootModel(client: client) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: " ship it ", + selection: nil + ) + + #expect(sent) + #expect(client.sentText == "ship it") + #expect(model.details[thread.id]?.messages.last?.text == "ship it") + #expect(model.details[thread.id]?.messages.last?.state == .complete) + } + + @Test + func sendPreservesTheThreadAutomaticPermission() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .automatic + ) + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + let model = testRootModel(client: client) + await model.reload() + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Use the saved permission", + selection: nil + ) + + #expect(sent) + #expect(client.sentRuntimeModes == [.automatic]) + } + + @Test + func runtimeModeUpdatesAfterSuccessAndStaysPutAfterFailure() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .fullAccess + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + await model.setRuntimeMode(thread.id, mode: .automatic) + + #expect(client.setRuntimeModeCalls == [.automatic]) + #expect(model.snapshot.threads.first?.runtimeMode == .automatic) + + client.runtimeModeError = FeatureCapabilityUnavailable("Permission update failed") + await model.setRuntimeMode(thread.id, mode: .fullAccess) + + #expect(client.setRuntimeModeCalls == [.automatic, .fullAccess]) + #expect(model.snapshot.threads.first?.runtimeMode == .automatic) + } + + @Test + func restoredOutboxRetryPreservesAutomaticPermission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-permission-retry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + wireID: "thread-wire", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread", + runtimeMode: .fullAccess + ) + try await store.enqueue(FeatureQueuedSubmission( + environmentID: "environment-1", + identity: .init(threadID: "thread-wire"), + threadID: thread.id, + text: "Retry with Automatic", + selection: nil, + runtimeMode: .automatic, + interactionMode: .standard, + attachments: [] + )) + let delivery = AsyncStream.makeStream() + let client = FeatureClientStub() + client.beforeSendMessage = { delivery.continuation.yield() } + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + + await model.start() + _ = await delivery.stream.first { _ in true } + delivery.continuation.finish() + await model.disconnect() + + #expect(client.sentRuntimeModes == [.automatic]) + } + + @Test + func loadingEarlierTurnsPrependsHistoryAndClearsTheCursor() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Long thread" + ) + let recent = FeatureMessage( + id: "message-recent", + role: .assistant, + text: "Recent", + createdAt: Date(timeIntervalSince1970: 2) + ) + let older = FeatureMessage( + id: "message-older", + role: .user, + text: "Older", + createdAt: Date(timeIntervalSince1970: 1) + ) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [recent], + page: FeatureThreadPage(beforeCursor: "cursor-1", hasMore: true) + ) + client.earlierThreadDetail = FeatureThreadDetail( + thread: thread, + messages: [older, recent], + page: FeatureThreadPage(beforeCursor: nil, hasMore: false) + ) + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + await model.loadEarlierTurns(for: thread.id) + + #expect(model.details[thread.id]?.messages.map(\.id) == [older.id, recent.id]) + #expect(model.details[thread.id]?.page?.hasMore == false) + #expect(client.loadEarlierCallCount == 1) + } + + @Test + func failedDiscardKeepsTheDurableAndOptimisticSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-discard-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + client.beforeSendMessage = { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + } + client.sendMessageError = FeatureCapabilityUnavailable("Rejected message") + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Keep this queued", + selection: nil + ) + + #expect(!sent) + #expect(try await store.submissions().count == 1) + #expect(model.details[thread.id]?.messages.last?.text == "Keep this queued") + #expect(model.details[thread.id]?.messages.last?.state == .queued) + } + + @Test + func failedDeliveryCleanupKeepsTheDurableAndOptimisticSubmission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-completion-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + environmentID: "environment-1", + title: "Thread" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + threads: [thread] + ) + client.threadDetail = FeatureThreadDetail(thread: thread) + client.beforeSendMessage = { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + } + let model = FeatureRootModel(client: client, outboxStore: store) + await model.reload() + _ = await model.detail(for: thread.id) + + let sent = await model.sendMessage( + threadID: thread.id, + text: "Already delivered", + selection: nil + ) + + #expect(sent) + #expect(client.sendMessageCallCount == 1) + #expect(try await store.submissions().count == 1) + #expect(model.details[thread.id]?.messages.last?.text == "Already delivered") + #expect(model.details[thread.id]?.messages.last?.state == .queued) + #expect(model.errorMessage?.contains("delivered") == true) + } + + @Test + func failedEnvironmentOutboxCleanupKeepsPendingState() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-environment-cleanup-failure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + + let store = FeatureOutboxStore(fileURL: directory.appendingPathComponent("outbox.json")) + let identity = FeatureSubmissionIdentity( + threadID: "queued-thread", + commandID: "queued-command", + messageID: "queued-message" + ) + let submission = FeatureQueuedSubmission( + environmentID: "environment-1", + identity: identity, + threadID: "environment-1::thread::queued-thread", + text: "Create from the outbox", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [], + creation: .init( + projectID: "project-1", + projectName: "Native", + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false + ) + ) + try await store.enqueue(submission) + + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .disconnected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .disconnected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + client.snapshotAfterEnvironmentRemoval = FeatureSnapshot( + connection: .init(state: .disconnected) + ) + client.finishEvents() + let model = FeatureRootModel(client: client, outboxStore: store) + await model.start() + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o500))], + ofItemAtPath: directory.path + ) + + await model.removeEnvironment("environment-1") + + #expect(client.removedEnvironmentID == "environment-1") + #expect(model.snapshot.environments.isEmpty) + #expect(model.snapshot.threads.contains(where: { $0.id == submission.threadID })) + #expect(try await store.submissions() == [submission]) + #expect(model.errorMessage?.contains("queued messages") == true) + } + + @Test + func testNewTaskStartsThreadAndFirstTurnAtomically() async { + let client = FeatureClientStub() + let created = FeatureThread( + id: "thread-atomic", + projectID: "project-1", + title: "Ship the native app", + providerID: "codex", + modelID: "gpt-5.6-sol" + ) + client.createdThread = created + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [ + .init( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + let attachment = FeatureDraftAttachment( + data: Data([0xFF, 0xD8, 0xFF]), + filename: "reference.jpg", + mimeType: "image/jpeg" + ) + + let result = await model.startTask( + NewTaskRequest( + projectID: "project-1", + prompt: " Ship the native app ", + selection: .init(providerID: "codex", modelID: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .worktree, + branch: "main", + startFromOrigin: true, + attachments: [attachment] + ) + ) + + #expect(result == created) + #expect(client.startedPrompt == "Ship the native app") + #expect(client.startedAttachments.map(\.name) == ["reference.jpg"]) + #expect(client.startedWorkspaceMode == .worktree) + #expect(client.startedBranch == "main") + #expect(client.startedWorktreePath == nil) + #expect(client.startedFromOrigin) + #expect(client.createThreadCallCount == 0) + #expect(client.sendMessageCallCount == 0) + #expect(model.snapshot.threads == [created]) + } + + @Test( + "New-task composer grows beyond two lines with a software-keyboard viewport", + .bug("https://github.com/saphid/t3code-personal/issues/105") + ) + func newTaskComposerGrowsWithSoftwareKeyboardViewport() async throws { + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ) + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "environment-1", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ], + projects: [project], + providersByEnvironment: [ + "environment-1": [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol")] + ), + ], + ] + ) + let model = testRootModel(client: client) + await model.reload() + + let draftURL = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-new-task-keyboard-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: draftURL) } + let draftStore = FeatureComposerDraftStore(fileURL: draftURL) + let longDraft = (1...20).map { "Composer keyboard proof line \($0)" } + .joined(separator: "\n") + try await draftStore.setDraft( + FeatureComposerDraft(text: longDraft), + for: FeatureComposerDraftStore.newTaskKey(project: project) + ) + + let controller = UIHostingController( + rootView: NewThreadView( + model: model, + submit: { _ in nil }, + onCreated: { _ in }, + initialProjectID: project.id, + draftStore: draftStore + ) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 540)) + window.rootViewController = controller + window.isHidden = false + defer { window.isHidden = true } + + var textInput: UIView? + for _ in 0..<30 { + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + textInput = firstMultilineTextInput(in: controller.view) + if textInputText(textInput) == longDraft { break } + try await Task.sleep(for: .milliseconds(10)) + } + + let input = try #require(textInput) + #expect(textInputText(input) == longDraft) + #expect( + input.bounds.height >= 100, + "Expected room for more than two visible lines; got \(input.bounds.height) points" + ) + let inputFrame = input.convert(input.bounds, to: window) + #expect( + inputFrame.maxY <= window.bounds.height - 44, + "The text editor overlaps the composer controls: editor frame \(inputFrame), viewport \(window.bounds)" + ) + } + + @Test + func testArchiveAndDeleteKeepLocalListsConsistent() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setArchived(thread.id, archived: true) + #expect(model.snapshot.threads[0].isArchived) + + await model.deleteThread(thread.id) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func activeThreadsCannotBeArchivedOrSettled() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Running task", + state: .working, + supportsSettlement: true + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + await model.setArchived(thread.id, archived: true) + + #expect(model.snapshot.threads.first?.isArchived == false) + #expect(model.errorMessage?.contains("still active") == true) + + model.errorMessage = nil + await model.setSettled(thread.id, settled: true) + + #expect(model.snapshot.threads.first?.isSettled == false) + #expect(model.errorMessage?.contains("needs attention") == true) + } + + @Test + func cachedThreadRefreshShowsLoadingThenRetryWithoutHidingMessages() async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "cached", projectID: "project", title: "Cached thread") + let cached = FeatureThreadDetail( + thread: thread, + messages: [.init(id: "user", role: .user, text: "Do the task")] + ) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.loadThreadHandler = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + let refresh = Task { await model.detail(for: thread.id, force: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + #expect(model.detailLoadStates[thread.id] == .loading) + #expect(model.details[thread.id] == cached) + + response?.resume(throwing: URLError(.notConnectedToInternet)) + #expect(await refresh.value == cached) + guard case .failed = model.detailLoadStates[thread.id] else { + Issue.record("Expected an inline retry state for the cached thread") + return + } + #expect(model.errorMessage == nil) + #expect(model.details[thread.id]?.messages.first?.text == "Do the task") + + client.loadThreadHandler = nil + _ = await model.detail(for: thread.id, force: true) + #expect(model.detailLoadStates[thread.id] == nil) + } + + @Test + func threadRefreshPresentationShowsConnectionLossEvenWithCachedContent() { + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: true + ) == .loading) + #expect(ThreadRefreshPresentation.resolve( + loadState: .loading, connectionState: .connected, isOpening: false + ) == .loading) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .reconnecting, isOpening: false + ) == .reconnecting) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .disconnected, isOpening: false + ) == .offline) + #expect(ThreadRefreshPresentation.resolve( + loadState: .failed("Offline"), connectionState: .connected, isOpening: false + ) == .failed) + #expect(ThreadRefreshPresentation.resolve( + loadState: nil, connectionState: .connected, isOpening: false + ) == nil) + #expect(ThreadRefreshPresentation.failed.canRetry) + #expect(!ThreadRefreshPresentation.loading.canRetry) + } + + @Test + func testCancelledDetailRefreshKeepsCachedContentWithoutAlert() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let detail = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .assistant, text: "Still here"), + ] + ) + client.threadDetail = detail + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.loadThreadError = CancellationError() + + let refreshed = await model.detail(for: thread.id, force: true) + + #expect(refreshed == detail) + #expect(model.errorMessage == nil) + #expect(model.detailLoadStates[thread.id] == nil) + } + + @Test + func testResnoozeRefreshesTheOptimisticSnoozeTimestamp() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + state: .failed + ) + let oldSnooze = Date.now.addingTimeInterval(-600) + thread.snoozedAt = oldSnooze + thread.attentionAt = Date.now.addingTimeInterval(-300) + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setSnoozed( + thread.id, + until: Date.now.addingTimeInterval(3_600) + ) + + let updated = model.snapshot.threads[0] + #expect(updated.snoozedAt != oldSnooze) + #expect(updated.snoozedAt! > updated.attentionAt!) + } + + @Test + func testPinOptimisticallyWakesWithoutInventingSettlementOverride() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + isSettled: true, + settledAt: .now, + snoozedUntil: Date.now.addingTimeInterval(3_600), + snoozedAt: .now + ) + thread.supportsPinning = true + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setPinned(thread.id, pinned: true) + + let updated = model.snapshot.threads[0] + #expect(updated.pinnedAt != nil) + #expect(updated.isSettled) + #expect(!updated.keepsActive) + #expect(updated.settledAt != nil) + #expect(updated.snoozedUntil == nil) + } + + @Test + func testPinDoesNotMakeAnOrdinaryThreadPermanentlyActive() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread" + ) + client.createdThread = thread + let model = testRootModel(client: client) + _ = await model.createThread(projectID: thread.projectID, title: nil, selection: nil) + + await model.setPinned(thread.id, pinned: true) + await model.setPinned(thread.id, pinned: false) + + let updated = model.snapshot.threads[0] + #expect(updated.pinnedAt == nil) + #expect(!updated.keepsActive) + } + + @Test + func failedSettlementKeepsNewerActivityFacts() async { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Thread", + supportsSettlement: true, + settlementFacts: FeatureThreadSettlementFacts() + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.settlementError = URLError(.notConnectedToInternet) + let model = testRootModel(client: client) + await model.reload() + + client.beforeSettlementReturn = { + thread.settlementFacts?.sessionStatus = "running" + thread.settlementFacts?.hasPendingApprovals = true + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.threads.first?.settlementFacts?.sessionStatus + } onChange: { + continuation.resume() + } + client.emit(.thread(thread)) + } + } + let run = Task { await model.start() } + + #expect(!(await model.setSettled(thread.id, settled: true))) + client.finishEvents() + await run.value + + guard let restored = model.snapshot.threads.first else { + Issue.record("Expected the thread after settlement rollback") + return + } + #expect(restored.settlementFacts?.settlementOverride == nil) + #expect(restored.settlementFacts?.sessionStatus == "running") + #expect(restored.settlementFacts?.hasPendingApprovals == true) + } + + @Test + func testResolveUserInputForwardsTypedAnswersAndClearsTheRequest() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let request = FeatureUserInput( + id: "request-1", + threadID: thread.id, + questions: [] + ) + client.threadDetail = FeatureThreadDetail(thread: thread, userInputs: [request]) + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let answers: [String: FeatureInputAnswer] = [ + "scope": .selections(["Server", "Web"]), + "note": .text("Ship it"), + ] + await model.resolveUserInput(request.id, answers: answers) + + #expect(client.resolvedInputID == request.id) + #expect(client.resolvedInputAnswers == answers) + #expect(model.details[thread.id]?.userInputs.isEmpty == true) + } + + @Test + func granularThreadEventsMaintainCountsAndCollectionRevision() async { + let client = FeatureClientStub() + let project = FeatureProject( + id: "project-1", + environmentID: "environment-1", + name: "Native", + path: "/native" + ) + client.snapshot = FeatureSnapshot(projects: [project]) + let model = testRootModel(client: client) + let thread = FeatureThread( + id: "thread-1", + projectID: project.id, + title: "Stream deltas" + ) + + let run = Task { await model.start() } + client.emit(.thread(thread)) + client.emit(.thread(thread)) + client.emit(.threadRemoved(id: thread.id)) + let connected = FeatureConnection(state: .connected, environmentName: "Native") + client.emit(.connection(connected)) + client.emit(.connection(connected)) + client.finishEvents() + await run.value + + #expect(model.snapshot.threads.isEmpty) + #expect(model.snapshot.projects[0].threadCount == 0) + #expect(model.threadCollectionRevision == 2) + #expect(model.homePresentationRevision == 4) + } + + @Test + func initialDetailLoadDoesNotOverwriteNewerLiveUpdate() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let initial = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let live = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Live")] + ) + client.threadDetail = initial + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.detail(live)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == live) + #expect(model.details[thread.id] == live) + } + + @Test( + "Later overlapping detail load wins for either completion order", + .bug("https://github.com/pingdotgg/t3code/pull/7206#discussion_r3816827717"), + arguments: [[1, 2], [2, 1]] + ) + func laterOverlappingDetailLoadWins(completionOrder: [Int]) async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let initial = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let refreshed = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + let loadStarted = AsyncStream.makeStream() + var loadIndex = 0 + var loadContinuations: [Int: CheckedContinuation] = [:] + defer { + loadStarted.continuation.finish() + for continuation in loadContinuations.values { + continuation.resume(returning: refreshed) + } + } + client.loadThreadHandler = { _ in + loadIndex += 1 + let index = loadIndex + loadStarted.continuation.yield(index) + return await withCheckedContinuation { continuation in + loadContinuations[index] = continuation + } + } + let model = testRootModel(client: client) + var starts = loadStarted.stream.makeAsyncIterator() + + let initialLoad = Task { await model.detail(for: thread.id, force: true) } + let firstStart = await starts.next() + #expect(firstStart == 1) + let refresh = Task { await model.detail(for: thread.id, force: true) } + let secondStart = await starts.next() + #expect(secondStart == 2) + + for index in completionOrder { + let pendingContinuation = loadContinuations.removeValue(forKey: index) + let continuation = try #require(pendingContinuation) + continuation.resume(returning: index == 1 ? initial : refreshed) + if index == 1 { + _ = await initialLoad.value + if completionOrder.first == 1 { + #expect(model.detailLoadStates[thread.id] == .loading) + } + } else { + _ = await refresh.value + #expect(model.detailLoadStates[thread.id] == nil) + } + } + + let expectedInitialResult = completionOrder.first == 1 ? initial : refreshed + #expect(await initialLoad.value == expectedInitialResult) + #expect(await refresh.value == refreshed) + #expect(model.details[thread.id] == refreshed) + } + + @Test( + "Pagination does not cancel an overlapping detail refresh", + .bug("https://github.com/pingdotgg/t3code/pull/7206#discussion_r3816827717") + ) + func paginationDoesNotCancelOverlappingDetailRefresh() async throws { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Cached")], + page: FeatureThreadPage(beforeCursor: "cursor-1", hasMore: true) + ) + let paginated = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .user, text: "Earlier"), + cached.messages[0], + ], + page: FeatureThreadPage(beforeCursor: nil, hasMore: false) + ) + let refreshed = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-3", role: .assistant, text: "Refreshed")] + ) + client.threadDetail = cached + client.earlierThreadDetail = paginated + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + + let loadStarted = AsyncStream.makeStream() + var refreshContinuation: CheckedContinuation? + defer { + loadStarted.continuation.finish() + refreshContinuation?.resume(returning: refreshed) + } + client.loadThreadHandler = { _ in + loadStarted.continuation.yield(()) + return await withCheckedContinuation { continuation in + refreshContinuation = continuation + } + } + var starts = loadStarted.stream.makeAsyncIterator() + + let refresh = Task { await model.detail(for: thread.id, force: true) } + _ = await starts.next() + await model.loadEarlierTurns(for: thread.id) + let continuation = try #require(refreshContinuation) + refreshContinuation = nil + continuation.resume(returning: refreshed) + + #expect(await refresh.value == refreshed) + #expect(model.details[thread.id]?.messages == refreshed.messages) + } + + @Test + func initialDetailLoadDoesNotRestoreRemovedThread() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.detailRevisions[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.threadRemoved(id: thread.id)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == nil) + #expect(model.details[thread.id] == nil) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func initialDetailLoadMergesLatestThreadMetadata() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let intermediateThread = FeatureThread( + id: thread.id, + projectID: thread.projectID, + title: "Intermediate" + ) + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Cached")] + ) + let refreshed = FeatureThreadDetail( + thread: intermediateThread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.threadDetail = refreshed + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(intermediateThread)) + } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(thread)) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == thread) + #expect(loaded?.messages == refreshed.messages) + #expect(model.details[thread.id] == loaded) + #expect(model.snapshot.threads == [thread]) + } + + @Test + func initialDetailLoadKeepsMetadataFromThreadCreatedDuringLoad() async { + let client = FeatureClientStub() + let original = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let live = FeatureThread(id: original.id, projectID: original.projectID, title: "Live") + let created = FeatureThread(id: original.id, projectID: original.projectID, title: "Created") + client.snapshot = FeatureSnapshot(threads: [original]) + client.threadDetail = FeatureThreadDetail(thread: original) + let model = testRootModel(client: client) + _ = await model.detail(for: original.id) + let run = Task { await model.start() } + client.createdThread = created + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[original.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.thread(live)) + } + _ = await model.createThread(projectID: original.projectID, title: nil, selection: nil) + } + + let loaded = await model.detail(for: original.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == created) + #expect(model.details[original.id]?.thread == created) + #expect(model.snapshot.threads == [created]) + } + + @Test + func duplicateThreadEventDuringRefreshDoesNotDiscardLoadedMetadata() async { + let client = FeatureClientStub() + let original = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let refreshed = FeatureThread(id: original.id, projectID: original.projectID, title: "Refreshed") + client.snapshot = FeatureSnapshot(threads: [original]) + client.threadDetail = FeatureThreadDetail(thread: original) + let model = testRootModel(client: client) + _ = await model.detail(for: original.id) + client.threadDetail = FeatureThreadDetail(thread: refreshed) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.snapshot.connection + } onChange: { + continuation.resume() + } + client.emit(.thread(original)) + client.emit(.connection(.init(state: .connected))) + } + } + + let loaded = await model.detail(for: original.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == refreshed) + #expect(model.details[original.id]?.thread == refreshed) + #expect(model.snapshot.threads == [refreshed]) + } + + @Test + func initialDetailLoadDoesNotRestoreResolvedApproval() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + let approval = FeatureApproval( + id: "approval-1", + threadID: thread.id, + kind: .command, + title: "Run command", + detail: "swift test" + ) + let stale = FeatureThreadDetail(thread: thread, approvals: [approval]) + client.threadDetail = stale + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.beforeLoadThreadReturn = { + await model.resolveApproval(approval.id, decision: .allowOnce) + } + + let loaded = await model.detail(for: thread.id, force: true) + + #expect(loaded?.approvals.isEmpty == true) + #expect(model.details[thread.id]?.approvals.isEmpty == true) + } + + @Test + func initialDetailLoadDoesNotRestoreThreadRemovedBySnapshot() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Thread") + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Initial")] + ) + let model = testRootModel(client: client) + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.detailRevisions[thread.id] + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot())) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded == nil) + #expect(model.details[thread.id] == nil) + #expect(model.snapshot.threads.isEmpty) + } + + @Test + func initialDetailLoadMergesLatestSnapshotMetadata() async { + let client = FeatureClientStub() + let thread = FeatureThread(id: "thread-1", projectID: "project-1", title: "Original") + let intermediateThread = FeatureThread( + id: thread.id, + projectID: thread.projectID, + title: "Intermediate" + ) + let cached = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Cached")] + ) + let refreshed = FeatureThreadDetail( + thread: intermediateThread, + messages: [FeatureMessage(id: "message-2", role: .assistant, text: "Refreshed")] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + client.threadDetail = cached + let model = testRootModel(client: client) + _ = await model.detail(for: thread.id) + client.threadDetail = refreshed + let run = Task { await model.start() } + client.beforeLoadThreadReturn = { + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot(threads: [intermediateThread]))) + } + await withCheckedContinuation { continuation in + withObservationTracking { + _ = model.details[thread.id]?.thread + } onChange: { + continuation.resume() + } + client.emit(.snapshot(FeatureSnapshot(threads: [thread]))) + } + } + + let loaded = await model.detail(for: thread.id, force: true) + client.finishEvents() + await run.value + + #expect(loaded?.thread == thread) + #expect(loaded?.messages == refreshed.messages) + #expect(model.details[thread.id] == loaded) + #expect(model.snapshot.threads == [thread]) + } + + @Test + func environmentScopedCatalogAndPreferencesInvalidateHomePresentation() async { + let client = FeatureClientStub() + let model = testRootModel(client: client) + client.snapshot = FeatureSnapshot( + providersByEnvironment: [ + "studio": [ + .init( + id: "codex", + name: "Codex", + models: [.init(id: "gpt-5.6-sol", name: "Sol")] + ), + ], + ] + ) + + await model.reload() + let catalogRevision = model.homePresentationRevision + #expect(catalogRevision == 1) + + client.snapshot.preferencesByEnvironment = [ + "studio": .init(defaultWorkspaceMode: .worktree), + ] + await model.reload() + + #expect(model.homePresentationRevision == catalogRevision + 1) + } + + @Test + func providerRefreshRoutesOnlyToChosenEnvironment() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot(providersByEnvironment: [ + "left": [.init(id: "old-left", name: "Old left")], + "right": [.init(id: "old-right", name: "Old right")], + ]) + client.refreshedProviders = [.init(id: "new-left", name: "New left")] + let model = testRootModel(client: client) + await model.reload() + + let didRefresh = await model.refreshProviders(environmentID: "left") + + #expect(didRefresh) + #expect(client.refreshedProviderEnvironmentID == "left") + #expect(model.snapshot.providersByEnvironment?["left"] == client.refreshedProviders) + #expect( + model.snapshot.providersByEnvironment?["right"] + == [.init(id: "old-right", name: "Old right")] + ) + } + + @Test + func automaticSettlementUpdatesRemainEnvironmentScopedAndFailuresKeepVisibleValues() async { + let client = FeatureClientStub() + client.snapshot = FeatureSnapshot( + preferencesByEnvironment: [ + "left": .init( + automaticSettlement: .init(onMerge: true, afterDays: 3) + ), + "right": .init( + automaticSettlement: .init(onMerge: false, afterDays: 7.5) + ), + ] + ) + let model = testRootModel(client: client) + await model.reload() + + client.automaticSettlementResult = .init(onMerge: false, afterDays: 3) + let didUpdate = await model.updateAutomaticSettlement( + environmentID: "left", + change: .onMerge(false) + ) + + #expect(didUpdate) + #expect(client.automaticSettlementEnvironmentID == "left") + #expect(client.automaticSettlementChange == .onMerge(false)) + #expect( + model.snapshot.preferencesByEnvironment?["left"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 3) + ) + #expect( + model.snapshot.preferencesByEnvironment?["right"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 7.5) + ) + + client.automaticSettlementError = FeatureCapabilityUnavailable( + "Automatic settlement settings" + ) + let didFail = await model.updateAutomaticSettlement( + environmentID: "right", + change: .afterDays(nil) + ) + + #expect(!didFail) + #expect( + model.snapshot.preferencesByEnvironment?["right"]?.automaticSettlement + == FeatureAutomaticSettlementSettings(onMerge: false, afterDays: 7.5) + ) + #expect( + model.errorMessage + == "Automatic settlement settings is not supported by this environment." + ) + } + + @Test + func stalePullRequestResponseCannotReplaceANewBranchIdentity() async throws { + let client = FeatureClientStub() + var thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "studio", + title: "Task", + branch: "feature/old", + worktreePath: "/repo" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + await model.reload() + + let oldIdentity = try #require(thread.pullRequestObservationIdentity) + model.updatePullRequest( + HomeThreadPullRequestPresentation(number: 1, state: .merged, updatedAt: .now), + threadID: thread.id, + observationIdentity: oldIdentity + ) + #expect(model.pullRequestsByThreadID[thread.id]?.number == 1) + + thread.branch = "feature/new" + client.snapshot.threads = [thread] + await model.reload() + #expect(model.pullRequestsByThreadID[thread.id] == nil) + + model.updatePullRequest( + HomeThreadPullRequestPresentation(number: 1, state: .closed, updatedAt: .now), + threadID: thread.id, + observationIdentity: oldIdentity + ) + #expect(model.pullRequestsByThreadID[thread.id] == nil) + } + + @Test + func responseTimeoutKeepsDurableSubmissionQueued() { + let snapshot = FeatureSnapshot( + connection: .init(state: .connected), + environments: [ + .init( + id: "studio", + name: "Studio", + endpoint: "https://studio.example", + isActive: true, + connectionState: .connected + ), + ] + ) + + #expect( + FeatureRootModel.shouldQueue( + RPCError.responseTimedOut, + environmentID: "studio", + snapshot: snapshot + ) + ) + } + + @Test + func detailEventsIgnoreDuplicatesAndAdvancePerThreadRevision() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Stream transcript" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let first = FeatureThreadDetail( + thread: thread, + messages: [FeatureMessage(id: "message-1", role: .assistant, text: "Hel")] + ) + let second = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage(id: "message-1", role: .assistant, text: "Hello"), + FeatureMessage(id: "message-2", role: .user, text: "Ship it"), + ] + ) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(first)) + client.emit(.detail(first)) + client.emit(.detail(second)) + client.finishEvents() + await run.value + + #expect(model.details[thread.id] == second) + #expect(model.detailRevision == 2) + #expect(model.detailRevisions[thread.id] == 2) + } + + @Test + func authoritativeAttachmentRetainsLocalPreviewUntilURLHydrates() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Image preview" + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let preview = Data([0x01, 0x02, 0x03]) + let local = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage( + id: "message-1", + role: .user, + text: "See image", + attachments: [ + FeatureMessageAttachment( + id: "local-attachment", + name: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 3, + previewData: preview + ), + ] + ), + ] + ) + let authoritative = FeatureThreadDetail( + thread: thread, + messages: [ + FeatureMessage( + id: "message-1", + role: .user, + text: "See image", + attachments: [ + FeatureMessageAttachment( + id: "server-attachment", + name: "image.jpg", + mimeType: "image/jpeg", + sizeBytes: 3 + ), + ] + ), + ] + ) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(local)) + client.emit(.detail(authoritative)) + client.finishEvents() + await run.value + + #expect(model.details[thread.id]?.messages[0].attachments[0].id == "server-attachment") + #expect(model.details[thread.id]?.messages[0].attachments[0].previewData == preview) + } + + @Test + func detailDeltaCarriesAContiguousRenderCursor() async { + let client = FeatureClientStub() + let thread = FeatureThread( + id: "thread-1", + projectID: "project-1", + title: "Incremental transcript" + ) + let firstMessage = FeatureMessage(id: "message-1", role: .assistant, text: "Hel") + let completedMessage = FeatureMessage(id: "message-1", role: .assistant, text: "Hello") + let appendedMessage = FeatureMessage(id: "message-2", role: .user, text: "Ship it") + let first = FeatureThreadDetail(thread: thread, messages: [firstMessage]) + let second = FeatureThreadDetail( + thread: thread, + messages: [completedMessage, appendedMessage] + ) + client.snapshot = FeatureSnapshot(threads: [thread]) + let model = testRootModel(client: client) + + let run = Task { await model.start() } + client.emit(.detail(first)) + client.emit(.detailDelta( + second, + FeatureDetailDelta( + changedMessages: [completedMessage, appendedMessage], + appendedMessageIDs: [appendedMessage.id] + ) + )) + client.finishEvents() + await run.value + + #expect(model.details[thread.id] == second) + #expect(model.detailRevisions[thread.id] == 2) + guard let update = model.detailRenderUpdates[thread.id] else { + Issue.record("Expected an incremental render update") + return + } + #expect(update.baseRevision == 1) + #expect(update.revision == 2) + guard case let .delta(delta) = update.change else { + Issue.record("Expected a detail delta") + return + } + #expect(delta.appendedMessageIDs == [appendedMessage.id]) + #expect(delta.changedMessages == [completedMessage, appendedMessage]) + } + + @Test + func detailReducerAppendsStreamingTailAndExposesRenderMutation() { + let startedAt = "2026-07-31T20:00:00Z" + let message = OrchestrationMessage( + id: "message-1", + role: "assistant", + text: "Hel", + attachments: nil, + turnId: "turn-1", + streaming: true, + createdAt: startedAt, + updatedAt: startedAt + ) + let thread = orchestrationThread(messages: [message]) + let event = orchestrationEvent( + type: "thread.message-sent", + sequence: 12, + payload: [ + "threadId": .string(thread.id), + "messageId": .string(message.id), + "role": .string("assistant"), + "text": .string("lo"), + "turnId": .string("turn-1"), + "streaming": .bool(true), + "createdAt": .string(startedAt), + "updatedAt": .string("2026-07-31T20:00:01Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + #expect(reduction.sequence == 12) + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected a streaming message update") + return + } + #expect(updated.messages[0].text == "Hello") + #expect(updated.messages[0].updatedAt == startedAt) + guard case let .message(rendered) = reduction.renderMutation else { + Issue.record("Expected a message-only render mutation") + return + } + #expect(rendered.text == "Hello") + } + + @Test + func detailReducerBindsCheckpointThatArrivedBeforeAssistantMessage() { + let checkpoint = CheckpointSummary( + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoint-1", + status: "completed", + files: [], + assistantMessageId: nil, + completedAt: "2026-07-31T20:00:01Z" + ) + let thread = orchestrationThread(checkpoints: [checkpoint]) + let event = orchestrationEvent( + type: "thread.message-sent", + sequence: 12, + payload: [ + "threadId": .string(thread.id), + "messageId": .string("assistant-1"), + "role": .string("assistant"), + "text": .string("Done"), + "turnId": .string("turn-1"), + "streaming": .bool(false), + "createdAt": .string("2026-07-31T20:00:00Z"), + "updatedAt": .string("2026-07-31T20:00:02Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected an assistant message update") + return + } + #expect(updated.checkpoints.first?.assistantMessageId == "assistant-1") + } + + @Test + func activityReducerKeepsLargeSnapshotHistorySharedAndExposesOnlyTheTail() throws { + let historical = (0..<1_000).map { (index: Int) in + OrchestrationActivity( + id: "history-\(index)", + tone: "info", + kind: "tool.completed", + summary: "Historical work", + payload: .object([:]), + turnId: "turn-1", + sequence: index, + createdAt: "2026-07-31T20:00:00Z" + ) + } + let appended = OrchestrationActivity( + id: "activity-new", + tone: "info", + kind: "tool.completed", + summary: "New work", + payload: .object([:]), + turnId: "turn-1", + sequence: historical.count, + createdAt: "2026-07-31T20:00:01Z" + ) + let thread = orchestrationThread(activities: historical) + let event = orchestrationEvent( + type: "thread.activity-appended", + sequence: 1_001, + payload: [ + "threadId": .string(thread.id), + "activity": try JSONValue.encode(appended), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected an activity update") + return + } + #expect(updated.activities.count == historical.count) + guard case let .activity(rendered) = reduction.renderMutation else { + Issue.record("Expected an activity-tail render mutation") + return + } + #expect(rendered == appended) + } + + @Test + func destructiveDetailEventRequestsAuthoritativeSnapshot() { + let thread = orchestrationThread() + let event = orchestrationEvent( + type: "thread.reverted", + sequence: 3, + payload: ["threadId": .string(thread.id)] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + #expect(reduction.result == .refresh) + #expect(reduction.renderMutation == .full) + } + + @Test + func detailReducerAppliesServerSettlementEvents() { + let thread = orchestrationThread() + let settled = orchestrationEvent( + type: "thread.settled", + sequence: 4, + payload: [ + "threadId": .string(thread.id), + "settledAt": .string("2026-07-31T20:00:03Z"), + "updatedAt": .string("2026-07-31T20:00:03Z"), + ] + ) + + guard case let .updated(settledThread) = NativeThreadDetailReducer + .apply(settled, to: thread).result else { + Issue.record("Expected a settled thread") + return + } + #expect(settledThread.settledOverride == "settled") + #expect(settledThread.settledAt == "2026-07-31T20:00:03Z") + #expect(settledThread.unsettledAt == nil) + + let unsettled = orchestrationEvent( + type: "thread.unsettled", + sequence: 5, + payload: [ + "threadId": .string(thread.id), + "reason": .string("user"), + "updatedAt": .string("2026-07-31T20:00:04Z"), + ] + ) + guard case let .updated(activeThread) = NativeThreadDetailReducer + .apply(unsettled, to: settledThread).result else { + Issue.record("Expected an active thread") + return + } + #expect(activeThread.settledOverride == "active") + #expect(activeThread.settledAt == nil) + #expect(activeThread.unsettledAt == "2026-07-31T20:00:04Z") + + let activityReset = orchestrationEvent( + type: "thread.unsettled", + sequence: 6, + payload: [ + "threadId": .string(thread.id), + "reason": .string("activity"), + "updatedAt": .string("2026-07-31T20:00:05Z"), + ] + ) + guard case let .updated(resetThread) = NativeThreadDetailReducer + .apply(activityReset, to: activeThread).result else { + Issue.record("Expected an activity reset") + return + } + #expect(resetThread.settledOverride == nil) + #expect(resetThread.unsettledAt == "2026-07-31T20:00:04Z") + } + + @Test + func linkedPullRequestUpdatesDoNotReloadTheEntireThread() throws { + let thread = orchestrationThread() + let link = ThreadLinkedPullRequest( + projectId: thread.projectId, + repository: "pingdotgg/t3code", + number: 5178, + url: "https://github.com/pingdotgg/t3code/pull/5178" + ) + let event = orchestrationEvent( + type: "thread.meta-updated", + sequence: 7, + payload: [ + "threadId": .string(thread.id), + "linkedPullRequest": try JSONValue.encode(link), + "updatedAt": .string("2026-08-25T12:00:00Z"), + ] + ) + + let reduction = NativeThreadDetailReducer.apply(event, to: thread) + + guard case let .updated(updated) = reduction.result else { + Issue.record("Expected the linked pull request to update without a full refresh") + return + } + #expect(updated.linkedPullRequest == link) + #expect(reduction.renderMutation == .metadata) + + let unlink = orchestrationEvent( + type: "thread.meta-updated", + sequence: 8, + payload: [ + "threadId": .string(thread.id), + "linkedPullRequest": .null, + "updatedAt": .string("2026-08-25T12:01:00Z"), + ] + ) + guard case let .updated(unlinked) = NativeThreadDetailReducer.apply(unlink, to: updated).result else { + Issue.record("Expected the pull request link to clear") + return + } + #expect(unlinked.linkedPullRequest == nil) + } +} + +@MainActor +private func firstMultilineTextInput(in view: UIView) -> UIView? { + if view is UITextView || view is UITextField { + return view + } + for subview in view.subviews { + if let input = firstMultilineTextInput(in: subview) { + return input + } + } + return nil +} + +@MainActor +private func textInputText(_ view: UIView?) -> String? { + if let textView = view as? UITextView { + return textView.text + } + if let textField = view as? UITextField { + return textField.text + } + return nil +} + +@MainActor +private func testRootModel(client: FeatureClientStub) -> FeatureRootModel { + FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("t3-root-outbox-\(UUID().uuidString).json") + ) + ) +} + +private func orchestrationEvent( + type: String, + sequence: Int, + payload: [String: JSONValue] +) -> JSONValue { + .object([ + "type": .string(type), + "sequence": .number(Double(sequence)), + "occurredAt": .string("2026-07-31T20:00:02Z"), + "payload": .object(payload), + ]) +} + +private func orchestrationThread( + messages: [OrchestrationMessage] = [], + activities: [OrchestrationActivity] = [], + checkpoints: [CheckpointSummary] = [] +) -> OrchestrationThread { + OrchestrationThread( + id: "thread-1", + projectId: "project-1", + title: "Native detail stream", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "main", + worktreePath: "/native", + latestTurn: nil, + createdAt: "2026-07-31T20:00:00Z", + updatedAt: "2026-07-31T20:00:00Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: messages, + activities: activities, + checkpoints: checkpoints, + session: nil + ) +} + +@MainActor +private final class FeatureClientStub: FeatureClient, T3ConnectCapable { + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + var snapshot = FeatureSnapshot() + var backgroundSnapshotValue: FeatureSnapshot? + var snapshotAfterEnvironmentToggle: FeatureSnapshot? + var initialSnapshotCallCount = 0 + var backgroundSnapshotCallCount = 0 + var snapshotAfterPair: FeatureSnapshot? + var snapshotAfterEnvironmentRemoval: FeatureSnapshot? + var createdThread = FeatureThread(id: "created", projectID: "project", title: "Created") + var threadDetail: FeatureThreadDetail? + var earlierThreadDetail: FeatureThreadDetail? + var pairEndpoint: String? + var pairToken: String? + var sentText: String? + var startedPrompt: String? + var startedAttachments: [FeatureUploadAttachment] = [] + var startedWorkspaceMode: FeatureWorkspaceMode? + var startedBranch: String? + var startedWorktreePath: String? + var startedFromOrigin = false + var createThreadCallCount = 0 + var sendMessageCallCount = 0 + var sentRuntimeModes: [FeatureRuntimeMode] = [] + var setRuntimeModeCalls: [FeatureRuntimeMode] = [] + var cancelTurnCallCount = 0 + var signOutCallCount = 0 + var startTaskError: (any Error)? + var sendMessageError: (any Error)? + var runtimeModeError: (any Error)? + var settlementError: (any Error)? + var beforeSettlementReturn: (() async -> Void)? + var enabledEnvironmentID: String? + var environmentEnabledValue: Bool? + var removedEnvironmentID: String? + var beforeStartTask: (() async throws -> Void)? + var beforeSendMessage: (() throws -> Void)? + var loadThreadError: (any Error)? + var loadThreadHandler: ((String) async throws -> FeatureThreadDetail)? + var beforeLoadThreadReturn: (() async -> Void)? + var loadEarlierCallCount = 0 + var resolvedInputID: String? + var resolvedInputAnswers: [String: FeatureInputAnswer]? + var savedSettings: [FeatureSettings] = [] + var refreshedProviderEnvironmentID: String? + var refreshedProviders: [FeatureProvider] = [] + var automaticSettlementEnvironmentID: String? + var automaticSettlementChange: FeatureAutomaticSettlementChange? + var automaticSettlementResult = FeatureAutomaticSettlementSettings( + onMerge: true, + afterDays: 3 + ) + var automaticSettlementError: (any Error)? + lazy var t3ConnectController = T3ConnectController( + resolution: .unavailable(reason: "T3 Connect is disabled in feature tests.") + ) + + init() { + let pair = AsyncStream.makeStream() + eventStream = pair.stream + eventContinuation = pair.continuation + } + + func events() -> AsyncStream { + eventStream + } + + func emit(_ event: FeatureEvent) { + eventContinuation.yield(event) + } + + func finishEvents() { + eventContinuation.finish() + } + + func initialSnapshot() async throws -> FeatureSnapshot { + initialSnapshotCallCount += 1 + if removedEnvironmentID != nil, let snapshotAfterEnvironmentRemoval { + return snapshotAfterEnvironmentRemoval + } + if pairEndpoint != nil, let snapshotAfterPair { + return snapshotAfterPair + } + if enabledEnvironmentID != nil, let snapshotAfterEnvironmentToggle { + return snapshotAfterEnvironmentToggle + } + return snapshot + } + + func backgroundSnapshot() async throws -> FeatureSnapshot { + backgroundSnapshotCallCount += 1 + return backgroundSnapshotValue ?? snapshot + } + + func pair(endpoint: String, token: String?) async throws { + pairEndpoint = endpoint + pairToken = token + } + + func setEnvironmentEnabled(id: String, enabled: Bool) async throws { + enabledEnvironmentID = id + environmentEnabledValue = enabled + } + + func removeEnvironment(id: String) async throws { + removedEnvironmentID = id + } + + func connectT3Environment( + _ credential: T3ConnectManagedEnvironmentCredential + ) async throws {} + + func signOutT3Connect() async { + signOutCallCount += 1 + let removedIDs = Set(snapshot.environments.filter { $0.source == .t3Connect }.map(\.id)) + snapshot.environments.removeAll { removedIDs.contains($0.id) } + snapshot.projects.removeAll { removedIDs.contains($0.environmentID) } + snapshot.threads.removeAll { + $0.environmentID.map(removedIDs.contains) ?? false + } + } + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + createThreadCallCount += 1 + return createdThread + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + if let startTaskError { throw startTaskError } + startedPrompt = prompt + startedAttachments = attachments + return createdThread + } + + func createThreadAndSend( + projectID: String, + prompt: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + interactionMode: FeatureInteractionMode, + workspaceMode: FeatureWorkspaceMode, + branch: String?, + worktreePath: String?, + startFromOrigin: Bool, + attachments: [FeatureUploadAttachment] + ) async throws -> FeatureThread { + try await beforeStartTask?() + if let startTaskError { throw startTaskError } + startedPrompt = prompt + startedAttachments = attachments + startedWorkspaceMode = workspaceMode + startedBranch = branch + startedWorktreePath = worktreePath + startedFromOrigin = startFromOrigin + return createdThread + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func setRuntimeMode(id: String, mode: FeatureRuntimeMode) async throws { + setRuntimeModeCalls.append(mode) + if let runtimeModeError { throw runtimeModeError } + } + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + if let loadThreadError { + throw loadThreadError + } + if let loadThreadHandler { + return try await loadThreadHandler(id) + } + await beforeLoadThreadReturn?() + if let threadDetail { + return threadDetail + } + return FeatureThreadDetail(thread: createdThread) + } + + func loadEarlierThreadTurns(id: String) async throws -> FeatureThreadDetail? { + loadEarlierCallCount += 1 + return earlierThreadDetail + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws { + sendMessageCallCount += 1 + try beforeSendMessage?() + if let sendMessageError { throw sendMessageError } + sentText = text + } + + func sendMessage( + threadID: String, + text: String, + selection: FeatureSelection?, + runtimeMode: FeatureRuntimeMode, + attachments _: [FeatureUploadAttachment], + identity _: FeatureSubmissionIdentity + ) async throws { + sentRuntimeModes.append(runtimeMode) + try await sendMessage(threadID: threadID, text: text, selection: selection) + } + + func cancelTurn(threadID: String) async throws { + cancelTurnCallCount += 1 + } + func setThreadSettled(id: String, settled: Bool) async throws { + await beforeSettlementReturn?() + if let settlementError { throw settlementError } + } + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func resolveUserInput( + id: String, + answers: [String: FeatureInputAnswer] + ) async throws { + resolvedInputID = id + resolvedInputAnswers = answers + } + func saveSettings(_ settings: FeatureSettings) async throws { + savedSettings.append(settings) + } + func refreshProviders(environmentID: String) async throws -> [FeatureProvider] { + refreshedProviderEnvironmentID = environmentID + return refreshedProviders + } + func updateAutomaticSettlement( + environmentID: String, + change: FeatureAutomaticSettlementChange + ) async throws -> FeatureAutomaticSettlementSettings { + automaticSettlementEnvironmentID = environmentID + automaticSettlementChange = change + if let automaticSettlementError { throw automaticSettlementError } + return automaticSettlementResult + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift new file mode 100644 index 000000000000..295eb8e69a27 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift @@ -0,0 +1,392 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Thread tool state") +struct FeatureToolStateTests { + @Test + func fileFilteringKeepsDirectoriesFirstAndHonorsHiddenFiles() { + let entries = [ + FeatureFileEntry(path: "z.swift", name: "z.swift", kind: .file), + FeatureFileEntry(path: ".env", name: ".env", kind: .file, isHidden: true), + FeatureFileEntry(path: "Sources", name: "Sources", kind: .directory), + FeatureFileEntry(path: "a.swift", name: "a.swift", kind: .file), + ] + + #expect(entries.featureFiltered(by: "", includesHidden: false).map(\.name) == [ + "Sources", "a.swift", "z.swift", + ]) + #expect(entries.featureFiltered(by: "env", includesHidden: true).map(\.name) == [".env"]) + } + + @Test + func filePreviewKindUsesImageMarkdownAndSourceSemantics() { + #expect(FeatureFilePreviewKind.infer(path: "art/hero.webp") == .image) + #expect(FeatureFilePreviewKind.infer(path: "docs/spec.pdf") == .pdf) + #expect(FeatureFilePreviewKind.infer(path: "demo.mov") == .video) + #expect(FeatureFilePreviewKind.infer(path: "brief.docx") == .document) + #expect(FeatureFilePreviewKind.infer(path: "README.md") == .markdown) + #expect(FeatureFilePreviewKind.infer(path: "Package.swift") == .source) + #expect(FeatureFilePreviewKind.infer(path: "LICENSE") == .plainText) + #expect(FeatureFilePreviewKind.infer(path: "template", language: "html") == .source) + } + + @Test + func previewFileNamesDropPathsAndRejectEmptyNames() throws { + #expect(try FeatureMediaPreviewFiles.safeFileName("reports/final.pdf") == "final.pdf") + #expect(try FeatureMediaPreviewFiles.safeFileName("clip:one.mov") == "clip_one.mov") + #expect(throws: FeatureMediaPreviewError.invalidFileName) { + try FeatureMediaPreviewFiles.safeFileName(" ") + } + } + + @Test + func previewDirectoriesHaveUniqueOwnership() throws { + let first = try FeatureMediaPreviewFiles.ownedDirectory() + let second = try FeatureMediaPreviewFiles.ownedDirectory() + defer { + try? FileManager.default.removeItem(at: first) + try? FileManager.default.removeItem(at: second) + } + #expect(first != second) + #expect(FileManager.default.fileExists(atPath: first.path)) + #expect(FileManager.default.fileExists(atPath: second.path)) + } + + @Test + func remotePreviewNeverSharesItsSignedSourceURL() { + let signedURL = URL(string: "https://example.com/file.pdf?token=secret")! + let downloadedURL = URL(fileURLWithPath: "/tmp/owned/file.pdf") + #expect( + FeatureMediaPreviewFiles.shareURL( + for: .remote(signedURL), + downloadedURL: nil + ) == nil + ) + #expect( + FeatureMediaPreviewFiles.shareURL( + for: .remote(signedURL), + downloadedURL: downloadedURL + ) == downloadedURL + ) + } + + @Test + func typedMediaPreviewRouteKeepsHostPathAndKind() { + var components = URLComponents() + components.scheme = "t3code" + components.host = "media-preview" + components.path = "/open" + components.queryItems = [ + URLQueryItem(name: "path", value: "/tmp/output/final image.png"), + URLQueryItem(name: "kind", value: "image"), + ] + #expect( + FeatureTypedMediaPreviewRoute.parse(components.url!) + == FeatureTypedMediaPreviewRoute( + path: "/tmp/output/final image.png", + kind: .image + ) + ) + #expect( + FeatureTypedMediaPreviewRoute.parse( + URL(string: "t3code://media-preview/open?path=/tmp/a.png&kind=pdf")! + ) == nil + ) + } + + @Test + func previewGenerationRejectsCompletionAfterDismissal() { + var generation = FeatureMediaPreviewGeneration() + let downloadGeneration = generation.begin() + #expect(generation.isCurrent(downloadGeneration)) + generation.invalidate() + #expect(!generation.isCurrent(downloadGeneration)) + } + + @Test + func sourceHighlighterPreservesTextAndClassifiesStableSpans() { + let source = """ + let count = 42 // total + /* first + second */ return "done" + """ + let lines = FeatureSourceHighlighter.lines(text: source, language: "swift") + + #expect(lines.map(\.text).joined(separator: "\n") == source) + #expect(lines[0].spans.contains { $0.text == "let" && $0.kind == .keyword }) + #expect(lines[0].spans.contains { $0.text == "42" && $0.kind == .number }) + #expect(lines[0].spans.last?.kind == .comment) + #expect(lines[1].spans.allSatisfy { $0.kind == .comment }) + #expect(lines[2].spans.first?.kind == .comment) + #expect(lines[2].spans.contains { $0.text.contains("return") && $0.kind == .keyword }) + #expect(lines[2].spans.last?.kind == .literal) + } + + @Test + func sourceHighlighterRecognizesJSONProperties() { + let line = FeatureSourceHighlighter.lines( + text: #"{"enabled": true, "count": 3}"#, + language: "json" + )[0] + + #expect(line.spans.contains { $0.text == #""enabled""# && $0.kind == .property }) + #expect(line.spans.contains { $0.text == "true" && $0.kind == .literal }) + #expect(line.spans.contains { $0.text == "3" && $0.kind == .number }) + } + + @Test + func sourceHighlighterBoundsWorkForLargeMinifiedLines() { + let source = String(repeating: #"{"value":42}"#, count: 3_000) + let line = FeatureSourceHighlighter.lines(text: source, language: "json")[0] + + #expect(line.text == source) + #expect(line.spans == [FeatureSourceSpan(text: source, kind: .plain)]) + } + + @Test + func reviewTotalsAggregateAcrossFiles() { + let review = FeatureReview(files: [ + FeatureReviewFile(path: "a.swift", change: .modified, additions: 4, deletions: 1), + FeatureReviewFile(path: "b.swift", change: .added, additions: 8, deletions: 0), + ]) + + #expect(review.additions == 12) + #expect(review.deletions == 1) + } + + @Test + func wordDiffHighlightsOnlyChangedTokens() { + let result = FeatureDiffWordHighlighter.spans( + old: "let color = blue", + new: "let color = green" + ) + + #expect(result.old.map(\.text).joined() == "let color = blue") + #expect(result.new.map(\.text).joined() == "let color = green") + #expect(result.old.filter { $0.kind == .changed }.map(\.text) == ["blue"]) + #expect(result.new.filter { $0.kind == .changed }.map(\.text) == ["green"]) + } + + @Test + func workspaceReviewMapperPairsReplacementLinesAndCarriesBaseReference() { + let preview = ReviewDiffPreview( + cwd: "/tmp/project", + generatedAt: "2026-08-01T00:00:00Z", + sources: [ + ReviewDiffSource( + id: "working-tree", + kind: "working-tree", + title: "Working tree", + baseRef: "main", + headRef: nil, + diff: """ + diff --git a/App.swift b/App.swift + --- a/App.swift + +++ b/App.swift + @@ -1,1 +1,1 @@ + -let color = blue + +let color = green + """, + diffHash: "hash", + truncated: false + ), + ] + ) + + let review = NativeWorkspaceMapper.review(preview) + let deletion = review.files[0].lines.first { $0.kind == .deletion } + let addition = review.files[0].lines.first { $0.kind == .addition } + + #expect(review.baseReference == "main") + #expect(review.files[0].sourceKind == "working-tree") + #expect(review.files[0].sourceBaseReference == "main") + #expect(deletion?.spans?.filter { $0.kind == .changed }.map(\.text) == ["blue"]) + #expect(addition?.spans?.filter { $0.kind == .changed }.map(\.text) == ["green"]) + } + + @Test + func fullDiffHydrationRestoresUnchangedRegionsWithoutLosingPatchRows() { + let file = FeatureReviewFile( + path: "App.swift", + change: .modified, + additions: 1, + deletions: 1, + lines: [ + .init(id: "hunk", kind: .hunk, text: "@@ -2,2 +2,2 @@"), + .init(id: "old", kind: .deletion, oldLine: 2, text: "let color = blue"), + .init(id: "new", kind: .addition, newLine: 2, text: "let color = green"), + .init(id: "after", kind: .context, oldLine: 3, newLine: 3, text: "render()"), + ] + ) + + let lines = FeatureFullDiffHydrator.lines( + for: file, + contents: FeatureReviewFileContents( + oldContents: "import SwiftUI\nlet color = blue\nrender()\nfinish()\n", + newContents: "import SwiftUI\nlet color = green\nrender()\nfinish()\n" + ) + ) + + #expect(lines.map(\.kind) == [.context, .deletion, .addition, .context, .context]) + #expect(lines.map(\.text) == [ + "import SwiftUI", + "let color = blue", + "let color = green", + "render()", + "finish()", + ]) + #expect(lines.last?.oldLine == 4) + #expect(lines.last?.newLine == 4) + } + + @Test + func fullDiffHydrationHandlesWholeAddedAndDeletedFiles() { + let added = FeatureFullDiffHydrator.lines( + for: FeatureReviewFile( + path: "Added.swift", + change: .added, + additions: 2, + deletions: 0 + ), + contents: FeatureReviewFileContents( + oldContents: "", + newContents: "one\ntwo\n" + ) + ) + let deleted = FeatureFullDiffHydrator.lines( + for: FeatureReviewFile( + path: "Deleted.swift", + change: .deleted, + additions: 0, + deletions: 1 + ), + contents: FeatureReviewFileContents( + oldContents: "gone\n", + newContents: "" + ) + ) + + #expect(added.map(\.kind) == [.addition, .addition]) + #expect(added.map(\.newLine) == [1, 2]) + #expect(deleted.map(\.kind) == [.deletion]) + #expect(deleted.map(\.oldLine) == [1]) + } + + @Test + func fullDiffHydrationKeepsDeletionAtItsPreviousAnchor() { + let file = FeatureReviewFile( + path: "App.swift", + change: .modified, + additions: 1, + deletions: 1, + lines: [ + .init(id: "anchor", kind: .context, oldLine: 2, newLine: 2, text: "two"), + .init(id: "deleted", kind: .deletion, oldLine: 3, text: "three"), + .init(id: "later", kind: .addition, newLine: 7, text: "added later"), + ] + ) + + let lines = FeatureFullDiffHydrator.lines( + for: file, + contents: FeatureReviewFileContents( + oldContents: "one\ntwo\nthree\nfour\nfive\nsix\nseven\n", + newContents: "one\ntwo\nfour\nfive\nsix\nseven\nadded later\n" + ) + ) + + #expect(lines.firstIndex { $0.id == "deleted" } == 2) + #expect(lines.prefix(3).map(\.text) == ["one", "two", "three"]) + } + + @Test + func reviewCommentPromptIncludesActionableFileAndLineContext() { + let draft = FeatureReviewCommentDraft( + filePath: "Sources/App.swift", + line: FeatureReviewLineSelection(side: .new, line: 42), + body: " Handle the nil case. " + ) + + #expect(draft.prompt.contains("`Sources/App.swift` at new line 42")) + #expect(draft.prompt.contains("Handle the nil case.")) + #expect(!draft.prompt.contains(" Handle the nil case. ")) + } + + @Test + func sourceControlActionsReflectRepositoryState() { + let clean = FeatureSourceControlStatus(branch: "main") + #expect(clean.availableActions == [.createPullRequest]) + + let changed = FeatureSourceControlStatus( + branch: "feature/native", + aheadCount: 2, + behindCount: 1, + files: [.init(path: "App.swift", state: .modified, isStaged: false)] + ) + #expect(changed.availableActions.contains(.commit)) + #expect(changed.availableActions.contains(.push)) + #expect(changed.availableActions.contains(.pull)) + #expect(changed.availableActions.contains(.commitPushAndCreatePullRequest)) + + var busy = changed + busy.isBusy = true + #expect(busy.availableActions.isEmpty) + } + + @Test + func terminalPlainTextDropsControlSequences() { + let prompt = "\u{1B}]0;workspace\u{7}\u{1B}[38;5;221mx\u{8}repo\u{1B}[39m ❯ " + #expect(TerminalText.plainText(from: prompt) == "repo ❯ ") + } + + @Test + func terminalSessionSelectionPrefersAndAllocatesStableIDs() { + let sessions = [ + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "term-2", + state: .running, + title: "Tests" + ), + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "default", + state: .running + ), + FeatureTerminalSnapshot( + threadID: "thread", + terminalID: "term-3", + state: .exited + ), + ] + + #expect(TerminalSessionList.initialID(in: sessions) == "default") + #expect(TerminalSessionList.nextID(occupiedIDs: ["default", "term-2", "term-4"]) == "term-3") + #expect(TerminalSessionList.displayTitle(for: sessions[0]) == "Terminal 2 · Tests") + #expect(TerminalSessionList.displayTitle(for: sessions[1]) == "Terminal 1") + #expect( + TerminalSessionList.fallbackID(in: sessions, excluding: "default") == "term-2" + ) + } + + @Test + func terminalSnapshotPreservesVTDataForGhostty() { + let history = "\u{1B}[31mred\u{1B}[0m\r\n" + let snapshot = TerminalSessionSnapshot( + threadId: "thread", + terminalId: "default", + cwd: "/repo", + worktreePath: nil, + status: .running, + pid: 123, + history: history, + exitCode: nil, + exitSignal: nil, + label: "Terminal", + updatedAt: "2026-08-07T00:00:00Z", + sequence: 1 + ) + + #expect(NativeWorkspaceMapper.terminal(snapshot).buffer == history) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift new file mode 100644 index 000000000000..1e9d7572f65d --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/FeatureVoiceInputTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Local voice input", .serialized) +@MainActor +struct FeatureVoiceInputTests { + @Test + func insertsAtUTF16SelectionsWithEnglishSpacing() { + let text = "Fix 🧪 then $review please" + let selectedRange = (text as NSString).range(of: "$review") + let selected = snapshot(text: text, selection: selectedRange) + + guard case let .commit(selectedCommit) = FeatureVoiceTranscriptResolver.resolve( + captured: selected, + current: selected, + transcript: "use the mobile skill", + localeIdentifier: "en-US" + ) else { + Issue.record("Expected a selected-text transcript commit") + return + } + #expect(selectedCommit.text == "Fix 🧪 then use the mobile skill please") + #expect( + selectedCommit.caretLocation + == selectedRange.location + "use the mobile skill".utf16.count + ) + + let atEnd = snapshot( + text: "Fix cache.", + selection: NSRange(location: "Fix cache.".utf16.count, length: 0) + ) + guard case let .commit(commit) = FeatureVoiceTranscriptResolver.resolve( + captured: atEnd, + current: atEnd, + transcript: "Also fix tests.", + localeIdentifier: "en_US" + ) else { + Issue.record("Expected a transcript commit") + return + } + #expect(commit.text == "Fix cache. Also fix tests.") + #expect(commit.caretLocation == commit.text.utf16.count) + } + + @Test + func preservesUnicodeBoundariesAndNonEnglishSpacing() { + let text = "修正🧪キャッシュ" + let caret = (text as NSString).range(of: "キャッシュ").location + let draft = snapshot( + text: text, + selection: NSRange(location: caret, length: 0) + ) + + guard case let .commit(commit) = FeatureVoiceTranscriptResolver.resolve( + captured: draft, + current: draft, + transcript: "テストも", + localeIdentifier: "ja-JP" + ) else { + Issue.record("Expected a transcript commit") + return + } + #expect(commit.text == "修正🧪テストもキャッシュ") + #expect(commit.caretLocation == caret + "テストも".utf16.count) + } + + @Test + func rejectsChangedDraftTextRevisionAndOwner() { + let captured = snapshot() + let changedText = snapshot(text: "newer") + let changedRevision = snapshot(revision: 2) + let changedOwner = snapshot(ownerID: "thread:other") + + #expect(resolve(captured, changedText) == .stale) + #expect(resolve(captured, changedRevision) == .stale) + #expect(resolve(captured, changedOwner) == .stale) + } + + @Test + func cancellationBeforeAndAfterPermissionCleansUpWithoutRecording() async { + let beforePermission = TestVoiceInputAdapter() + let beforeController = controller(adapter: beforePermission) + beforePermission.onPermissionRequest = { beforeController.cancel() } + beforeController.start() + await beforeController.waitForCurrentOperation() + + #expect(beforeController.phase == .idle) + #expect(beforePermission.startRecordingCount == 0) + #expect(beforePermission.cleanupCount == 1) + #expect(beforeController.pendingCommit == nil) + + let afterPermission = TestVoiceInputAdapter() + let afterController = controller(adapter: afterPermission) + afterPermission.onStartRecording = { afterController.cancel() } + afterController.start() + await afterController.waitForCurrentOperation() + + #expect(afterController.phase == .idle) + #expect(afterPermission.startRecordingCount == 1) + #expect(afterPermission.cleanupCount == 1) + #expect(afterController.pendingCommit == nil) + } + + @Test + func cancellationDuringTranscriptionDiscardsLateResultsAndOwnedAudio() async { + let adapter = TestVoiceInputAdapter() + let controller = controller(adapter: adapter) + adapter.onTranscribe = { controller.cancel() } + + controller.start() + await controller.waitForCurrentOperation() + #expect(controller.phase == .recording) + + controller.stop() + await controller.waitForCurrentOperation() + + #expect(controller.phase == .idle) + #expect(controller.pendingCommit == nil) + #expect(adapter.cleanupCount == 1) + #expect(adapter.ownedRecordingWasRemoved) + } + + @Test + func changedOwnerDuringTranscriptionNeverCommits() async { + let adapter = TestVoiceInputAdapter() + let controller = controller(adapter: adapter) + adapter.onTranscribe = { + controller.ownerChanged(to: FeatureVoiceDraftSnapshot( + ownerID: "thread:other", + text: "hello world", + revision: 1, + selection: NSRange(location: 6, length: 5) + )) + } + + controller.start() + await controller.waitForCurrentOperation() + controller.stop() + await controller.waitForCurrentOperation() + + #expect(controller.pendingCommit == nil) + #expect(adapter.cleanupCount == 1) + } + + private func controller(adapter: TestVoiceInputAdapter) -> FeatureVoiceInputController { + let controller = FeatureVoiceInputController(adapter: adapter) + controller.updateDraft(snapshot()) + return controller + } + + private func resolve( + _ captured: FeatureVoiceDraftSnapshot, + _ current: FeatureVoiceDraftSnapshot + ) -> FeatureVoiceTranscriptCommitResult { + FeatureVoiceTranscriptResolver.resolve( + captured: captured, + current: current, + transcript: "replacement", + localeIdentifier: "en-US" + ) + } + + private func snapshot( + ownerID: String = "thread:one", + text: String = "hello world", + revision: UInt64 = 1, + selection: NSRange = NSRange(location: 6, length: 5) + ) -> FeatureVoiceDraftSnapshot { + FeatureVoiceDraftSnapshot( + ownerID: ownerID, + text: text, + revision: revision, + selection: selection + ) + } +} + +@MainActor +private final class TestVoiceInputAdapter: FeatureVoiceInputAdapter { + let isSupported = true + let localeIdentifier = "en-US" + var onPermissionRequest: (() -> Void)? + var onStartRecording: (() -> Void)? + var onTranscribe: (() -> Void)? + private(set) var startRecordingCount = 0 + private(set) var cleanupCount = 0 + private(set) var ownedRecordingWasRemoved = false + + func prepare() async throws {} + + func requestMicrophonePermission() async -> FeatureVoiceMicrophonePermission { + onPermissionRequest?() + return .granted + } + + func startRecording(maximumDuration: TimeInterval) throws { + startRecordingCount += 1 + #expect(maximumDuration == 5 * 60) + onStartRecording?() + } + + func stopRecording() async throws -> URL { + URL(fileURLWithPath: "/tmp/t3-owned-voice-test.m4a") + } + + func transcribe(recordingURL: URL) async throws -> String { + onTranscribe?() + return "late transcript" + } + + func cancelTranscription() async {} + + func cleanup() async { + cleanupCount += 1 + ownedRecordingWasRemoved = true + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift new file mode 100644 index 000000000000..8901461c4f95 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadMetadataTests.swift @@ -0,0 +1,518 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Web V2 home thread metadata") +struct HomeThreadMetadataTests { + private let now = Date(timeIntervalSince1970: 10_000) + + @Test + func statusLabelsFollowTheWebV2RowVocabulary() { + let expected: [(FeatureThreadState, HomeThreadStatus, String?)] = [ + (.idle, .ready, nil), + (.queued, .working, "Working"), + (.working, .working, "Working"), + (.monitoring, .monitoring, "Monitoring"), + (.waitingForApproval, .approval, "Approval"), + (.waitingForInput, .input, "Input"), + (.failed, .failed, "Failed"), + (.completed, .done, "Done"), + ] + + for (state, status, label) in expected { + let thread = FeatureThread( + id: state.rawValue, + projectID: "project", + title: "Task", + state: state + ) + #expect(thread.homeStatus == status) + #expect(thread.homeStatusLabel == label) + } + } + + @Test + func completedAndIdleRowsUseQuietRelativeAges() { + let updatedAt = now.addingTimeInterval(-120) + let completed = FeatureThread( + id: "completed", + projectID: "project", + title: "Done task", + updatedAt: updatedAt, + state: .completed + ) + let idle = FeatureThread( + id: "idle", + projectID: "project", + title: "Idle task", + updatedAt: updatedAt, + state: .idle + ) + + #expect(completed.homeRowStatusLabel(at: now) == "2m") + #expect(idle.homeRowStatusLabel(at: now) == "2m") + } + + @Test + func completedDetailHeadersDoNotShowAStatusBadge() { + let completed = FeatureThread( + id: "completed", + projectID: "project", + title: "Completed task", + state: .completed + ) + let working = FeatureThread( + id: "working", + projectID: "project", + title: "Working task", + state: .working + ) + + #expect(completed.detailHeaderStatusLabel == nil) + #expect(completed.detailHeaderStatusIcon == nil) + #expect(working.detailHeaderStatusLabel == "Working") + #expect(working.detailHeaderStatusIcon == "circle.dotted") + } + + @Test + func workingDurationMatchesTheCompactWebFormatAndClampsFutureDates() { + let thread = FeatureThread( + id: "working", + projectID: "project", + title: "Build", + state: .working, + workingStartedAt: now.addingTimeInterval(-5_465) + ) + let future = FeatureThread( + id: "queued", + projectID: "project", + title: "Queue", + state: .queued, + workingStartedAt: now.addingTimeInterval(5) + ) + let idle = FeatureThread( + id: "idle", + projectID: "project", + title: "Rest", + state: .idle, + workingStartedAt: now.addingTimeInterval(-10) + ) + let monitoring = FeatureThread( + id: "monitoring", + projectID: "project", + title: "Watch", + state: .monitoring, + workingStartedAt: now.addingTimeInterval(-10) + ) + + #expect(thread.homeWorkingDuration(at: now) == "1h 31m") + #expect(future.homeWorkingDuration(at: now) == "0s") + #expect(idle.homeWorkingDuration(at: now) == nil) + #expect(monitoring.homeWorkingDuration(at: now) == nil) + } + + @Test + func accessibilityDurationSpellsOutUnitsAndClampsFutureDates() { + #expect(accessibilityDuration(startedAtOffset: 5) == "0 seconds") + #expect(accessibilityDuration(startedAtOffset: -1) == "1 second") + #expect(accessibilityDuration(startedAtOffset: -42) == "42 seconds") + #expect(accessibilityDuration(startedAtOffset: -60) == "1 minute") + #expect(accessibilityDuration(startedAtOffset: -120) == "2 minutes") + #expect(accessibilityDuration(startedAtOffset: -3_600) == "1 hour") + #expect(accessibilityDuration(startedAtOffset: -7_200) == "2 hours") + #expect(accessibilityDuration(startedAtOffset: -5_465) == "1 hour, 31 minutes") + } + + @Test + func accessibilityStatusDescribesOnlyLiveWorkingDurations() { + let working = thread(state: .working, startedAtOffset: -90) + let queuedWithoutStart = thread(state: .queued) + let monitoring = thread(state: .monitoring, startedAtOffset: -90) + let idle = thread(state: .idle) + + #expect(working.hasLiveWorkingDuration) + #expect(working.homeStatusAccessibilityLabel(at: now) == "Agent is working for 1 minute") + #expect(!queuedWithoutStart.hasLiveWorkingDuration) + #expect(queuedWithoutStart.homeStatusAccessibilityLabel(at: now) == "Agent is working") + #expect(!monitoring.hasLiveWorkingDuration) + #expect(monitoring.homeStatusAccessibilityLabel(at: now) == "Monitoring") + #expect(!idle.hasLiveWorkingDuration) + #expect(idle.homeStatusAccessibilityLabel(at: now) == "Ready") + } + + private func accessibilityDuration(startedAtOffset: TimeInterval) -> String { + HomeWorkingDuration.accessibility( + since: now.addingTimeInterval(startedAtOffset), + now: now + ) + } + + private func thread( + state: FeatureThreadState, + startedAtOffset: TimeInterval? = nil + ) -> FeatureThread { + FeatureThread( + id: state.rawValue, + projectID: "project", + title: "Task", + state: state, + workingStartedAt: startedAtOffset.map(now.addingTimeInterval) + ) + } + + @Test + func rowAttributionPrefersCurrentEnvironmentNameAndWireProviderName() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + environmentID: "device", + environmentName: "Old device name", + title: "Build", + branch: "feat/web-v2-home", + worktreePath: "/worktrees/web-v2-home", + providerID: "codex-work", + providerName: "Codex Work" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "leftbook", + endpoint: "https://leftbook.example" + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + providers: [FeatureProvider(id: "codex-work", name: "Config name")] + ) + + #expect(thread.homeEnvironmentLabel(in: snapshot) == "leftbook") + #expect(thread.homeProviderLabel(in: snapshot) == "Codex Work") + #expect(thread.branch == "feat/web-v2-home") + #expect(thread.worktreePath == "/worktrees/web-v2-home") + } + + @Test + func rowAttributionFallsBackThroughProjectAndProviderCatalog() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Build", + providerID: "claude" + ) + let snapshot = FeatureSnapshot( + environments: [ + FeatureEnvironment( + id: "device", + name: "steambox", + endpoint: "https://steambox.example" + ), + ], + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + providersByEnvironment: [ + "device": [FeatureProvider(id: "claude", name: "Claude")], + ] + ) + + #expect(thread.homeEnvironmentLabel(in: snapshot) == "steambox") + #expect(thread.homeProviderLabel(in: snapshot) == "Claude") + } + + @Test + func rowContextCarriesHarnessIdentityAndCustomProviderFallback() throws { + let knownThread = FeatureThread( + id: "known", + projectID: "project", + title: "Use Claude", + providerID: "work-claude" + ) + let customThread = FeatureThread( + id: "custom", + projectID: "project", + title: "Use a custom harness", + providerID: "acme-agent", + providerName: "Acme Agent" + ) + let snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "device", + name: "t3code", + path: "/work/t3code" + ), + ], + threads: [knownThread, customThread], + providersByEnvironment: [ + "device": [ + FeatureProvider(id: "work-claude", name: "Claude Code", driver: "custom"), + FeatureProvider(id: "acme-agent", name: "Acme Agent", driver: "custom"), + ], + ] + ) + + let contexts = HomeThreadRowContext.index(snapshot: snapshot) + let known = try #require(contexts[knownThread.id]) + let custom = try #require(contexts[customThread.id]) + + #expect(known.providerID == "work-claude") + #expect(known.projectEnvironmentID == "device") + #expect(known.projectWorkspaceRoot == "/work/t3code") + #expect(known.providerDriver == "custom") + #expect(known.providerName == "Claude Code") + #expect( + ProviderBrand.resolve( + driver: known.providerDriver, + providerID: known.providerID, + providerName: known.providerName + ) == .claude + ) + #expect(custom.providerID == "acme-agent") + #expect(custom.providerDriver == "custom") + #expect(custom.providerName == "Acme Agent") + #expect( + ProviderBrand.resolve( + driver: custom.providerDriver, + providerID: custom.providerID, + providerName: custom.providerName + ) == nil + ) + } + + @Test + func rowContextUsesRepositoryGroupNameInsteadOfStalePhysicalProjectTitle() throws { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Test T3 Code Functionality" + ) + let snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "bb-1", + name: "wat", + path: "/work/t3code", + repositoryIdentity: FeatureRepositoryIdentity( + canonicalKey: "github.com/pingdotgg/t3code", + rootPath: "/work/t3code", + displayName: "pingdotgg/t3code", + name: "t3code" + ) + ), + ], + threads: [thread], + preferencesByEnvironment: [ + "bb-1": FeatureEnvironmentPreferences(projectGroupingMode: .repository), + ] + ) + + let context = try #require(HomeThreadRowContext.index(snapshot: snapshot)[thread.id]) + + #expect(context.projectName == "pingdotgg/t3code") + } + + @Test + func pullRequestIndicatorsUseTheCurrentThreadBranchAndPreserveTheirState() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Add native PR indicators", + branch: "feature/native-pull-requests" + ) + + for state in ["open", "merged", "closed"] { + let status = FeatureSourceControlStatus( + branch: "feature/native-pull-requests", + pullRequest: FeaturePullRequest( + number: 42, + title: "Add native PR indicators", + state: state, + updatedAt: "2026-08-28T12:30:45.123Z" + ) + ) + + let presentation = HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: status + ) + + #expect(presentation?.label == "#42") + #expect(presentation?.state.rawValue == state) + #expect(presentation?.updatedAt != nil) + #expect(presentation?.accessibilityLabel == "Pull request #42, \(state)") + } + + let wholeSecond = FeatureSourceControlStatus( + branch: thread.branch, + pullRequest: FeaturePullRequest( + number: 42, + title: "Add native PR indicators", + state: "merged", + updatedAt: "2026-08-28T12:30:45Z" + ) + ) + #expect(HomeThreadPullRequestPresentation.resolve( + thread: thread, + status: wholeSecond + )?.updatedAt != nil) + } + + @Test + func pullRequestIndicatorsIgnoreOtherBranchesAndUnknownStates() { + let thread = FeatureThread( + id: "thread", + projectID: "project", + title: "Task", + branch: "feature/current" + ) + let otherBranch = FeatureSourceControlStatus( + branch: "feature/other", + pullRequest: FeaturePullRequest(number: 42, title: "Other work", state: "open") + ) + let unsupportedState = FeatureSourceControlStatus( + branch: "feature/current", + pullRequest: FeaturePullRequest(number: 42, title: "Current work", state: "draft") + ) + let branchless = FeatureThread(id: "branchless", projectID: "project", title: "Task") + + #expect(HomeThreadPullRequestPresentation.resolve(thread: thread, status: otherBranch) == nil) + #expect(HomeThreadPullRequestPresentation.resolve(thread: thread, status: unsupportedState) == nil) + #expect(HomeThreadPullRequestPresentation.resolve(thread: branchless, status: otherBranch) == nil) + } + + @Test + func threadMenuOpensDurablePullRequestURL() throws { + let linked = ThreadLinkedPullRequest( + projectId: "project-wire", + repository: "pingdotgg/t3code", + number: 5178, + url: "https://github.com/pingdotgg/t3code/pull/5178" + ) + let thread = FeatureThread( + id: "thread", + projectID: "environment:project-wire", + environmentID: "studio", + environmentName: "Studio", + title: "Native client", + linkedPullRequest: linked + ) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: nil + )) + + #expect(destination.number == 5178) + #expect(destination.url.absoluteString == "https://github.com/pingdotgg/t3code/pull/5178") + } + + @Test + func threadMenuOpensBranchPullRequestsWithoutADurableLink() throws { + let project = FeatureProject( + id: "scoped-project", + wireID: "project-wire", + environmentID: "studio", + name: "T3 Code", + path: "/work/t3code" + ) + let thread = FeatureThread( + id: "thread", + projectID: project.id, + environmentID: "studio", + environmentName: "Studio", + title: "Native client", + branch: "feature/native" + ) + let pullRequest = FeaturePullRequest( + number: 42, + title: "Native client", + state: "open", + url: URL(string: "https://github.com/pingdotgg/t3code/pull/42") + ) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: pullRequest + )) + + #expect(destination.number == 42) + #expect(destination.url == pullRequest.url) + } + + @Test + func threadMenuRequiresPullRequestURL() throws { + let url = try #require(URL(string: "https://example.com/reviews/42")) + let thread = FeatureThread(id: "thread", projectID: "missing", title: "Task") + let pullRequest = FeaturePullRequest(number: 42, title: "Task", state: "open", url: url) + + let destination = try #require(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: pullRequest + )) + + #expect(destination.url == url) + #expect(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: nil + ) == nil) + #expect(ThreadPullRequestDestination.resolve( + thread: thread, + branchPullRequest: FeaturePullRequest( + number: 42, + title: "Task", + state: "open" + ) + ) == nil) + } + + @Test + func liveSourceControlSnapshotsCarryPullRequestsAndClearMissingRemoteState() { + let local = VCSLocalStatus( + isRepo: true, + sourceControlProvider: nil, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/native-pull-requests", + hasWorkingTreeChanges: false, + workingTree: VCSWorkingTree(files: [], insertions: 0, deletions: 0) + ) + let remote = VCSRemoteStatus( + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + aheadOfDefaultCount: 2, + pr: VCSChangeRequest( + number: 42, + title: "Add native PR indicators", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/native-pull-requests", + state: "open" + ) + ) + + let status = NativeWorkspaceMapper.sourceControl(local: local, remote: remote) + let withoutRemote = NativeWorkspaceMapper.sourceControl(local: local, remote: nil) + + #expect(status.branch == "feature/native-pull-requests") + #expect(status.pullRequest?.number == 42) + #expect(status.pullRequest?.state == "open") + #expect(status.aheadCount == 2) + #expect(status.behindCount == 1) + #expect(withoutRemote.pullRequest == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift new file mode 100644 index 000000000000..603792ef0595 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift @@ -0,0 +1,978 @@ +import Foundation +import Observation +import Testing +import UIKit +@testable import T3Code + +@MainActor +@Suite("Home row trailing swipe actions") +struct HomeThreadSwipeActionTests { + private let now = Date(timeIntervalSince1970: 20_000) + + @Test + func backKeepsTheMostRecentlyOpenedThreadHighlighted() { + var selection = WorkspaceThreadSelection() + selection.open("first") + selection.close() + #expect(selection.selectedID == nil) + #expect(selection.highlightedID == "first") + + selection.open("second") + #expect(selection.selectedID == "second") + #expect(selection.highlightedID == "second") + selection.close() + #expect(selection.highlightedID == "second") + } + + @Test + func settlementOwnsTheEdgeSlotSoAFullSwipeSettles() { + let active = thread(id: "active") + let actions = HomeThreadSwipeAction.trailingActions( + for: active, + isArchived: false, + at: now + ) + + #expect(actions == [.settle, .delete]) + #expect(actions.first == .settle) + #expect(actions.first?.intent == .setSettled(true)) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + #expect(actions.map(\.title) == ["Settle", "Delete"]) + } + + @Test + func pinnedRowsSettleFromTheEdgeAndKeepUnpinBesideIt() { + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + let actions = HomeThreadSwipeAction.trailingActions( + for: pinned, + isArchived: false, + at: now + ) + + #expect(actions == [.settle, .unpin, .delete]) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + #expect(actions.map(\.title) == ["Settle", "Unpin", "Delete"]) + #expect(actions.map(\.systemImage) == ["checkmark", "pin.slash", "trash"]) + } + + /// The pinned shelf also holds settled threads, so the edge action has to be + /// able to reverse instead of settling a second time. + @Test + func settledRowsPutReopenAtTheEdge() { + var settled = thread(id: "settled") + settled.settlementFacts = .init(settlementOverride: .settled) + let settledActions = HomeThreadSwipeAction.trailingActions( + for: settled, + isArchived: false, + at: now + ) + #expect(settledActions == [.reopen, .delete]) + #expect(settledActions.first?.intent == .setSettled(false)) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: settledActions)) + + var pinnedSettled = thread(id: "pinned-settled", pinnedAt: now.addingTimeInterval(-30)) + pinnedSettled.settlementFacts = .init(settlementOverride: .settled) + #expect( + HomeThreadSwipeAction.trailingActions( + for: pinnedSettled, + isArchived: false, + at: now + ) == [.reopen, .unpin, .delete] + ) + + // Age alone does not settle a row. The server decides when it moves. + var resting = thread(id: "resting") + resting.lastActivityAt = now.addingTimeInterval(-4 * 24 * 60 * 60) + #expect( + HomeThreadSwipeAction.trailingActions( + for: resting, + isArchived: false, + at: now + ) == [.settle, .delete] + ) + } + + @Test + func rowsWithNothingToSettleKeepAReversibleEdgeActionAndNoFullSwipe() { + var unsupported = thread(id: "no-settlement") + unsupported.supportsSettlement = false + let unsupportedActions = HomeThreadSwipeAction.trailingActions( + for: unsupported, + isArchived: false, + at: now + ) + #expect(unsupportedActions == [.archive, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: unsupportedActions)) + + var pinnedUnsupported = thread( + id: "pinned-no-settlement", + pinnedAt: now.addingTimeInterval(-30) + ) + pinnedUnsupported.supportsSettlement = false + let pinnedActions = HomeThreadSwipeAction.trailingActions( + for: pinnedUnsupported, + isArchived: false, + at: now + ) + #expect(pinnedActions == [.unpin, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: pinnedActions)) + + // Archived rows stay restore-only, and restoring is not a full swipe. + var archived = thread(id: "archived", pinnedAt: now.addingTimeInterval(-30)) + archived.isArchived = true + archived.isSettled = true + let archivedActions = HomeThreadSwipeAction.trailingActions( + for: archived, + isArchived: true, + at: now + ) + #expect(archivedActions == [.restore, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: archivedActions)) + } + + @Test + func workingRowsNeverOfferSettlementOrAFullSwipe() { + for state in [ + FeatureThreadState.queued, + .working, + .monitoring, + .waitingForApproval, + .waitingForInput, + ] { + var active = thread(id: "active-\(state.rawValue)") + active.state = state + + let actions = HomeThreadSwipeAction.trailingActions( + for: active, + isArchived: false, + at: now + ) + + #expect(actions == [.archive, .delete]) + #expect(!HomeThreadSwipeAction.performsFullSwipe(with: actions)) + } + } + + /// Delete must never reach the edge slot, because the edge slot is what a + /// full swipe runs. This sweeps every pinned/settled/capability/archived + /// combination rather than trusting the branch order. + @Test + func deleteIsNeverTheEdgeActionAndOnlySettlementArmsTheFullSwipe() { + for isSettled in [false, true] { + for isPinned in [false, true] { + for supportsSettlement in [nil, true, false] as [Bool?] { + for supportsPinning in [nil, true, false] as [Bool?] { + for isArchived in [false, true] { + var candidate = thread( + id: "row", + pinnedAt: isPinned ? now.addingTimeInterval(-30) : nil + ) + candidate.isSettled = isSettled + candidate.supportsSettlement = supportsSettlement + candidate.supportsPinning = supportsPinning + candidate.isArchived = isArchived + + let actions = HomeThreadSwipeAction.trailingActions( + for: candidate, + isArchived: isArchived, + at: now + ) + + #expect(actions.last == .delete) + #expect(actions.first != .delete) + #expect(actions.count == Set(actions).count) + #expect(actions.filter { $0.style == .destructive } == [.delete]) + #expect(actions.filter(\.isSettlement).count <= 1) + + let armsFullSwipe = HomeThreadSwipeAction.performsFullSwipe(with: actions) + #expect(armsFullSwipe == (actions.first?.isSettlement ?? false)) + if armsFullSwipe { + switch actions.first?.intent { + case .setSettled: + break + default: + Issue.record("A full swipe may only request settlement") + } + } + } + } + } + } + } + } + + @Test + func actionsRequestExactlyOneLifecycleMutationEach() { + #expect(HomeThreadSwipeAction.settle.intent == .setSettled(true)) + #expect(HomeThreadSwipeAction.reopen.intent == .setSettled(false)) + #expect(HomeThreadSwipeAction.unpin.intent == .setPinned(false)) + #expect(HomeThreadSwipeAction.archive.intent == .setArchived(true)) + #expect(HomeThreadSwipeAction.restore.intent == .setArchived(false)) + #expect(HomeThreadSwipeAction.delete.intent == .delete) + + #expect(HomeThreadSwipeAction.settle.isSettlement) + #expect(HomeThreadSwipeAction.reopen.isSettlement) + #expect(!HomeThreadSwipeAction.unpin.isSettlement) + #expect(!HomeThreadSwipeAction.delete.isSettlement) + + // The settlement actions keep the row's existing accent vocabulary and + // never inherit the destructive style that arms a destructive swipe. + #expect(HomeThreadSwipeAction.settle.style == .normal) + #expect(HomeThreadSwipeAction.settle.backgroundColor == .systemGreen) + #expect(HomeThreadSwipeAction.reopen.backgroundColor == .systemBlue) + #expect(HomeThreadSwipeAction.reopen.systemImage == "arrow.counterclockwise") + #expect(HomeThreadSwipeAction.delete.style == .destructive) + #expect(HomeThreadSwipeAction.delete.backgroundColor == nil) + } + + /// The full swipe carries no settlement logic of its own: its edge action is + /// applied through the same `FeatureRootModel.setSettled` call the context + /// menu uses, which reaches the client's real settlement request and clears + /// the pin, so one motion unpins and settles. + @Test + func aFullSwipeOnAPinnedRowSettlesThroughTheRealPathAndClearsThePin() async throws { + let client = SwipeSettlementClientStub() + var pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + pinned.lastActivityAt = now + client.snapshot = FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "environment", + name: "Studio", + path: "/studio" + ), + ], + threads: [pinned] + ) + let model = testRootModel(client: client) + await model.reload() + + #expect(presentation(for: model).pinned.map(\.id) == ["pinned"]) + + let actions = HomeThreadSwipeAction.trailingActions( + for: pinned, + isArchived: false, + at: now + ) + let edge = try #require(actions.first) + #expect(edge == .settle) + #expect(HomeThreadSwipeAction.performsFullSwipe(with: actions)) + + // Applying the edge action the way the row's `onSettle` closure does. + guard case let .setSettled(settled) = edge.intent else { return } + await model.setSettled(pinned.id, settled: settled) + + #expect(client.settlementRequests == [SettlementRequest(id: "pinned", settled: true)]) + #expect(client.pinRequests.isEmpty) + let updated = try #require(model.snapshot.threads.first { $0.id == "pinned" }) + #expect(updated.isSettled) + #expect(!updated.keepsActive) + #expect(updated.settledAt != nil) + #expect(updated.pinnedAt == nil) + + // One motion: the row leaves the pinned shelf for Settled, where its + // edge action is now the reverse. + let shelves = presentation(for: model) + #expect(shelves.pinned.isEmpty) + #expect(shelves.settled.map(\.id) == ["pinned"]) + #expect( + HomeThreadSwipeAction.trailingActions(for: updated, isArchived: false, at: now) + == [.reopen, .delete] + ) + } + + @Test + func settlementLeavesTheActiveShelfBeforeTheServerResponds() async throws { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + #expect(presentation(for: model).active.isEmpty) + #expect(presentation(for: model).settled.map(\.id) == [active.id]) + #expect(model.snapshot.threads.first?.isSettled == true) + + response?.resume() + #expect(await settlement.value) + + #expect(client.settlementRequests == [SettlementRequest(id: active.id, settled: true)]) + } + + @Test + func staleSnapshotsCannotRestoreThreadsWhileSettlementIsPending() async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + await model.reload() + + #expect(presentation(for: model).active.isEmpty) + #expect(model.snapshot.threads.first?.isSettled == true) + + response?.resume() + #expect(await settlement.value) + } + + @Test(arguments: PendingSettlementEvent.allCases) + func pendingSettlementSurvivesIncomingThreadAndDetailEvents( + event: PendingSettlementEvent + ) async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + + let subscribed = AsyncStream.makeStream() + client.onEventsSubscribed = { subscribed.continuation.yield() } + let eventLoop = Task { await model.start() } + var subscriptions = subscribed.stream.makeAsyncIterator() + await subscriptions.next() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + let settledAt = model.snapshot.threads.first?.settledAt + + var authoritative = active + authoritative.title = "Updated on the server" + let changed = AsyncStream.makeStream() + withObservationTracking { + _ = model.snapshot.threads.first?.title + } onChange: { + changed.continuation.yield() + } + + switch event { + case .thread: + client.emit(.thread(authoritative)) + case .detail: + client.emit(.detail(FeatureThreadDetail(thread: authoritative))) + case .detailDelta: + client.emit(.detailDelta( + FeatureThreadDetail(thread: authoritative), + FeatureDetailDelta(changedMessages: []) + )) + } + + var changes = changed.stream.makeAsyncIterator() + await changes.next() + + let updated = model.snapshot.threads.first + #expect(updated?.title == "Updated on the server") + #expect(updated?.isSettled == true) + #expect(updated?.settledAt == settledAt) + #expect(updated?.pinnedAt == nil) + #expect(presentation(for: model).active.isEmpty) + if let detail = model.details[active.id] { + #expect(detail.thread.isSettled) + #expect(detail.thread.pinnedAt == nil) + } + + response?.resume() + #expect(await settlement.value) + client.finishEvents() + await eventLoop.value + } + + @Test + func failedSettlementPreservesNewerServerMetadataWhenRestoringItsFields() async { + let client = SwipeSettlementClientStub() + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [pinned]) + let model = testRootModel(client: client) + await model.reload() + + client.beforeSettlementResponse = { _ in + var authoritative = pinned + authoritative.title = "Updated on the server" + client.snapshot = snapshot(threads: [authoritative]) + await model.reload() + throw SwipeSettlementFailure.offline + } + + #expect(!(await model.setSettled(pinned.id, settled: true))) + let updated = model.snapshot.threads.first + #expect(updated?.title == "Updated on the server") + #expect(updated?.isSettled == false) + #expect(updated?.pinnedAt == pinned.pinnedAt) + } + + @Test + func settlementRejectedAfterAThreadStartsWorkingReturnsFailure() async { + let client = SwipeSettlementClientStub() + var working = thread(id: "working") + working.state = .working + client.snapshot = snapshot(threads: [working]) + let model = testRootModel(client: client) + await model.reload() + + #expect(!(await model.setSettled(working.id, settled: true))) + #expect(client.settlementRequests.isEmpty) + #expect(model.snapshot.threads == [working]) + } + + @Test + func consecutiveSettlementsLeaveTheInboxWithoutWaitingForEarlierRequests() async throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let remaining = thread(id: "remaining") + client.snapshot = snapshot(threads: [first, second, remaining]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var responses: [String: CheckedContinuation] = [:] + client.beforeSettlementResponse = { request in + try await withCheckedThrowingContinuation { continuation in + responses[request.id] = continuation + started.continuation.yield(request.id) + } + } + + var requests = started.stream.makeAsyncIterator() + let firstSettlement = Task { await model.setSettled(first.id, settled: true) } + #expect(await requests.next() == first.id) + #expect(!presentation(for: model).active.contains { $0.id == first.id }) + + let secondSettlement = Task { await model.setSettled(second.id, settled: true) } + #expect(await requests.next() == second.id) + #expect(presentation(for: model).active.map(\.id) == [remaining.id]) + + responses[second.id]?.resume() + #expect(await secondSettlement.value) + responses[first.id]?.resume() + #expect(await firstSettlement.value) + + #expect(Set(presentation(for: model).settled.map(\.id)) == [first.id, second.id]) + } + + @Test + func failedSettlementRestoresTheOriginalPinnedThread() async { + let client = SwipeSettlementClientStub() + let pinned = thread(id: "pinned", pinnedAt: now.addingTimeInterval(-30)) + client.snapshot = snapshot(threads: [pinned]) + client.beforeSettlementResponse = { _ in + throw SwipeSettlementFailure.offline + } + let model = testRootModel(client: client) + await model.reload() + + await model.setSettled(pinned.id, settled: true) + + #expect(model.snapshot.threads == [pinned]) + #expect(presentation(for: model).pinned.map(\.id) == [pinned.id]) + #expect(presentation(for: model).settled.isEmpty) + #expect(model.errorMessage == "The test environment is offline.") + } + + @Test + func reopeningImmediatelyMovesTheThreadToTheTopAndRestoresItsOrderOnFailure() async throws { + let client = SwipeSettlementClientStub() + var older = thread(id: "older") + older.createdAt = now.addingTimeInterval(-1_000) + older.isSettled = true + older.settledAt = now.addingTimeInterval(-20) + let newer = thread(id: "newer") + client.snapshot = snapshot(threads: [older, newer]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation? + client.beforeSettlementResponse = { _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let reopening = Task { await model.setSettled(older.id, settled: false) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + + #expect(presentation(for: model).active.map(\.id) == ["older", "newer"]) + #expect(model.snapshot.threads.first(where: { $0.id == older.id })?.unsettledAt != nil) + response?.resume(throwing: SwipeSettlementFailure.offline) + #expect(!(await reopening.value)) + #expect(presentation(for: model).active.map(\.id) == ["newer"]) + let restored = try #require(model.snapshot.threads.first(where: { $0.id == older.id })) + #expect(restored.unsettledAt == older.unsettledAt) + #expect(restored.settledAt == older.settledAt) + } + + @Test + func anOlderFailedSettlementCannotUndoANewerReopen() async { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + client.snapshot = snapshot(threads: [active]) + let model = testRootModel(client: client) + await model.reload() + + let started = AsyncStream.makeStream() + var delayedResponse: CheckedContinuation? + client.beforeSettlementResponse = { request in + guard request.settled else { return } + try await withCheckedThrowingContinuation { continuation in + delayedResponse = continuation + started.continuation.yield() + } + } + + let settlement = Task { await model.setSettled(active.id, settled: true) } + var requests = started.stream.makeAsyncIterator() + await requests.next() + #expect(model.snapshot.threads.first?.isSettled == true) + + await model.setSettled(active.id, settled: false) + #expect(model.snapshot.threads.first?.isSettled == false) + let reopenedAt = model.snapshot.threads.first?.unsettledAt + #expect(reopenedAt != nil) + + delayedResponse?.resume(throwing: SwipeSettlementFailure.offline) + #expect(!(await settlement.value)) + + #expect(model.snapshot.threads.first?.isSettled == false) + #expect(model.snapshot.threads.first?.keepsActive == true) + #expect(model.snapshot.threads.first?.unsettledAt == reopenedAt) + #expect(client.settlementRequests == [ + SettlementRequest(id: active.id, settled: true), + SettlementRequest(id: active.id, settled: false), + ]) + } + + @Test + func swipeCompletionWaitsUntilTheCollectionHasRemovedTheThread() async { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let remaining = thread(id: "remaining") + let initial = snapshot(threads: [first, remaining]) + var requests: [SettlementRequest] = [] + let initialList = threadList(client: client, snapshot: initial) { thread, settled in + requests.append(SettlementRequest(id: thread.id, settled: settled)) + } + let coordinator = initialList.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + var finished = false + coordinator.performSwipe(.settle, for: first) { succeeded in + finished = true + completions.continuation.yield(succeeded) + } + + #expect(requests == [SettlementRequest(id: first.id, settled: true)]) + #expect(!finished) + + var settled = first + settled.isSettled = true + settled.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settled, remaining]) + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + #expect(finished) + } + + @Test + func settledSearchRowsFinishTheSwipeWithoutLeavingTheSearchResults() async { + let client = SwipeSettlementClientStub() + let active = thread(id: "search") + let initialList = threadList( + client: client, + snapshot: snapshot(threads: [active]), + query: "Task" + ) + let coordinator = initialList.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: active) { + completions.continuation.yield($0) + } + + var settled = active + settled.isSettled = true + settled.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settled]), + query: "Task" + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + #expect(await results.next() == true) + #expect(collectionView.numberOfItems(inSection: 0) == 1) + } + + @Test + func failedSettlementClosesTheSwipeWithoutACollectionUpdate() { + let client = SwipeSettlementClientStub() + let active = thread(id: "active") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [active]), + settlementResult: false + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + var result: Bool? + coordinator.performSwipe(.settle, for: active) { result = $0 } + + #expect(result == false) + } + + @Test + func consecutiveSwipeCompletionsResolveFromTheSameCollectionUpdate() async { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let remaining = thread(id: "remaining") + let initial = threadList( + client: client, + snapshot: snapshot(threads: [first, second, remaining]) + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let completions = AsyncStream.makeStream() + coordinator.performSwipe(.settle, for: first) { succeeded in + if succeeded { completions.continuation.yield(first.id) } + } + coordinator.performSwipe(.settle, for: second) { succeeded in + if succeeded { completions.continuation.yield(second.id) } + } + + var settledFirst = first + settledFirst.isSettled = true + settledFirst.settledAt = now + var settledSecond = second + settledSecond.isSettled = true + settledSecond.settledAt = now + let updated = threadList( + client: client, + snapshot: snapshot(threads: [settledFirst, settledSecond, remaining]) + ) + coordinator.update(parent: updated, collectionView: collectionView) + + var results = completions.stream.makeAsyncIterator() + let completed = await [results.next(), results.next()].compactMap { $0 } + #expect(Set(completed) == [first.id, second.id]) + } + + @Test + func threadCellsClipContentWhileTheirRowsCollapse() throws { + let client = SwipeSettlementClientStub() + let initial = threadList( + client: client, + snapshot: snapshot(threads: [thread(id: "visible")]) + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + + let cell = try #require(collectionView.cellForItem(at: IndexPath(item: 0, section: 0))) + + #expect(cell.clipsToBounds) + #expect(cell.contentView.clipsToBounds) + } + + @Test + func selectionUpdatesWhenOtherRowsArriveInTheSameSnapshot() throws { + let client = SwipeSettlementClientStub() + let first = thread(id: "first") + let second = thread(id: "second") + let initial = threadList( + client: client, snapshot: snapshot(threads: [first, second]), selectedThreadID: first.id + ) + let coordinator = initial.makeCoordinator() + let collectionView = testCollectionView() + coordinator.configure(collectionView) + collectionView.layoutIfNeeded() + defer { + coordinator.invalidateTimer() + coordinator.cancelPendingSwipeActions() + } + let firstCell = try #require(collectionView.visibleCells.first { + $0.accessibilityLabel == first.title + }) + #expect(firstCell.accessibilityTraits.contains(.selected)) + + let updated = threadList( + client: client, + snapshot: snapshot(threads: [first, second, thread(id: "arrived")]), + selectedThreadID: second.id + ) + coordinator.update(parent: updated, collectionView: collectionView) + collectionView.layoutIfNeeded() + let selected = collectionView.visibleCells.filter { $0.accessibilityTraits.contains(.selected) } + #expect(selected.count == 1) + #expect(selected.first?.accessibilityLabel == second.title) + } + + private func presentation(for model: FeatureRootModel) -> HomePresentation { + HomePresentation( + snapshot: model.snapshot, + query: "", + projectID: nil, + now: now + ) + } + + private func snapshot(threads: [FeatureThread]) -> FeatureSnapshot { + FeatureSnapshot( + projects: [ + FeatureProject( + id: "project", + environmentID: "environment", + name: "Studio", + path: "/studio" + ), + ], + threads: threads + ) + } + + private func threadList( + client: SwipeSettlementClientStub, + snapshot: FeatureSnapshot, + query: String = "", + selectedThreadID: String? = nil, + settlementResult: Bool = true, + onSettle: @escaping (FeatureThread, Bool) -> Void = { _, _ in } + ) -> HomeThreadCollectionView { + HomeThreadCollectionView( + presentation: HomePresentation( + snapshot: snapshot, + query: query, + projectID: nil, + now: now + ), + projectFaviconClient: client, + query: query, + selectedThreadID: selectedThreadID, + forceRichRows: false, + hapticsEnabled: false, + settings: snapshot.settings, + pullRequestsByThreadID: [:], + isSnoozedExpanded: false, + isSettledExpanded: false, + isArchiveExpanded: false, + settledLimit: 12, + onOpen: { _ in }, + onToggleSnoozed: {}, + onToggleSettled: {}, + onToggleArchive: {}, + onShowMoreSettled: {}, + onRename: { _ in }, + onRegenerateTitle: { _ in }, + onArchive: { _, _ in }, + onSettle: { thread, settled, completion in + onSettle(thread, settled) + completion(settlementResult) + }, + onSnooze: { _, _ in }, + onPin: { _, _ in }, + onDelete: { _ in }, + onPullRequestChange: { _, _, _ in } + ) + } + + private func testCollectionView() -> UICollectionView { + UICollectionView( + frame: CGRect(x: 0, y: 0, width: 390, height: 844), + collectionViewLayout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration(appearance: .plain) + ) + ) + } + + private func thread( + id: String, + pinnedAt: Date? = nil + ) -> FeatureThread { + FeatureThread( + id: id, + projectID: "project", + title: "Task \(id)", + createdAt: now.addingTimeInterval(-100), + updatedAt: now.addingTimeInterval(-50), + state: .idle, + lastActivityAt: now.addingTimeInterval(-50), + pinnedAt: pinnedAt, + supportsSettlement: true, + supportsPinning: true + ) + } +} + +enum PendingSettlementEvent: CaseIterable { + case thread + case detail + case detailDelta +} + +@MainActor +private func testRootModel(client: SwipeSettlementClientStub) -> FeatureRootModel { + FeatureRootModel( + client: client, + outboxStore: FeatureOutboxStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("t3-swipe-settlement-outbox-\(UUID().uuidString).json") + ) + ) +} + +private struct SettlementRequest: Equatable { + let id: String + let settled: Bool +} + +private enum SwipeSettlementFailure: LocalizedError { + case offline + + var errorDescription: String? { + "The test environment is offline." + } +} + +/// Records the settlement requests the feature client actually receives, so the +/// swipe action's wiring is proved against the real client call rather than a +/// view-local shortcut. +@MainActor +private final class SwipeSettlementClientStub: FeatureClient { + private let eventStream: AsyncStream + private let eventContinuation: AsyncStream.Continuation + var snapshot = FeatureSnapshot() + var settlementRequests: [SettlementRequest] = [] + var pinRequests: [String] = [] + var beforeSettlementResponse: ((SettlementRequest) async throws -> Void)? + var onEventsSubscribed: (() -> Void)? + + init() { + let events = AsyncStream.makeStream() + eventStream = events.stream + eventContinuation = events.continuation + } + + func initialSnapshot() async throws -> FeatureSnapshot { snapshot } + + func events() -> AsyncStream { + onEventsSubscribed?() + return eventStream + } + + func emit(_ event: FeatureEvent) { + eventContinuation.yield(event) + } + + func finishEvents() { + eventContinuation.finish() + } + + func setThreadSettled(id: String, settled: Bool) async throws { + let request = SettlementRequest(id: id, settled: settled) + settlementRequests.append(request) + try await beforeSettlementResponse?(request) + } + + func setThreadPinned(id: String, pinned: Bool) async throws { + pinRequests.append(id) + } + + func pair(endpoint: String, token: String?) async throws {} + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + FeatureThread(id: "created", projectID: projectID, title: title ?? "Created") + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + FeatureThreadDetail( + thread: snapshot.threads.first { $0.id == id } + ?? FeatureThread(id: id, projectID: "project", title: "Task") + ) + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws {} + func cancelTurn(threadID: String) async throws {} + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func saveSettings(_ settings: FeatureSettings) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift new file mode 100644 index 000000000000..0f312af1bdbc --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift @@ -0,0 +1,689 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Chat Markdown") +struct MarkdownDocumentTests { + @Test + func codexFileCitationsBecomeWorkspaceLinks() { + let document = MarkdownDocument( + parsing: #"See :codex-file-citation{path="docs/My file%#?.md" line_range_start="12"}."# + ) + + #expect( + document.blocks == [ + .paragraph("See [My file%#?.md]()."), + ] + ) + } + + @Test + func codexFileCitationsEscapeLabelsAndDestinations() { + let document = MarkdownDocument( + parsing: #":codex-file-citation{path=" reports/*draft*_[copy]`<&).txt "}"# + ) + + #expect( + document.blocks == [ + .paragraph( + #"[\*draft\*\_\[copy\]\`\<\&).txt]()"# + ), + ] + ) + + #expect( + CodexMarkdownDirectives.fileCitation( + from: ":codex-file-citation{path=\"reports/a\r\n.txt\"}" + ) == "[a\\\r\n.txt]()" + ) + } + + @Test + func codexFileCitationEndIsQuoteAwareAndSupportsSingleQuotes() { + let document = MarkdownDocument( + parsing: #":codex-file-citation{path='reports/a}b file.md' line_range_start=' 9 '}"# + ) + + #expect(document.blocks == [.paragraph("[a}b file.md]()")]) + + #expect( + MarkdownDocument( + parsing: ":codex-file-citation{path=reports/unquoted.md line_range_start=3}" + ).blocks == [.paragraph("[unquoted.md]()")] + ) + } + + @Test + func codexFileCitationsStayLiteralInEscapedCodeAndReferenceLinks() { + let directive = #":codex-file-citation{path="src/file.swift"}"# + let document = MarkdownDocument( + parsing: """ + \\`\(directive)\\` + + [outer [nested \(directive)] label][ref] + + [ref]: docs/reference.md + """ + ) + + #expect(document.blocks[0] == .paragraph("\\`[file.swift]()\\`")) + #expect(document.blocks[1] == .paragraph("[outer [nested \(directive)] label][ref]")) + } + + @Test + func codexFileCitationsStayLiteralInCodeAndLinks() { + let directive = #":codex-file-citation{path="src/file.swift" line_range_start="2"}"# + let document = MarkdownDocument( + parsing: """ + `\(directive)` + + \(directive) + + ```text + \(directive) + ``` + + [existing \(directive)](docs/existing.md) + """ + ) + + #expect(document.blocks[0] == .paragraph("`\(directive)`")) + #expect(document.blocks[1] == .paragraph(" \(directive)")) + #expect(document.blocks[2] == .codeBlock(language: "text", code: directive)) + #expect(document.blocks[3] == .paragraph("[existing \(directive)](docs/existing.md)")) + } + + @Test + func malformedAndIncompleteCodexDirectivesStayLiteral() { + let missingPath = #":codex-file-citation{line_range_start="4"}"# + let incomplete = #":codex-file-citation{path="src/file.swift""# + let invalidLine = #":codex-file-citation{path="src/file.swift" line_range_start="zero"}"# + let document = MarkdownDocument(parsing: "\(missingPath)\n\(incomplete)\n\(invalidLine)") + + #expect( + document.blocks == [ + .paragraph("\(missingPath)\n\(incomplete)\n[file.swift]()"), + ] + ) + } + + @Test + func parsesArtifactTemplatesInsideNestedLists() { + let directive = #"::artifact-template{artifact_kind="document" display_name="Release notes template" skill_directory="/templates/release notes" skill_name="artifact-template-release" gallery_kind="imagegen"}"# + let document = MarkdownDocument(parsing: "- Templates\n - \(directive)") + guard case let .unorderedList(items) = document.blocks.first, + case let .unorderedList(children) = items.first?.blocks.last, + case let .artifactTemplate(template) = children.first?.blocks.first else { + Issue.record("Expected a nested artifact template") + return + } + + #expect(template.displayName == "Release notes template") + #expect(template.kind == .document) + #expect(template.usePrompt == "Create a document using this $artifact-template-release about…") + #expect(template.useURL?.scheme == "t3code") + } + + @Test + func invalidArtifactTemplateAttributesStayLiteral() { + for directive in [ + #"::artifact-template{artifact_kind="video" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="relative" skill_name="artifact-template-demo"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="C:\\templates" skill_name="wrong"}"#, + #"::artifact-template{artifact_kind="image" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo" gallery_kind="unknown"}"#, + ] { + #expect(MarkdownDocument(parsing: directive).blocks == [.paragraph(directive)]) + } + + let validButIndented = #" ::artifact-template{artifact_kind="document" display_name="Demo" skill_directory="/tmp" skill_name="artifact-template-demo"}"# + #expect( + MarkdownDocument(parsing: validButIndented).blocks == [.paragraph(validButIndented)] + ) + } + + @Test + func relativeImagesCanResolveFromTheViewedSourceFile() { + #expect( + MarkdownImageSource.classify( + "images/preview.png", + workspaceRoot: "/workspace/project/docs" + ) == .workspaceFile("/workspace/project/docs/images/preview.png") + ) + } + + @Test + func workspaceFileLinksResolveRelativeAbsoluteAndSpacedPaths() throws { + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "docs/My%20Folder/checklist.xml")), + workspaceRoot: "/workspace/project" + ) == "docs/My Folder/checklist.xml" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "Updated%20cutover%20checklist.md")), + workspaceRoot: "/workspace/project" + ) == "Updated cutover checklist.md" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "file:///workspace/project/src/main.swift#L18")), + workspaceRoot: "/workspace/project" + ) == "src/main.swift" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: "/workspace/project/src/a%23b%3Fc%25.swift#L2")), + workspaceRoot: "/workspace/project" + ) == "src/a#b?c%.swift" + ) + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require( + URL(string: "file:///workspace/project/src/a%23b%3Fc%25.swift#L2") + ), + workspaceRoot: "/workspace/project" + ) == "src/a#b?c%.swift" + ) + } + + @Test + func workspaceFileLinksRejectExternalAndEscapedPaths() throws { + for value in ["https://example.com/file.md", "javascript:alert(1)", "../private.md", + "file:///other/project/file.md"] { + #expect( + MarkdownWorkspaceFileLink.relativePath( + for: try #require(URL(string: value)), + workspaceRoot: "/workspace/project" + ) == nil + ) + } + } + + @Test + func separatesHeadingsParagraphsAndListKinds() { + let document = MarkdownDocument( + parsing: """ + # Release notes + + Includes **important** details. + + - First + - [x] Shipped + - [ ] Follow up + + 3. Third + 4. Fourth + """ + ) + + #expect( + document.blocks == [ + .heading(level: 1, text: "Release notes"), + .paragraph("Includes **important** details."), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("First")]), + MarkdownListItem(task: .complete, blocks: [.paragraph("Shipped")]), + MarkdownListItem(task: .incomplete, blocks: [.paragraph("Follow up")]), + ]), + .orderedList( + start: 3, + items: [ + MarkdownListItem(task: nil, blocks: [.paragraph("Third")]), + MarkdownListItem(task: nil, blocks: [.paragraph("Fourth")]), + ] + ), + ] + ) + } + + @Test + func separatesMarkdownImagesFromSurroundingParagraphText() { + let document = MarkdownDocument( + parsing: "Before ![Build result](images/result.png) after\n\n![Preview]( \"Title\")" + ) + + #expect( + document.blocks == [ + .paragraph("Before"), + .image(MarkdownImage(source: "images/result.png", alternativeText: "Build result")), + .paragraph("after"), + .image(MarkdownImage(source: "", alternativeText: "Preview")), + ] + ) + } + + @Test + func markdownImageSourcesDistinguishRemoteAndWorkspaceImages() { + #expect( + MarkdownImageSource.classify("https://example.com/image.png", workspaceRoot: "/repo") + == .direct(URL(string: "https://example.com/image.png")!) + ) + #expect( + MarkdownImageSource.classify("//cdn.example.com/image.png") + == .direct(URL(string: "https://cdn.example.com/image.png")!) + ) + #expect( + MarkdownImageSource.classify("images/result.png", workspaceRoot: "/workspace/project") + == .workspaceFile("/workspace/project/images/result.png") + ) + #expect( + MarkdownImageSource.classify( + "images/result.png", + workspaceRoot: #"C:\Users\theo\project"# + ) == .workspaceFile(#"C:\Users\theo\project\images\result.png"#) + ) + #expect( + MarkdownImageSource.classify("file:///workspace/project/image%20one.png") + == .workspaceFile("/workspace/project/image one.png") + ) + #expect( + MarkdownImageSource.classify("file://server/share/image.png") + == .workspaceFile(#"\\server\share\image.png"#) + ) + #expect( + MarkdownImageSource.classify("/C:/Users/theo/image.png") + == .workspaceFile("C:/Users/theo/image.png") + ) + } + + @Test + func markdownImageSourcesRejectUnsafeAndUnresolvedDestinations() { + for source in ["", "#image", "?image=1", "image.png", "~/image.png", + "javascript:alert(1)", "ftp://example.com/image.png", + "content://media/image/1"] { + #expect(MarkdownImageSource.classify(source) == .blocked) + } + } + + @Test + func preservesNestedStructureInsideQuotesAndLists() { + let document = MarkdownDocument( + parsing: """ + > ## Heads up + > Read this first. + > + > - Quoted item + + - Parent + - Nested child + """ + ) + + guard case let .blockquote(quote) = document.blocks.first else { + Issue.record("Expected a block quote") + return + } + #expect( + quote.blocks == [ + .heading(level: 2, text: "Heads up"), + .paragraph("Read this first."), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("Quoted item")]), + ]), + ] + ) + + guard case let .unorderedList(items) = document.blocks.last else { + Issue.record("Expected an unordered list") + return + } + #expect( + items == [ + MarkdownListItem( + task: nil, + blocks: [ + .paragraph("Parent"), + .unorderedList([ + MarkdownListItem(task: nil, blocks: [.paragraph("Nested child")]), + ]), + ] + ), + ] + ) + } + + @Test + func parsesTablesWithAlignmentEscapesAndNormalizedRows() { + let document = MarkdownDocument( + parsing: """ + | Name | Status | Notes | + | :--- | :---: | ---: | + | Parser | Ready | **Fast** | + | Escaped \\| pipe | ``a|b`` | [Docs](https://example.com) | + | Short | Row | + | Extra | cells | stay | ignored | + """ + ) + + #expect( + document.blocks == [ + .table( + MarkdownTable( + header: ["Name", "Status", "Notes"], + alignments: [.leading, .center, .trailing], + rows: [ + ["Parser", "Ready", "**Fast**"], + ["Escaped \\| pipe", "``a|b``", "[Docs](https://example.com)"], + ["Short", "Row", ""], + ["Extra", "cells", "stay"], + ] + ) + ), + ] + ) + } + + @Test + func unmatchedBacktickDoesNotHideLaterTableSeparators() { + let document = MarkdownDocument( + parsing: """ + Left | Middle | Right + --- | --- | --- + x | `y | z + """ + ) + + #expect( + document.blocks == [ + .table( + MarkdownTable( + header: ["Left", "Middle", "Right"], + alignments: [.natural, .natural, .natural], + rows: [["x", "`y", "z"]] + ) + ), + ] + ) + } + + @Test + func rejectsTableDelimiterCellsWithFewerThanThreeDashes() { + let document = MarkdownDocument( + parsing: """ + Name | Status + -- | --- + Parser | Ready + """ + ) + + #expect( + document.blocks == [ + .paragraph("Name | Status\n-- | ---\nParser | Ready"), + ] + ) + } + + @Test + func rendersTableCellsThroughTheInlineMarkdownCache() throws { + let source = """ + Label | Value + --- | --- + **Build** | `green` + """ + let revision = MarkdownContentRevision(source) + let rendered = try #require( + MarkdownRenderCache.shared.documentImmediately(for: revision) + ) + guard case let .table(table) = rendered.blocks.first else { + Issue.record("Expected a rendered table") + return + } + + #expect(String(table.header[0].attributedText.characters) == "Label") + #expect(String(table.rows[0][0].attributedText.characters) == "Build") + #expect( + table.rows[0][0].attributedText.runs.contains { + $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true + } + ) + #expect( + table.rows[0][1].attributedText.runs.contains { + $0.inlinePresentationIntent?.contains(.code) == true + } + ) + } + + @Test + func fencedCodeKeepsLanguageAndContentsLiteral() { + let document = MarkdownDocument( + parsing: """ + ```swift + let value = "**not emphasis**" + print(value) + ``` + """ + ) + + #expect( + document.blocks == [ + .codeBlock( + language: "swift", + code: "let value = \"**not emphasis**\"\n print(value)" + ), + ] + ) + } + + @Test + func unclosedFenceConsumesTheRemainingMessage() { + let document = MarkdownDocument( + parsing: """ + ~~~console + pnpm test + no closing fence + """ + ) + + #expect( + document.blocks == [ + .codeBlock(language: "console", code: "pnpm test\nno closing fence"), + ] + ) + } + + @Test + func plaintextCodeBlocksWrapByDefault() { + for language in ["text", "TEXT", "txt", "plaintext", "plain", "md", "markdown"] { + #expect(MarkdownCodeBlockWrapping.wrapsByDefault(language: language)) + } + + for language in [nil, "swift", "typescript", "console"] { + #expect(!MarkdownCodeBlockWrapping.wrapsByDefault(language: language)) + } + } + + @Test + func parsesSetextHeadingsAndNormalizesWindowsNewlines() { + let document = MarkdownDocument(parsing: "Heading\r\n=======\r\n\r\nBody") + + #expect( + document.blocks == [ + .heading(level: 1, text: "Heading"), + .paragraph("Body"), + ] + ) + } + + @Test + func inlineFormatterRetainsEmphasisCodeAndLinks() { + let formatted = MarkdownInlineFormatter.format( + "Use **bold**, *emphasis*, `code`, and [docs](https://example.com)." + ) + let runs = Array(formatted.runs) + + #expect(String(formatted.characters) == "Use bold, emphasis, code, and docs.") + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.emphasized) == true }) + #expect(runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) + #expect(runs.contains { $0.link == URL(string: "https://example.com") }) + } + + @Test @MainActor + func selectableTextAttributesPreserveInlineFormatting() throws { + let revision = MarkdownContentRevision( + "Use **bold**, *emphasis*, `code`, ~~removed~~, and [docs](https://example.com)." + ) + let document = try #require( + MarkdownRenderCache.shared.documentImmediately(for: revision) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let attributed = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + foregroundColor: T3Colors.uiTextSecondary + ) + let text = attributed.string as NSString + let boldIndex = try #require(index(of: "bold", in: text)) + let emphasisIndex = try #require(index(of: "emphasis", in: text)) + let codeIndex = try #require(index(of: "code", in: text)) + let removedIndex = try #require(index(of: "removed", in: text)) + let linkIndex = try #require(index(of: "docs", in: text)) + + let boldFont = try #require( + attributed.attribute(.font, at: boldIndex, effectiveRange: nil) as? UIFont + ) + let emphasisFont = try #require( + attributed.attribute(.font, at: emphasisIndex, effectiveRange: nil) as? UIFont + ) + let codeFont = try #require( + attributed.attribute(.font, at: codeIndex, effectiveRange: nil) as? UIFont + ) + let paragraphStyle = try #require( + attributed.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle + ) + + #expect(boldFont.fontDescriptor.symbolicTraits.contains(.traitBold)) + #expect(emphasisFont.fontDescriptor.symbolicTraits.contains(.traitItalic)) + #expect(codeFont.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + #expect( + attributed.attribute(.backgroundColor, at: codeIndex, effectiveRange: nil) + as? UIColor == T3Colors.uiSurfaceRaised + ) + #expect( + attributed.attribute(.strikethroughStyle, at: removedIndex, effectiveRange: nil) + as? Int == NSUnderlineStyle.single.rawValue + ) + #expect( + attributed.attribute(.foregroundColor, at: boldIndex, effectiveRange: nil) + as? UIColor == T3Colors.uiTextSecondary + ) + #expect( + attributed.attribute(.link, at: linkIndex, effectiveRange: nil) as? URL + == URL(string: "https://example.com") + ) + #expect( + attributed.string + == "Use bold, emphasis, code, removed, and docs." + ) + #expect(paragraphStyle.lineSpacing == 4) + } + + @Test @MainActor + func selectableTextAttributesHonorDynamicTypeSize() throws { + let document = try #require( + MarkdownRenderCache.shared.documentImmediately( + for: MarkdownContentRevision("Readable body text") + ) + ) + guard case let .paragraph(inline) = document.blocks.first else { + Issue.record("Expected a rendered paragraph") + return + } + + let small = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + dynamicTypeSize: .small + ) + let accessibility = MarkdownSelectableTextAttributes.make( + from: inline, + lineSpacing: 4, + dynamicTypeSize: .accessibility1 + ) + let smallFont = try #require( + small.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + let accessibilityFont = try #require( + accessibility.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + + #expect(accessibilityFont.pointSize > smallFont.pointSize) + } + + @Test @MainActor + func codeBlocksReuseSelectableInlineRendering() throws { + let literalCode = "x = arr[i](fn)\na **b** c\nprintf(\\\"a\\\\tb\\\");" + let cache = MarkdownRenderCache() + let first = try #require( + cache.documentImmediately( + for: MarkdownContentRevision("```swift\n\(literalCode)\n```") + ) + ) + let second = try #require( + cache.documentImmediately( + for: MarkdownContentRevision("Before\n\n```swift\n\(literalCode)\n```") + ) + ) + + guard case let .codeBlock(_, firstCode, firstInline) = first.blocks.first, + case let .codeBlock(_, secondCode, secondInline) = second.blocks.last + else { + Issue.record("Expected rendered code blocks") + return + } + + #expect(firstCode == literalCode) + #expect(secondCode == firstCode) + #expect(firstInline === secondInline) + #expect(firstInline.style == .code) + + let attributed = MarkdownSelectableTextAttributes.make( + from: firstInline, + lineSpacing: 3 + ) + let font = try #require( + attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont + ) + #expect(attributed.string == firstCode) + #expect(font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + } + + @Test + func restoresSelectionOnlyWhenTextIsExtended() { + let selection = NSRange(location: 7, length: 5) + + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: selection, + newText: "Hello, world!" + ) == selection + ) + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: selection, + newText: "Different text" + ) == NSRange(location: 0, length: 0) + ) + #expect( + MarkdownSelectionRestoration.range( + previousText: "Hello, world", + previousRange: NSRange(location: 7, length: 20), + newText: "Hello, world!" + ) == NSRange(location: 0, length: 0) + ) + } + + private func index(of substring: String, in text: NSString) -> Int? { + let range = text.range(of: substring) + return range.location == NSNotFound ? nil : range.location + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift new file mode 100644 index 000000000000..772f860073f9 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownRenderCacheTests.swift @@ -0,0 +1,102 @@ +import Testing +@testable import T3Code + +@Suite("Markdown render cache") +struct MarkdownRenderCacheTests { + @Test + func contentRevisionUsesExactSourceIdentity() { + let first = MarkdownContentRevision("Same text") + let again = MarkdownContentRevision("Same text") + let changed = MarkdownContentRevision("Same text.") + + #expect(first == again) + #expect(first != changed) + #expect(first.fingerprint == again.fingerprint) + } + + @Test + func reusesAnExactRenderedDocument() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("# Heading\n\nBody with **emphasis**.") + + #expect(cache.cachedDocument(for: revision) == nil) + guard let first = await cache.document(for: revision), + let second = await cache.document(for: revision) else { + Issue.record("Expected Markdown documents") + return + } + + #expect(first === second) + #expect(cache.cachedDocument(for: revision) === first) + #expect(first.blocks.count == 2) + } + + @Test + func immediatelyRendersAndCachesCompletedContent() { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("# Stable heading\n\n- First\n- Second") + + guard let document = cache.documentImmediately(for: revision) else { + Issue.record("Expected an immediate Markdown document") + return + } + + #expect(cache.cachedDocument(for: revision) === document) + #expect(document.blocks.count == 2) + } + + @Test + func coalescesConcurrentRequestsForOneRevision() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision("A paragraph with `code` and [a link](https://t3.gg).") + + async let first = cache.document(for: revision) + async let second = cache.document(for: revision) + let documents = await (first, second) + + guard let first = documents.0, let second = documents.1 else { + Issue.record("Expected coalesced Markdown documents") + return + } + #expect(first === second) + } + + @Test + func reusesUnchangedInlineRunsAcrossStreamingRevisions() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + guard let first = await cache.document( + for: MarkdownContentRevision("Shared **paragraph**.\n\nFirst ending") + ), let second = await cache.document( + for: MarkdownContentRevision("Shared **paragraph**.\n\nSecond ending") + ) else { + Issue.record("Expected streaming Markdown documents") + return + } + + guard let firstBlock = first.blocks.first, + let secondBlock = second.blocks.first, + case let .paragraph(firstInline) = firstBlock, + case let .paragraph(secondInline) = secondBlock else { + Issue.record("Expected a shared leading paragraph") + return + } + + #expect(firstInline === secondInline) + #expect(firstInline.style == .body) + #expect(String(firstInline.attributedText.characters) == "Shared paragraph.") + } + + @Test + func canceledRequestDoesNotRenderOrCache() async { + let cache = MarkdownRenderCache(documentCountLimit: 8, documentCostLimit: 64_000) + let revision = MarkdownContentRevision(String(repeating: "Paragraph.\n\n", count: 2_000)) + let render = Task { + await Task.yield() + return await cache.document(for: revision) + } + + render.cancel() + #expect(await render.value == nil) + #expect(cache.cachedDocument(for: revision) == nil) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift new file mode 100644 index 000000000000..93b83a03d9ef --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeMultiEnvironmentTests.swift @@ -0,0 +1,1250 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +final class NativeMultiEnvironmentTests: XCTestCase { + func testProviderCatalogueUsesStableProviderAndModelIdentities() { + let normalized = NativeFeatureClient.normalizedProviders([ + FeatureProvider( + id: "codex-work", + name: "Codex", + models: [ + FeatureModel(id: "gpt-5.6", name: "GPT-5.6"), + FeatureModel(id: "gpt-5.6", name: "Duplicate GPT-5.6"), + ] + ), + FeatureProvider( + id: "codex-work", + name: "Duplicate provider", + models: [ + FeatureModel(id: "gpt-5.6", name: "Duplicate again"), + FeatureModel(id: "gpt-5.6-mini", name: "GPT-5.6 mini"), + ] + ), + ]) + + XCTAssertEqual(normalized.map(\.id), ["codex-work"]) + XCTAssertEqual(normalized[0].models.map(\.id), ["gpt-5.6", "gpt-5.6-mini"]) + } + + func testClientReplacementIsSharedWhileStaleClientDisconnects() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-runtime-race-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let originalEnvironment = Environment( + id: "shared-environment", + label: "Old endpoint", + httpBaseURL: URL(string: "https://old.example")!, + webSocketBaseURL: URL(string: "wss://old.example")! + ) + let updatedEnvironment = Environment( + id: originalEnvironment.id, + label: "New endpoint", + httpBaseURL: URL(string: "https://new.example")!, + webSocketBaseURL: URL(string: "wss://new.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([updatedEnvironment]) + let staleConnection = BlockingRuntimeCloseConnection() + let connector = RuntimeReplacementConnector(connection: staleConnection) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + originalEnvironment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: RuntimeReplacementHTTPTransport(), + webSocketConnector: connector + ) + let original = await runtime.client(for: originalEnvironment) + await original.connect() + await staleConnection.waitUntilReceiving() + + let firstLookup = Task { await runtime.client(for: updatedEnvironment) } + await staleConnection.waitUntilCloseStarted() + let concurrentLookup = await runtime.client(for: updatedEnvironment) + await staleConnection.releaseClose() + let replacement = await firstLookup.value + + XCTAssertTrue( + replacement === concurrentLookup, + "Concurrent lookups must share the replacement cached before stale disconnect." + ) + } + + func testSnapshotMergesEnvironmentsAndRoutesThreadWorkToItsOwner() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual(Set(snapshot.projects.map(\.environmentID)), ["one", "two"]) + XCTAssertEqual(Set(snapshot.threads.compactMap(\.wireID)), ["thread-one", "thread-two"]) + let remoteThread = try XCTUnwrap( + snapshot.threads.first(where: { $0.environmentID == "two" }) + ) + XCTAssertEqual( + remoteThread.environmentName, + "Steam Box" + ) + XCTAssertEqual( + snapshot.environments.first(where: { $0.id == "two" })?.connectionState, + .connected + ) + + let detail = try await fixture.client.loadThread(id: remoteThread.id) + XCTAssertEqual(detail.thread.environmentID, "two") + XCTAssertEqual(detail.thread.environmentName, "Steam Box") + + try await fixture.client.renameThread(id: remoteThread.id, title: "Remote rename") + try await fixture.client.sendMessage( + threadID: remoteThread.id, + text: "Run this on Steam Box", + selection: nil + ) + + let routedHosts = await fixture.transport.dispatchHosts() + XCTAssertEqual(routedHosts, ["two.example", "two.example"]) + await fixture.client.disconnect() + } + + func testBackgroundLivenessKeepsASettledThreadWorking() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Local work", + backgroundLiveness: .working + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap( + snapshot.threads.first(where: { $0.wireID == "thread-one" }) + ) + XCTAssertEqual(thread.state, .working) + + let detail = try await fixture.client.loadThread(id: thread.id) + XCTAssertEqual(detail.thread.state, .working) + XCTAssertTrue(detail.backgroundWorkIsActive) + await fixture.client.disconnect() + } + + func testNewerDetailSettlementBeatsOlderShellForNonActiveEnvironment() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Remote work", + snapshotSequence: 90, + settledOverride: "settled", + settledAt: "2026-07-31T12:01:00.000Z" + ), + host: "two.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 100 + ), + host: "two.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) + let detail = try await fixture.client.loadThread(id: thread.id) + + XCTAssertFalse(detail.thread.isSettled) + XCTAssertNil(detail.thread.settlementFacts?.settlementOverride) + await fixture.client.disconnect() + } + + func testNewerShellSettlementBeatsStaleDetailForNonActiveEnvironment() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + await fixture.transport.setShell( + multiEnvironmentShell( + projectID: "project-two", + threadID: "thread-two", + title: "Remote work", + snapshotSequence: 100, + settledOverride: "settled", + settledAt: "2026-07-31T12:01:00.000Z" + ), + host: "two.example" + ) + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 90 + ), + host: "two.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap(snapshot.threads.first { $0.environmentID == "two" }) + let detail = try await fixture.client.loadThread(id: thread.id) + + XCTAssertTrue(detail.thread.isSettled) + XCTAssertEqual(detail.thread.settlementFacts?.settlementOverride, .settled) + + await fixture.transport.setDetail( + multiEnvironmentDetail( + projectID: "project-two", + threadID: "thread-two", + snapshotSequence: 95 + ), + host: "two.example" + ) + let refreshed = try await fixture.client.loadThread(id: thread.id) + XCTAssertTrue(refreshed.thread.isSettled) + XCTAssertEqual(refreshed.thread.settlementFacts?.settlementOverride, .settled) + await fixture.client.disconnect() + } + + func testSnapshotKeepsRepositoryIdentityForCrossComputerProjectGrouping() async throws { + let identity = RepositoryIdentity( + canonicalKey: "github.com/t3/example", + locator: .init( + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3/example.git" + ), + rootPath: "/work/example", + displayName: "Example", + provider: "github", + owner: "t3", + name: "example" + ) + let fixture = try await makeFixture(repositoryIdentity: identity) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + let groups = DailyUXCreationContext.projectGroups(in: snapshot) + + XCTAssertEqual(Set(snapshot.projects.compactMap(\.repositoryIdentity?.canonicalKey)), [ + identity.canonicalKey, + ]) + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(Set(groups[0].projects.map(\.environmentID)), ["one", "two"]) + await fixture.client.disconnect() + } + + func testFailedEnvironmentKeepsItsLastKnownRowsWithoutHidingHealthyDevices() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + await fixture.transport.setReachable(false, host: "two.example") + + let passiveFailure = try await fixture.client.initialSnapshot() + XCTAssertEqual( + Set(passiveFailure.threads.compactMap(\.wireID)), + ["thread-one", "thread-two"] + ) + XCTAssertEqual(passiveFailure.connection.state, .connected) + XCTAssertEqual( + passiveFailure.environments.first(where: { $0.id == "two" })?.connectionState, + .disconnected + ) + + await fixture.transport.setReachable(false, host: "one.example") + await fixture.transport.setReachable(true, host: "two.example") + + let activeFailure = try await fixture.client.initialSnapshot() + XCTAssertEqual( + Set(activeFailure.threads.compactMap(\.wireID)), + ["thread-one", "thread-two"] + ) + XCTAssertEqual(activeFailure.connection.state, .disconnected) + XCTAssertEqual(activeFailure.connection.environmentName, "Left Book") + XCTAssertEqual( + activeFailure.environments.first(where: { $0.id == "two" })?.connectionState, + .connected + ) + await fixture.client.disconnect() + } + + func testOlderHTTPSnapshotCannotReplaceNewerEnvironmentState() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let newer = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Newer work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 3, + projects: newer.projects, + threads: newer.threads, + updatedAt: newer.updatedAt + ), + host: "one.example" + ) + _ = try await fixture.client.initialSnapshot() + + let older = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Stale work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 2, + projects: older.projects, + threads: older.threads, + updatedAt: older.updatedAt + ), + host: "one.example" + ) + + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual( + snapshot.threads.first(where: { $0.environmentID == "one" })?.title, + "Newer work" + ) + await fixture.client.disconnect() + } + + func testThreadCreationCannotReplaceNewerEnvironmentStateWithAnOlderShell() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + + let newer = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Newer work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 3, + projects: newer.projects, + threads: newer.threads, + updatedAt: newer.updatedAt + ), + host: "one.example" + ) + let current = try await fixture.client.initialSnapshot() + let project = try XCTUnwrap( + current.projects.first(where: { $0.environmentID == "one" }) + ) + + let older = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Stale work" + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: 2, + projects: older.projects, + threads: older.threads, + updatedAt: older.updatedAt + ), + host: "one.example" + ) + + _ = try await fixture.client.createThread( + projectID: project.id, + title: "Another task", + selection: nil + ) + let snapshot = try await fixture.client.initialSnapshot() + + XCTAssertEqual( + snapshot.threads.first(where: { $0.wireID == "thread-one" })?.title, + "Newer work" + ) + await fixture.client.disconnect() + } + + func testPullRequestPagesPreserveCursorsAndTargetOnlyTheRequestedEnvironment() async throws { + let recorder = PullRequestPageRecorder() + let fixture = try await makeFixture( + pullRequestsAvailable: true, + webSocketConnector: PullRequestPageWebSocketConnector(recorder: recorder), + rpcConnectionWaitTimeout: .seconds(2) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let firstPages = try await fixture.client.pullRequestLists(PullRequestListInput()) + + XCTAssertEqual(Set(firstPages.map(\.environmentID)), ["one", "two"]) + XCTAssertTrue(firstPages.allSatisfy { $0.result?.truncated == true }) + XCTAssertTrue(firstPages.allSatisfy { $0.result?.nextCursors.isEmpty == false }) + let initialRequests = await recorder.recordedRequests() + XCTAssertEqual(initialRequests.count, 2) + XCTAssertEqual(Set(initialRequests.map(\.host)), ["one.example", "two.example"]) + + let cursor = try XCTUnwrap( + firstPages.first(where: { $0.environmentID == "two" })?.result?.nextCursors + ) + let nextPage = try await fixture.client.pullRequestLists( + PullRequestListInput(cursors: cursor), + environmentID: "two" + ) + + XCTAssertEqual(nextPage.map(\.environmentID), ["two"]) + let requests = await recorder.recordedRequests() + XCTAssertEqual(requests.count, 3) + XCTAssertEqual(requests.last?.host, "two.example") + XCTAssertEqual(requests.last?.input.cursors, cursor) + await fixture.client.disconnect() + } + + func testBackgroundSnapshotDoesNotStartAggregateRefreshLoops() async throws { + let loader = CountingAggregateEnvironmentLoader() + let fixture = try await makeFixture( + aggregateEnvironmentLoader: { runtime in + await loader.recordLoad() + return try await runtime.environments() + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.backgroundSnapshot() + + XCTAssertEqual(snapshot.connection.state, .connected) + let aggregateLoadCount = await loader.callCount + XCTAssertEqual(aggregateLoadCount, 0) + await fixture.client.disconnect() + } + + func testAggregateRefreshRetriesTransientEnvironmentLoadFailures() async throws { + let loader = FailOnceAggregateEnvironmentLoader() + let fixture = try await makeFixture( + aggregateRefreshInterval: .milliseconds(5), + aggregateEnvironmentLoader: { runtime in + try await loader.load(from: runtime) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + + await loader.waitForCallCount(2) + let retryCallCount = await loader.callCount + XCTAssertGreaterThanOrEqual(retryCallCount, 2) + await fixture.client.disconnect() + } + + func testSameClientSnapshotRestartsAggregateRefresh() async throws { + let loader = BlockingFirstAggregateEnvironmentLoader() + let fixture = try await makeFixture( + aggregateRefreshInterval: .milliseconds(5), + aggregateEnvironmentLoader: { runtime in + try await loader.load(from: runtime) + } + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + _ = try await fixture.client.initialSnapshot() + await loader.waitForCallCount(1) + + _ = try await fixture.client.initialSnapshot() + + await loader.waitForFirstLoadCancellation() + await loader.waitForCallCount(2) + let restartedCallCount = await loader.callCount + XCTAssertGreaterThanOrEqual(restartedCallCount, 2) + await fixture.client.disconnect() + } + + func testDuplicateWireIDsRemainDistinctAndRouteByEnvironment() async throws { + let fixture = try await makeFixture(duplicateIDs: true) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + XCTAssertEqual(snapshot.projects.count, 2) + XCTAssertEqual(snapshot.threads.count, 2) + XCTAssertEqual(Set(snapshot.projects.map(\.id)).count, 2) + XCTAssertEqual(Set(snapshot.threads.map(\.id)).count, 2) + XCTAssertEqual(Set(snapshot.projects.compactMap(\.wireID)), ["project-shared"]) + XCTAssertEqual(Set(snapshot.threads.compactMap(\.wireID)), ["thread-shared"]) + + let remote = try XCTUnwrap( + snapshot.threads.first(where: { $0.environmentID == "two" }) + ) + _ = try await fixture.client.loadThread(id: remote.id) + try await fixture.client.renameThread(id: remote.id, title: "Remote only") + + let hosts = await fixture.transport.dispatchHosts() + XCTAssertEqual(hosts, ["two.example"]) + await fixture.client.disconnect() + } + + func testPassiveCreateUsesOwningProjectDefaultAndFallbackRemainsRoutable() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let snapshot = try await fixture.client.initialSnapshot() + let remoteProject = try XCTUnwrap( + snapshot.projects.first(where: { $0.environmentID == "two" }) + ) + let created = try await fixture.client.createThread( + projectID: remoteProject.id, + title: "Passive task", + selection: nil + ) + + XCTAssertEqual(created.environmentID, "two") + XCTAssertEqual(created.projectID, remoteProject.id) + XCTAssertNotNil(created.wireID) + try await fixture.client.renameThread(id: created.id, title: "Fallback routed") + + let records = await fixture.transport.dispatchRecords() + XCTAssertEqual(records.map(\.host), ["two.example", "two.example"]) + XCTAssertEqual(records[0].command["type"]?.stringValue, "thread.create") + XCTAssertEqual(records[0].command["projectId"]?.stringValue, "project-two") + XCTAssertEqual( + records[0].command["modelSelection"]?["instanceId"]?.stringValue, + "claudeAgent" + ) + XCTAssertEqual( + records[1].command["threadId"]?.stringValue, + created.wireID + ) + await fixture.client.disconnect() + } + + func testPassiveCreateRecoversACommittedThreadAfterItsReplyIsLost() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let snapshot = try await fixture.client.initialSnapshot() + let project = try XCTUnwrap( + snapshot.projects.first(where: { $0.environmentID == "two" }) + ) + await fixture.transport.dropNextCreateReply(host: "two.example") + + let created = try await fixture.client.createThread( + projectID: project.id, + title: "Recovered task", + selection: nil + ) + + XCTAssertEqual(created.title, "Recovered task") + XCTAssertEqual(created.environmentID, "two") + let creates = await fixture.transport.dispatchRecords().filter { + $0.command["type"]?.stringValue == "thread.create" + } + XCTAssertEqual(creates.count, 1) + XCTAssertEqual(creates.first?.command["threadId"]?.stringValue, created.wireID) + await fixture.client.disconnect() + } + + func testUnarchiveImmediatelyRestoresLiveThreadWhenRefreshIsUnavailable() async throws { + let fixture = try await makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let initial = try await fixture.client.initialSnapshot() + let thread = try XCTUnwrap( + initial.threads.first(where: { $0.environmentID == "one" }) + ) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + await fixture.transport.setShellReadsEnabled(false, host: "one.example") + + try await fixture.client.setThreadArchived(id: thread.id, archived: true) + while let event = await iterator.next() { + if case let .thread(candidate) = event, + candidate.id == thread.id, + candidate.isArchived { + break + } + } + try await fixture.client.setThreadArchived(id: thread.id, archived: false) + var restored: FeatureThread? + while let event = await iterator.next() { + if case let .thread(candidate) = event, + candidate.id == thread.id, + !candidate.isArchived { + restored = candidate + break + } + } + + XCTAssertEqual(restored?.id, thread.id) + XCTAssertEqual(restored?.isArchived, false) + await fixture.client.disconnect() + } + + func testHTTPFallbackKeepsLiveConnectionReconnecting() async throws { + let fixture = try await makeFixture( + fallbackPollingInitialDelay: .milliseconds(40), + fallbackPollingInterval: .seconds(2) + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + _ = try await fixture.client.initialSnapshot() + let current = multiEnvironmentShell( + projectID: "project-one", + threadID: "thread-one", + title: "Local work" + ) + let addedProject = OrchestrationProject( + id: "project-fallback", + title: "Fallback project", + workspaceRoot: "/work/fallback", + repositoryIdentity: nil, + defaultModelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + scripts: [], + createdAt: current.updatedAt, + updatedAt: current.updatedAt, + deletedAt: nil + ) + await fixture.transport.setShell( + OrchestrationShellSnapshot( + snapshotSequence: current.snapshotSequence + 1, + projects: current.projects + [addedProject], + threads: current.threads, + updatedAt: current.updatedAt + ), + host: "one.example" + ) + let events = fixture.client.events() + var iterator = events.makeAsyncIterator() + var refreshed: FeatureSnapshot? + while let event = await iterator.next() { + if case let .snapshot(snapshot) = event, + snapshot.projects.contains(where: { $0.wireID == addedProject.id }) { + refreshed = snapshot + break + } + } + + XCTAssertEqual(refreshed?.connection.state, .reconnecting) + await fixture.client.disconnect() + } + + private func makeFixture( + duplicateIDs: Bool = false, + repositoryIdentity: RepositoryIdentity? = nil, + pullRequestsAvailable: Bool = false, + webSocketConnector: any WebSocketConnecting = UnavailableMultiEnvironmentWebSocketConnector(), + rpcConnectionWaitTimeout: Duration = .milliseconds(5), + fallbackPollingInitialDelay: Duration = .seconds(3), + fallbackPollingInterval: Duration = .seconds(2), + aggregateRefreshInterval: Duration = .seconds(20), + aggregateEnvironmentLoader: @escaping @Sendable (EnvironmentRuntime) async throws -> [Environment] = { + try await $0.environments() + } + ) async throws -> MultiEnvironmentFixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-multi-\(UUID().uuidString)", isDirectory: true) + let environments = [ + Environment( + id: "one", + label: "Left Book", + httpBaseURL: URL(string: "https://one.example")!, + webSocketBaseURL: URL(string: "wss://one.example")!, + descriptor: try multiEnvironmentDescriptor( + environmentID: "one", + label: "Left Book", + pullRequestsAvailable: pullRequestsAvailable + ) + ), + Environment( + id: "two", + label: "Steam Box", + httpBaseURL: URL(string: "https://two.example")!, + webSocketBaseURL: URL(string: "wss://two.example")!, + descriptor: try multiEnvironmentDescriptor( + environmentID: "two", + label: "Steam Box", + pullRequestsAvailable: pullRequestsAvailable + ) + ), + ] + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save(environments) + try await store.setActiveEnvironment(id: "one") + let transport = MultiEnvironmentHTTPTransport( + shells: [ + "one.example": multiEnvironmentShell( + projectID: duplicateIDs ? "project-shared" : "project-one", + threadID: duplicateIDs ? "thread-shared" : "thread-one", + title: "Local work", + repositoryIdentity: repositoryIdentity + ), + "two.example": multiEnvironmentShell( + projectID: duplicateIDs ? "project-shared" : "project-two", + threadID: duplicateIDs ? "thread-shared" : "thread-two", + title: "Remote work", + providerID: "claudeAgent", + modelID: "claude-opus-4-1", + repositoryIdentity: repositoryIdentity + ), + ] + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + "one": EnvironmentCredential(accessToken: "one-token"), + "two": EnvironmentCredential(accessToken: "two-token"), + ] + ), + httpTransport: transport, + webSocketConnector: webSocketConnector, + rpcConnectionWaitTimeout: rpcConnectionWaitTimeout + ) + let settings = UserDefaults( + suiteName: "t3-native-multi-\(UUID().uuidString)" + )! + return MultiEnvironmentFixture( + directory: directory, + transport: transport, + client: NativeFeatureClient( + runtime: runtime, + settingsStore: settings, + fallbackPollingInitialDelay: fallbackPollingInitialDelay, + fallbackPollingInterval: fallbackPollingInterval, + aggregateRefreshInterval: aggregateRefreshInterval, + aggregateEnvironmentLoader: aggregateEnvironmentLoader + ) + ) + } +} + +private actor FailOnceAggregateEnvironmentLoader { + private(set) var callCount = 0 + private var callCountWaiters: [( + target: Int, + continuation: CheckedContinuation + )] = [] + + func load(from runtime: EnvironmentRuntime) async throws -> [Environment] { + callCount += 1 + resumeSatisfiedWaiters() + if callCount == 1 { + throw URLError(.cannotOpenFile) + } + return try await runtime.environments() + } + + func waitForCallCount(_ target: Int) async { + guard callCount < target else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((target, continuation)) + } + } + + private func resumeSatisfiedWaiters() { + let satisfied = callCountWaiters.filter { callCount >= $0.target } + callCountWaiters.removeAll { callCount >= $0.target } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +private actor CountingAggregateEnvironmentLoader { + private(set) var callCount = 0 + + func recordLoad() { + callCount += 1 + } +} + +private actor BlockingFirstAggregateEnvironmentLoader { + private(set) var callCount = 0 + private var callCountWaiters: [( + target: Int, + continuation: CheckedContinuation + )] = [] + private var firstLoadContinuation: CheckedContinuation? + private var firstLoadCancellationObserved = false + private var firstLoadCancellationWaiters: [CheckedContinuation] = [] + + func load(from runtime: EnvironmentRuntime) async throws -> [Environment] { + callCount += 1 + resumeSatisfiedWaiters() + if callCount == 1 { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + firstLoadContinuation = continuation + if Task.isCancelled { + firstLoadContinuation = nil + continuation.resume() + } + } + } onCancel: { + Task { await self.recordFirstLoadCancellation() } + } + try Task.checkCancellation() + } + return try await runtime.environments() + } + + func waitForCallCount(_ target: Int) async { + guard callCount < target else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((target, continuation)) + } + } + + func waitForFirstLoadCancellation() async { + guard !firstLoadCancellationObserved else { return } + await withCheckedContinuation { continuation in + firstLoadCancellationWaiters.append(continuation) + } + } + + private func recordFirstLoadCancellation() { + firstLoadCancellationObserved = true + firstLoadContinuation?.resume() + firstLoadContinuation = nil + let waiters = firstLoadCancellationWaiters + firstLoadCancellationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func resumeSatisfiedWaiters() { + let satisfied = callCountWaiters.filter { callCount >= $0.target } + callCountWaiters.removeAll { callCount >= $0.target } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} + +private actor RuntimeReplacementConnector: WebSocketConnecting { + let connection: BlockingRuntimeCloseConnection + + init(connection: BlockingRuntimeCloseConnection) { + self.connection = connection + } + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor BlockingRuntimeCloseConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + private var receiveWaiters: [CheckedContinuation] = [] + private var closeContinuation: CheckedContinuation? + private var closeWaiters: [CheckedContinuation] = [] + + func send(_: Data) {} + + func receive() async throws -> Data { + let waiters = receiveWaiters + receiveWaiters.removeAll() + waiters.forEach { $0.resume() } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() async { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + let waiters = closeWaiters + closeWaiters.removeAll() + waiters.forEach { $0.resume() } + await withCheckedContinuation { continuation in + closeContinuation = continuation + } + } + + func waitUntilReceiving() async { + guard receiveContinuation == nil else { return } + await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func waitUntilCloseStarted() async { + guard closeContinuation == nil else { return } + await withCheckedContinuation { continuation in + closeWaiters.append(continuation) + } + } + + func releaseClose() { + closeContinuation?.resume() + closeContinuation = nil + } +} + +private actor RuntimeReplacementHTTPTransport: HTTPTransport { + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + guard request.url?.path == "/api/auth/websocket-ticket" else { + throw URLError(.unsupportedURL) + } + return ( + Data("{\"ticket\":\"ticket\",\"expiresAt\":\"2026-08-01T12:05:00.000Z\"}".utf8), + multiEnvironmentResponse(request) + ) + } +} + +private struct MultiEnvironmentFixture { + let directory: URL + let transport: MultiEnvironmentHTTPTransport + let client: NativeFeatureClient +} + +private actor MultiEnvironmentHTTPTransport: HTTPTransport { + private let shells: [String: OrchestrationShellSnapshot] + private var shellData: [String: Data] + private var detailData: [String: [String: Data]] = [:] + private var reachableHosts: Set + private var shellReadsEnabledHosts: Set + private var dispatched: [MultiEnvironmentDispatchRecord] = [] + private var hostsDroppingNextCreateReply = Set() + + init(shells: [String: OrchestrationShellSnapshot]) { + self.shells = shells + shellData = shells.mapValues { try! JSONEncoder.t3.encode($0) } + reachableHosts = Set(shells.keys) + shellReadsEnabledHosts = Set(shells.keys) + } + + func setReachable(_ reachable: Bool, host: String) { + if reachable { + reachableHosts.insert(host) + } else { + reachableHosts.remove(host) + } + } + + func setShellReadsEnabled(_ enabled: Bool, host: String) { + if enabled { + shellReadsEnabledHosts.insert(host) + } else { + shellReadsEnabledHosts.remove(host) + } + } + + func setShell(_ shell: OrchestrationShellSnapshot, host: String) { + shellData[host] = try! JSONEncoder.t3.encode(shell) + } + + func setDetail( + _ detail: OrchestrationThreadDetailSnapshot, + host: String + ) { + detailData[host, default: [:]][detail.thread.id] = try! JSONEncoder.t3.encode(detail) + } + + func dispatchHosts() -> [String] { + dispatched.map(\.host) + } + + func dispatchRecords() -> [MultiEnvironmentDispatchRecord] { + dispatched + } + + func dropNextCreateReply(host: String) { + hostsDroppingNextCreateReply.insert(host) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let host = request.url?.host ?? "" + guard reachableHosts.contains(host) else { + throw URLError(.cannotConnectToHost) + } + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell", + shellReadsEnabledHosts.contains(host), + let data = shellData[host] { + return (data, multiEnvironmentResponse(request)) + } + if path.hasPrefix("/api/orchestration/threads/") { + let threadID = request.url?.lastPathComponent.removingPercentEncoding ?? "thread" + if let data = detailData[host]?[threadID] { + return (data, multiEnvironmentResponse(request)) + } + let projectID = shells[host]?.threads + .first(where: { $0.id == threadID })? + .projectId ?? shells[host]?.projects.first?.id ?? "project" + return ( + try JSONEncoder.t3.encode( + multiEnvironmentDetail(projectID: projectID, threadID: threadID) + ), + multiEnvironmentResponse(request) + ) + } + if path == "/api/orchestration/dispatch" { + guard let body = request.httpBody else { throw URLError(.badServerResponse) } + let command = try JSONDecoder.t3.decode(JSONValue.self, from: body) + dispatched.append( + MultiEnvironmentDispatchRecord(host: host, command: command) + ) + if command["type"]?.stringValue == "thread.create", + hostsDroppingNextCreateReply.remove(host) != nil, + let projectID = command["projectId"]?.stringValue, + let threadID = command["threadId"]?.stringValue { + let model = command["modelSelection"] + shellData[host] = try JSONEncoder.t3.encode( + multiEnvironmentShell( + projectID: projectID, + threadID: threadID, + title: command["title"]?.stringValue ?? "New thread", + providerID: model?["instanceId"]?.stringValue ?? "codex", + modelID: model?["model"]?.stringValue ?? "gpt-5.6-sol" + ) + ) + throw URLError(.networkConnectionLost) + } + return ( + try JSONEncoder.t3.encode(DispatchResult(sequence: 2)), + multiEnvironmentResponse(request) + ) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + {"ticket":"ticket","expiresAt":"2026-07-31T12:05:00.000Z"} + """.utf8 + ), + multiEnvironmentResponse(request) + ) + } + throw URLError(.unsupportedURL) + } +} + +private struct MultiEnvironmentDispatchRecord: Sendable { + let host: String + let command: JSONValue +} + +private struct UnavailableMultiEnvironmentWebSocketConnector: WebSocketConnecting { + func connect(to _: URL) async throws -> any WebSocketConnection { + throw URLError(.cannotConnectToHost) + } +} + +private struct PullRequestPageRequest: Sendable { + let host: String + let input: PullRequestListInput +} + +private actor PullRequestPageRecorder { + private var requests: [PullRequestPageRequest] = [] + + func record(host: String, input: PullRequestListInput) { + requests.append(PullRequestPageRequest(host: host, input: input)) + } + + func recordedRequests() -> [PullRequestPageRequest] { + requests + } +} + +private struct PullRequestPageWebSocketConnector: WebSocketConnecting { + let recorder: PullRequestPageRecorder + + func connect(to url: URL) -> any WebSocketConnection { + PullRequestPageWebSocketConnection(host: url.host ?? "", recorder: recorder) + } +} + +private actor PullRequestPageWebSocketConnection: WebSocketConnection { + private let host: String + private let recorder: PullRequestPageRecorder + private var queuedResponses: [Data] = [] + private var receiveContinuation: CheckedContinuation? + + init(host: String, recorder: PullRequestPageRecorder) { + self.host = host + self.recorder = recorder + } + + func send(_ data: Data) async throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + guard request["tag"]?.stringValue == RPCMethod.pullRequestsList.rawValue, + case let .number(requestID)? = request["id"], + let payload = request["payload"] else { return } + + let input = try payload.decode(PullRequestListInput.self) + await recorder.record(host: host, input: input) + let page = PullRequestListResult( + viewers: ["github.com": "theo"], + providers: [], + entries: [], + errors: [], + truncated: true, + nextCursors: ["github.com t3/repo": "cursor-\(host)"] + ) + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": try JSONValue.encode(page), + ]), + ]) + let responseData = try JSONEncoder.t3.encode(response) + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: responseData) + } else { + queuedResponses.append(responseData) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } +} + +private func multiEnvironmentDescriptor( + environmentID: String, + label: String, + pullRequestsAvailable: Bool +) throws -> EnvironmentDescriptor? { + guard pullRequestsAvailable else { return nil } + let value = JSONValue.object([ + "environmentId": .string(environmentID), + "label": .string(label), + "platform": .object([ + "os": .string("darwin"), + "arch": .string("arm64"), + ]), + "serverVersion": .string("0.1.0"), + "capabilities": .object([ + "repositoryIdentity": .bool(true), + "pullRequests": .bool(true), + ]), + ]) + return try value.decode(EnvironmentDescriptor.self) +} + +private func multiEnvironmentShell( + projectID: String, + threadID: String, + title: String, + providerID: String = "codex", + modelID: String = "gpt-5.6-sol", + repositoryIdentity: RepositoryIdentity? = nil, + backgroundLiveness: OrchestrationBackgroundLiveness? = nil, + snapshotSequence: Int = 1, + settledOverride: String? = nil, + settledAt: String? = nil +) -> OrchestrationShellSnapshot { + let timestamp = "2026-07-31T12:00:00.000Z" + let model = ModelSelection(instanceId: providerID, model: modelID) + return OrchestrationShellSnapshot( + snapshotSequence: snapshotSequence, + projects: [ + OrchestrationProject( + id: projectID, + title: title, + workspaceRoot: "/work/\(projectID)", + repositoryIdentity: repositoryIdentity, + defaultModelSelection: model, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: nil + ), + ], + threads: [ + OrchestrationThreadShell( + id: threadID, + projectId: projectID, + title: title, + modelSelection: model, + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "feat/multi-device", + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: settledOverride, + settledAt: settledAt, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: backgroundLiveness + ), + ], + updatedAt: timestamp + ) +} + +private func multiEnvironmentDetail( + projectID: String, + threadID: String, + snapshotSequence: Int = 2, + settledOverride: String? = nil, + settledAt: String? = nil +) -> OrchestrationThreadDetailSnapshot { + let timestamp = "2026-07-31T12:00:00.000Z" + return OrchestrationThreadDetailSnapshot( + snapshotSequence: snapshotSequence, + thread: OrchestrationThread( + id: threadID, + projectId: projectID, + title: threadID, + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: "feat/multi-device", + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: settledOverride, + settledAt: settledAt, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: [], + activities: [], + checkpoints: [], + session: nil + ) + ) +} + +private func multiEnvironmentResponse(_ request: URLRequest) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift new file mode 100644 index 000000000000..1d2b05074ebe --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeRetryIdentityTests.swift @@ -0,0 +1,762 @@ +import Foundation +import XCTest +@testable import T3Code + +@MainActor +final class NativeRetryIdentityTests: XCTestCase { + func testConcurrentBootstrapRetriesKeepIndependentStableIdentities() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-concurrent-retry-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let environment = Environment( + id: "environment-concurrent-retry", + label: "Concurrent retry", + httpBaseURL: URL(string: "https://concurrent-retry.example")!, + webSocketBaseURL: URL(string: "wss://concurrent-retry.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + let transport = ConcurrentBootstrapHTTPTransport(shell: retryShellSnapshot()) + let connection = ConcurrentBootstrapWebSocketConnection() + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [environment.id: EnvironmentCredential(accessToken: "token")] + ), + httpTransport: transport, + webSocketConnector: ConcurrentBootstrapWebSocketConnector(connection: connection) + ) + let client = NativeFeatureClient( + runtime: runtime, + settingsStore: UserDefaults( + suiteName: "t3-native-concurrent-retry-\(UUID().uuidString)" + )! + ) + _ = try await client.initialSnapshot() + await connection.waitUntilConnected() + await transport.rejectShellReads() + + async let firstAttempt = failedBootstrap(client: client, prompt: "First task") + async let secondAttempt = failedBootstrap(client: client, prompt: "Second task") + _ = await (firstAttempt, secondAttempt) + await connection.waitUntilDispatchCount(2) + + await failedBootstrap(client: client, prompt: "First task") + await failedBootstrap(client: client, prompt: "Second task") + + let commands = await connection.dispatchCommands() + XCTAssertEqual(commands.count, 4) + for prompt in ["First task", "Second task"] { + let matching = commands.filter { + $0["message"]?["text"]?.stringValue == prompt + } + XCTAssertEqual(matching.count, 2, "Expected an initial attempt and one retry.") + XCTAssertEqual(matching.first?["threadId"], matching.last?["threadId"]) + XCTAssertEqual(matching.first?["commandId"], matching.last?["commandId"]) + XCTAssertEqual( + matching.first?["message"]?["messageId"], + matching.last?["message"]?["messageId"] + ) + } + await client.disconnect() + } + + func testTurnRetriesStayStableAndConfirmedBootstrapFailureResetsIdentity() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-retry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "environment-retry", + label: "Retry", + httpBaseURL: URL(string: "https://retry.example")!, + webSocketBaseURL: URL(string: "wss://retry.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + + let connection = AmbiguousDispatchWebSocketConnection() + let transport = RetryIdentityHTTPTransport(shell: retryShellSnapshot()) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: transport, + webSocketConnector: RetryIdentityWebSocketConnector(connection: connection) + ) + let settings = UserDefaults( + suiteName: "t3-native-retry-\(UUID().uuidString)" + )! + let client = NativeFeatureClient(runtime: runtime, settingsStore: settings) + let initial = try await client.initialSnapshot() + XCTAssertEqual(initial.threads.first?.runtimeMode, .approvalRequired) + XCTAssertEqual(initial.threads.first?.interactionMode, .standard) + await connection.waitUntilConnected() + + let turnIdentity = FeatureSubmissionIdentity( + threadID: "thread-existing", + commandID: "persisted-turn-command", + messageID: "persisted-turn-message", + createdAt: Date(timeIntervalSince1970: 1_750_000_000) + ) + for _ in 0..<2 { + do { + try await client.sendMessage( + threadID: "thread-existing", + text: "Retry without duplicating", + selection: nil, + attachments: [], + identity: turnIdentity + ) + XCTFail("The synthetic dispatch should fail ambiguously.") + } catch {} + } + + for _ in 0..<2 { + do { + _ = try await client.createThreadAndSend( + projectID: "project-1", + prompt: "Create exactly one task", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .autoAcceptEdits, + interactionMode: .plan, + attachments: [] + ) + XCTFail("The synthetic bootstrap should fail ambiguously.") + } catch {} + } + + let commands = await connection.dispatchCommands() + + transport.dispatchCommands() + XCTAssertEqual(commands.count, 4) + let turnCommands = commands.filter { + $0["message"]?["text"]?.stringValue == "Retry without duplicating" + } + XCTAssertEqual(turnCommands.count, 2) + let initialTurn = try XCTUnwrap(turnCommands.first) + let retriedTurn = try XCTUnwrap(turnCommands.dropFirst().first) + assertStableIdentity(initialTurn, retriedTurn, includesThreadID: false) + XCTAssertEqual(initialTurn["commandId"]?.stringValue, turnIdentity.commandID) + XCTAssertEqual( + initialTurn["message"]?["messageId"]?.stringValue, + turnIdentity.messageID + ) + let bootstrapCommands = commands.filter { + $0["message"]?["text"]?.stringValue == "Create exactly one task" + } + XCTAssertEqual(bootstrapCommands.count, 2) + let initialBootstrap = try XCTUnwrap(bootstrapCommands.first) + let retriedBootstrap = try XCTUnwrap(bootstrapCommands.dropFirst().first) + XCTAssertNotEqual(initialBootstrap["commandId"], retriedBootstrap["commandId"]) + XCTAssertNotEqual( + initialBootstrap["message"]?["messageId"], + retriedBootstrap["message"]?["messageId"] + ) + XCTAssertNotEqual(initialBootstrap["threadId"], retriedBootstrap["threadId"]) + for command in turnCommands { + XCTAssertEqual(command["runtimeMode"]?.stringValue, "approval-required") + XCTAssertEqual(command["interactionMode"]?.stringValue, "default") + } + for command in bootstrapCommands { + XCTAssertEqual(command["runtimeMode"]?.stringValue, "auto-accept-edits") + XCTAssertEqual(command["interactionMode"]?.stringValue, "default") + } + await client.disconnect() + } + + func testPartialBootstrapRecoversBySendingOnlyTheStableFinalTurn() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-native-partial-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let environment = Environment( + id: "environment-partial", + label: "Partial", + httpBaseURL: URL(string: "https://partial.example")!, + webSocketBaseURL: URL(string: "wss://partial.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([environment]) + try await store.setActiveEnvironment(id: environment.id) + + let connection = PartialBootstrapWebSocketConnection() + let transport = PartialBootstrapHTTPTransport(shell: retryShellSnapshot()) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: InMemoryCredentialStore( + credentials: [ + environment.id: EnvironmentCredential(accessToken: "token"), + ] + ), + httpTransport: transport, + webSocketConnector: PartialBootstrapWebSocketConnector(connection: connection) + ) + let settings = UserDefaults( + suiteName: "t3-native-partial-\(UUID().uuidString)" + )! + let client = NativeFeatureClient(runtime: runtime, settingsStore: settings) + _ = try await client.initialSnapshot() + await connection.waitUntilConnected() + + let identity = FeatureSubmissionIdentity( + threadID: "persisted-bootstrap-thread", + commandID: "persisted-bootstrap-command", + messageID: "persisted-bootstrap-message", + createdAt: Date(timeIntervalSince1970: 1_750_000_000) + ) + let created = try await client.createThreadAndSend( + projectID: "project-1", + prompt: "Recover the first turn", + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .standard, + workspaceMode: .local, + branch: nil, + worktreePath: nil, + startFromOrigin: false, + attachments: [], + identity: identity + ) + + let commands = await connection.dispatchCommands() + + transport.dispatchCommands() + XCTAssertEqual(commands.count, 2) + let bootstrap = try XCTUnwrap(commands.first { $0["bootstrap"] != nil }) + let finalTurn = try XCTUnwrap(commands.first { $0["bootstrap"] == nil }) + assertStableIdentity(bootstrap, finalTurn, includesThreadID: true) + XCTAssertEqual(bootstrap["threadId"]?.stringValue, identity.threadID) + XCTAssertEqual(bootstrap["commandId"]?.stringValue, identity.commandID) + XCTAssertEqual( + bootstrap["message"]?["messageId"]?.stringValue, + identity.messageID + ) + let wireID = try XCTUnwrap(bootstrap["threadId"]?.stringValue) + XCTAssertEqual(created.wireID, wireID) + XCTAssertEqual( + created.id, + FeatureScopedID.thread(environmentID: environment.id, wireID: wireID) + ) + await client.disconnect() + } + + private func assertStableIdentity( + _ first: JSONValue, + _ second: JSONValue, + includesThreadID: Bool + ) { + XCTAssertEqual(first["commandId"], second["commandId"]) + XCTAssertEqual(first["message"]?["messageId"], second["message"]?["messageId"]) + XCTAssertEqual(first["createdAt"], second["createdAt"]) + if includesThreadID { + XCTAssertEqual(first["threadId"], second["threadId"]) + } + } + + private func failedBootstrap(client: NativeFeatureClient, prompt: String) async { + do { + _ = try await client.createThreadAndSend( + projectID: "project-1", + prompt: prompt, + selection: FeatureSelection(providerID: "codex", modelID: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .standard, + attachments: [] + ) + XCTFail("The synthetic dispatch should fail ambiguously.") + } catch {} + } +} + +private struct ConcurrentBootstrapWebSocketConnector: WebSocketConnecting { + let connection: ConcurrentBootstrapWebSocketConnection + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor ConcurrentBootstrapWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var initialFailures: [CheckedContinuation] = [] + private var dispatchWaiters: [(Int, CheckedContinuation)] = [] + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) async throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + return + } + guard request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] else { + return + } + commands.append(payload) + let ready = dispatchWaiters.filter { commands.count >= $0.0 } + dispatchWaiters.removeAll { commands.count >= $0.0 } + ready.forEach { $0.1.resume() } + guard commands.count <= 2 else { + throw URLError(.networkConnectionLost) + } + return try await withCheckedThrowingContinuation { continuation in + initialFailures.append(continuation) + guard initialFailures.count == 2 else { return } + let failures = initialFailures + initialFailures.removeAll() + failures.forEach { $0.resume(throwing: URLError(.networkConnectionLost)) } + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func waitUntilDispatchCount(_ count: Int) async { + guard commands.count < count else { return } + await withCheckedContinuation { continuation in + dispatchWaiters.append((count, continuation)) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private actor ConcurrentBootstrapHTTPTransport: HTTPTransport { + private let shellData: Data + private var acceptsShellReads = true + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func rejectShellReads() { + acceptsShellReads = false + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + switch request.url?.path { + case "/api/orchestration/shell" where acceptsShellReads: + (shellData, retryHTTPResponse(request)) + case "/api/auth/websocket-ticket": + ( + Data( + "{\"ticket\":\"ticket\",\"expiresAt\":\"2026-08-01T12:05:00.000Z\"}".utf8 + ), + retryHTTPResponse(request) + ) + default: + throw URLError(.networkConnectionLost) + } + } +} + +private func retryShellSnapshot() -> OrchestrationShellSnapshot { + let timestamp = "2026-07-30T12:00:00.000Z" + let model = ModelSelection(instanceId: "codex", model: "gpt-5.4") + return OrchestrationShellSnapshot( + snapshotSequence: 1, + projects: [ + OrchestrationProject( + id: "project-1", + title: "T3 Code", + workspaceRoot: "/work/t3", + repositoryIdentity: nil, + defaultModelSelection: model, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: nil + ), + ], + threads: [ + OrchestrationThreadShell( + id: "thread-existing", + projectId: "project-1", + title: "Existing", + modelSelection: model, + runtimeMode: .approvalRequired, + interactionMode: .plan, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: nil + ), + ], + updatedAt: timestamp + ) +} + +private actor RetryIdentityHTTPTransport: HTTPTransport { + private let shellData: Data + private var commands: [JSONValue] = [] + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell" { + return (shellData, retryHTTPResponse(request)) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + { + "ticket": "ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """.utf8 + ), + retryHTTPResponse(request) + ) + } + if path.hasPrefix("/api/orchestration/threads/") { + throw URLError(.networkConnectionLost) + } + if path == "/api/orchestration/dispatch" { + commands.append(try retryDispatchCommand(from: request)) + throw URLError(.networkConnectionLost) + } + throw URLError(.unsupportedURL) + } + + func dispatchCommands() -> [JSONValue] { + commands + } +} + +private struct RetryIdentityWebSocketConnector: WebSocketConnecting { + let connection: AmbiguousDispatchWebSocketConnection + + func connect(to _: URL) async throws -> any WebSocketConnection { + connection + } +} + +private actor PartialBootstrapHTTPTransport: HTTPTransport { + private let shellData: Data + private var commands: [JSONValue] = [] + + init(shell: OrchestrationShellSnapshot) { + shellData = try! JSONEncoder.t3.encode(shell) + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + let path = request.url?.path ?? "" + if path == "/api/orchestration/shell" { + return (shellData, retryHTTPResponse(request)) + } + if path == "/api/auth/websocket-ticket" { + return ( + Data( + """ + { + "ticket": "ticket", + "expiresAt": "2026-07-30T12:05:00.000Z" + } + """.utf8 + ), + retryHTTPResponse(request) + ) + } + if path.hasPrefix("/api/orchestration/threads/") { + let threadID = request.url?.lastPathComponent.removingPercentEncoding ?? "thread" + let snapshot = retryEmptyThreadDetail(id: threadID) + return (try JSONEncoder.t3.encode(snapshot), retryHTTPResponse(request)) + } + if path == "/api/orchestration/dispatch" { + commands.append(try retryDispatchCommand(from: request)) + return ( + Data("{\"sequence\":42}".utf8), + retryHTTPResponse(request) + ) + } + throw URLError(.unsupportedURL) + } + + func dispatchCommands() -> [JSONValue] { + commands + } +} + +private struct PartialBootstrapWebSocketConnector: WebSocketConnecting { + let connection: PartialBootstrapWebSocketConnection + + func connect(to _: URL) async throws -> any WebSocketConnection { + connection + } +} + +private actor AmbiguousDispatchWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var queuedResponses: [Data] = [] + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + private var receiver: CheckedContinuation? + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + return + } + if request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] { + commands.append(payload) + throw URLError(.networkConnectionLost) + } + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private actor PartialBootstrapWebSocketConnection: WebSocketConnection { + private var commands: [JSONValue] = [] + private var queuedResponses: [Data] = [] + private var receiver: CheckedContinuation? + private var didConnect = false + private var connectionWaiters: [CheckedContinuation] = [] + + func send(_ data: Data) throws { + let request = try JSONDecoder.t3.decode(JSONValue.self, from: data) + if !didConnect { + didConnect = true + connectionWaiters.forEach { $0.resume() } + connectionWaiters.removeAll() + } + guard request["tag"]?.stringValue == RPCMethod.dispatchCommand.rawValue, + let payload = request["payload"] else { + if request["tag"]?.stringValue == RPCMethod.serverGetConfig.rawValue + || request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue, + let response = try retryConfigResponse(for: request) { + enqueue(response) + } + return + } + commands.append(payload) + if payload["bootstrap"] != nil { + throw URLError(.networkConnectionLost) + } + guard case let .number(requestID) = request["id"] else { return } + let response = JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": .object(["sequence": .number(42)]), + ]), + ]) + enqueue(try JSONEncoder.t3.encode(response)) + } + + func receive() async throws -> Data { + if !queuedResponses.isEmpty { + return queuedResponses.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiver = continuation + } + } + + func close() { + receiver?.resume(throwing: CancellationError()) + receiver = nil + } + + func waitUntilConnected() async { + guard !didConnect else { return } + await withCheckedContinuation { continuation in + connectionWaiters.append(continuation) + } + } + + func dispatchCommands() -> [JSONValue] { + commands + } + + private func enqueue(_ data: Data) { + if let receiver { + self.receiver = nil + receiver.resume(returning: data) + } else { + queuedResponses.append(data) + } + } +} + +private func retryConfigResponse(for request: JSONValue) throws -> Data? { + guard case let .number(requestID)? = request["id"] else { return nil } + let config = JSONValue.object(["providers": .array([])]) + if request["tag"]?.stringValue == RPCMethod.subscribeServerConfig.rawValue { + return try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Chunk"), + "requestId": .number(requestID), + "values": .array([.object([ + "type": .string("snapshot"), + "config": config, + ])]), + ]) + ) + } + return try JSONEncoder.t3.encode( + JSONValue.object([ + "_tag": .string("Exit"), + "requestId": .number(requestID), + "exit": .object([ + "_tag": .string("Success"), + "value": config, + ]), + ]) + ) +} + +private func retryEmptyThreadDetail(id: String) -> OrchestrationThreadDetailSnapshot { + let timestamp = "2026-07-30T12:00:00.000Z" + return OrchestrationThreadDetailSnapshot( + snapshotSequence: 2, + thread: OrchestrationThread( + id: id, + projectId: "project-1", + title: "Recover the first turn", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.4"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + deletedAt: nil, + messages: [], + activities: [], + checkpoints: [], + session: nil + ) + ) +} + +private func retryHTTPResponse(_ request: URLRequest) -> HTTPURLResponse { + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! +} + +private func retryDispatchCommand(from request: URLRequest) throws -> JSONValue { + guard let body = request.httpBody else { + throw URLError(.cannotDecodeContentData) + } + return try JSONDecoder.t3.decode(JSONValue.self, from: body) +} diff --git a/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift b/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift new file mode 100644 index 000000000000..ed45301d8840 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/NativeWorkLogAccumulatorTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Native work log accumulator") +struct NativeWorkLogAccumulatorTests { + @Test + func lifecycleUpdatesReplaceActiveWorkAndCompletionAddsOneLine() { + var accumulator = NativeWorkLogAccumulator() + accumulator.append( + activity( + id: "started", + kind: "tool.started", + summary: "Run tests", + payload: ["toolCallId": .string("call-1"), "title": .string("Run tests")] + ), + preview: nil, + createdAt: Date(timeIntervalSince1970: 1) + ) + accumulator.append( + activity( + id: "updated", + kind: "tool.updated", + summary: "Run focused tests", + payload: [ + "data": .object(["toolCallId": .string("call-1")]), + "title": .string("Run focused tests"), + ] + ), + preview: nil, + createdAt: Date(timeIntervalSince1970: 2) + ) + + var message = accumulator.message(groupID: "turn-1") + #expect(message.activeWorkLabel == "Run focused tests") + #expect(message.text.isEmpty) + + accumulator.append( + activity( + id: "completed", + kind: "tool.completed", + summary: "Run focused tests completed", + payload: ["toolCallId": .string("call-1")] + ), + preview: "2 tests passed", + createdAt: Date(timeIntervalSince1970: 3) + ) + message = accumulator.message(groupID: "turn-1") + #expect(message.activeWorkLabel == nil) + #expect(message.toolName == "Work log · 1") + #expect(message.text == "• 2 tests passed") + } + + @Test + func fallbackLifecycleMatchAndTurnEndClearActiveWork() { + var accumulator = NativeWorkLogAccumulator() + accumulator.append( + activity( + id: "started", + kind: "tool.started", + summary: "Read file", + payload: [ + "itemType": .string("dynamic_tool_call"), + "title": .string("Read file"), + "detail": .string("/tmp/screenshot.png"), + ] + ), + preview: nil, + createdAt: .now + ) + accumulator.append( + activity( + id: "completed", + kind: "tool.completed", + summary: "Read file completed", + payload: [ + "itemType": .string("dynamic_tool_call"), + "title": .string("Read file completed"), + "detail": .string("/tmp/screenshot.png"), + ] + ), + preview: "/tmp/screenshot.png", + createdAt: .now + ) + #expect(!accumulator.hasActiveWork) + + accumulator.append( + activity(id: "next", kind: "tool.started", summary: "Old task"), + preview: nil, + createdAt: .now + ) + accumulator.clearActiveWork() + #expect(accumulator.message(groupID: "turn-1").activeWorkLabel == nil) + } + + @Test + func viewedImagesExcludeMultilineAndNonImageDetails() { + var accumulator = NativeWorkLogAccumulator() + for (id, detail) in [ + ("image", "/tmp/image one.PNG"), + ("multiline", "/tmp/image.png\nextra"), + ("text", "/tmp/readme.txt"), + ] { + accumulator.append( + activity( + id: id, + kind: "tool.completed", + summary: "Read file", + payload: [ + "requestKind": .string("file-read"), + "detail": .string(detail), + ] + ), + preview: detail, + createdAt: .now + ) + } + #expect(accumulator.message(groupID: "turn-1").workLogImagePaths == [ + "/tmp/image one.PNG", + ]) + } + + @Test + func mediaRendersOnlyWhileExpandedAndEscapesMarkdownPaths() { + let paths = ["/tmp/image one(2).png"] + #expect(!FeatureWorkLogMedia.shouldRenderImages(isExpanded: false, paths: paths)) + #expect(FeatureWorkLogMedia.shouldRenderImages(isExpanded: true, paths: paths)) + #expect(FeatureWorkLogMedia.markdownSource(for: paths) == "![](/tmp/image%20one%282%29.png)") + } + + private func activity( + id: String, + kind: String, + summary: String, + payload: [String: JSONValue] = [:] + ) -> OrchestrationActivity { + OrchestrationActivity( + id: id, + tone: "info", + kind: kind, + summary: summary, + payload: .object(payload), + turnId: "turn-1", + sequence: nil, + createdAt: "2026-09-01T00:00:00Z" + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift b/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift new file mode 100644 index 000000000000..7b72fcbc1e75 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ProjectCreationModelsTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Native project creation") +struct ProjectCreationModelsTests { + @Test + func repositoryNamesCoverHttpsSshAndProviderPaths() { + #expect( + ProjectCreationPath.repositoryName( + from: "https://github.com/pingdotgg/t3code.git" + ) == "t3code" + ) + #expect( + ProjectCreationPath.repositoryName( + from: "git@github.com:pingdotgg/t3code.git" + ) == "t3code" + ) + #expect(ProjectCreationPath.repositoryName(from: "pingdotgg/t3code") == "t3code") + #expect(ProjectCreationPath.repositoryName(from: "") == "repository") + } + + @Test + func githubRepositoryShorthandUsesHttpsWithoutChangingExplicitRemotes() { + #expect( + ProjectCreationPath.normalizedCloneURL(" pingdotgg/t3code ") + == "https://github.com/pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("pingdotgg/t3code.git") + == "https://github.com/pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("git@github.com:pingdotgg/t3code.git") + == "git@github.com:pingdotgg/t3code.git" + ) + #expect( + ProjectCreationPath.normalizedCloneURL("https://gitlab.com/team/project.git") + == "https://gitlab.com/team/project.git" + ) + } + + @Test + func discoveredGitHubRepositoriesDefaultToTheirHttpsCloneURL() { + let github = SourceControlRepositoryInfo( + provider: .github, + nameWithOwner: "pingdotgg/t3code", + url: "https://github.com/pingdotgg/t3code", + sshUrl: "git@github.com:pingdotgg/t3code.git" + ) + let gitlab = SourceControlRepositoryInfo( + provider: .gitlab, + nameWithOwner: "team/project", + url: "https://gitlab.com/team/project", + sshUrl: "git@gitlab.com:team/project.git" + ) + + #expect(ProjectCreationPath.defaultCloneURL(for: github) == github.url) + #expect(ProjectCreationPath.defaultCloneURL(for: gitlab) == gitlab.sshUrl) + } + + @Test + func pathsRequireServerAbsoluteOrHomeRelativeInput() throws { + #expect(try ProjectCreationPath.validated(" ~/work/t3code ").get() == "~/work/t3code") + #expect(try ProjectCreationPath.validated("/srv/t3code").get() == "/srv/t3code") + #expect(try ProjectCreationPath.validated(#"C:\work\t3code"#).get() == #"C:\work\t3code"#) + #expect(ProjectCreationPath.validated("relative/project").isFailure) + #expect(ProjectCreationPath.validated(" ").isFailure) + } + + @Test + func destinationSuggestionsRespectUnixAndWindowsSeparators() { + #expect(ProjectCreationPath.appending("t3code", to: "~/work") == "~/work/t3code") + #expect(ProjectCreationPath.appending("t3code", to: "~/work/") == "~/work/t3code") + #expect( + ProjectCreationPath.appending("t3code", to: #"C:\work"#) == #"C:\work\t3code"# + ) + #expect( + ProjectCreationPath.normalizedForComparison(#"C:\Work\T3Code\"#) + == "c:/work/t3code" + ) + #expect(ProjectCreationPath.normalizedForComparison("/srv/App") == "/srv/App") + #expect(ProjectCreationPath.normalizedForComparison(#"/srv/a\b"#) == #"/srv/a\b"#) + } + + @Test + func folderBrowseQueriesNavigateDirectoriesInsteadOfPrefixSearching() { + #expect(ProjectCreationPath.directoryBrowsePath("~/work") == "~/work/") + #expect(ProjectCreationPath.directoryBrowsePath("/srv/t3code/") == "/srv/t3code/") + #expect( + ProjectCreationPath.directoryBrowsePath(#"C:\work\t3code"#) + == #"C:\work\t3code\"# + ) + + #expect(ProjectCreationPath.parentBrowsePath(of: "~/work/t3code/") == "~/work/") + #expect(ProjectCreationPath.parentBrowsePath(of: "~/") == nil) + #expect(ProjectCreationPath.parentBrowsePath(of: "/srv/t3code/") == "/srv/") + #expect(ProjectCreationPath.parentBrowsePath(of: "/") == nil) + #expect( + ProjectCreationPath.parentBrowsePath(of: #"C:\work\t3code\"#) + == #"C:\work\"# + ) + #expect(ProjectCreationPath.parentBrowsePath(of: #"C:\"#) == nil) + #expect( + ProjectCreationPath.parentBrowsePath(of: #"\\server\share\folder\"#) + == #"\\server\share\"# + ) + #expect(ProjectCreationPath.parentBrowsePath(of: #"\\server\share\"#) == nil) + #expect( + ProjectCreationPath.directoryBrowsePath("//server/share/folder") + == #"\\server\share\folder\"# + ) + } + + @Test + func projectTitlesHandleServerPathStyles() { + #expect(ProjectCreationPath.lastPathComponent("/srv/t3code/") == "t3code") + #expect(ProjectCreationPath.lastPathComponent(#"C:\work\t3code\"#) == "t3code") + #expect(ProjectCreationPath.lastPathComponent(#"\\server\share\t3code"#) == "t3code") + } + + @Test + func explicitPathsMatchTheConnectedServerFilesystemStyle() { + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "/srv/t3code", + serverPath: "/srv" + ) + ) + #expect( + !ProjectCreationPath.isCompatibleWithServerPath( + #"C:\work\t3code"#, + serverPath: "/srv" + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + #"C:\work\t3code"#, + serverPath: #"C:\work"# + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "//server/share/t3code", + serverPath: #"C:\work"# + ) + ) + #expect( + !ProjectCreationPath.isCompatibleWithServerPath( + "/srv/t3code", + serverPath: #"C:\work"# + ) + ) + #expect( + ProjectCreationPath.isCompatibleWithServerPath( + "~/work/t3code", + serverPath: #"C:\Users\theo"# + ) + ) + } + + @Test + func discoveryKeepsGitUrlReadyAndGatesProviderAuthentication() { + let discovery = SourceControlDiscoveryResult( + versionControlSystems: [], + sourceControlProviders: [ + SourceControlProviderDiscoveryItem( + kind: .github, + label: "GitHub", + status: .available, + version: "2.76", + installHint: "Install gh", + auth: SourceControlProviderAuth( + status: .authenticated, + account: "octocat" + ) + ), + SourceControlProviderDiscoveryItem( + kind: .gitlab, + label: "GitLab", + status: .available, + installHint: "Install glab", + auth: SourceControlProviderAuth( + status: .unauthenticated, + detail: "Run glab auth login" + ) + ), + ] + ) + + let options = ProjectRemoteSourceOptions.options(discovery: discovery) + let bySource = Dictionary(uniqueKeysWithValues: options.map { ($0.source, $0) }) + + #expect(bySource[.url]?.isReady == true) + #expect(bySource[.github]?.isReady == true) + #expect(bySource[.github]?.detail == "Signed in as octocat") + #expect(bySource[.gitlab]?.isReady == false) + #expect(bySource[.gitlab]?.detail == "Run glab auth login") + #expect(bySource[.bitbucket]?.isReady == false) + } +} + +private extension Result { + var isFailure: Bool { + if case .failure = self { return true } + return false + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift b/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift new file mode 100644 index 000000000000..b9527519801d --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/ProjectFaviconStoreTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +import UIKit +@testable import T3Code + +@Suite("Project favicon cache") +struct ProjectFaviconStoreTests { + @Test + func persistsLastKnownIconAcrossStoreInstances() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let key = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code/" + ) + let data = Data("known-icon".utf8) + let checkedAt = Date(timeIntervalSince1970: 1_000) + + let writer = FeatureProjectFaviconStore(directoryURL: directory) + try await writer.record( + data: data, + revision: "v1-favicon.svg", + for: key, + checkedAt: checkedAt + ) + + let reader = FeatureProjectFaviconStore(directoryURL: directory) + let value = try #require(try await reader.value(for: key)) + #expect(value.data == data) + #expect(value.revision == "v1-favicon.svg") + #expect(value.lastCheckedAt == checkedAt) + } + + @Test + func missingRemoteIconKeepsLastKnownProjectRelationship() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureProjectFaviconStore(directoryURL: directory) + let key = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code" + ) + let data = Data("cached-icon".utf8) + + try await store.record( + data: data, + revision: "v1-favicon.svg", + for: key, + checkedAt: Date(timeIntervalSince1970: 1_000) + ) + try await store.record( + data: nil, + revision: nil, + for: key, + checkedAt: Date(timeIntervalSince1970: 2_000) + ) + + let value = try #require(try await store.value(for: key)) + #expect(value.data == data) + #expect(value.revision == "v1-favicon.svg") + #expect(value.lastCheckedAt == Date(timeIntervalSince1970: 2_000)) + } + + @Test + func projectKeysSeparateEnvironmentsAndNormalizePaths() { + let first = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/./t3code/" + ) + let sameProject = FeatureProjectFaviconCacheKey( + environmentID: "leftbook", + workspaceRoot: "/work/t3code" + ) + let otherEnvironment = FeatureProjectFaviconCacheKey( + environmentID: "big-o", + workspaceRoot: "/work/t3code" + ) + + #expect(first == sameProject) + #expect(first.fingerprint == sameProject.fingerprint) + #expect(first != otherEnvironment) + #expect(first.fingerprint != otherEnvironment.fingerprint) + } + + @Test + @MainActor + func svgFaviconsAreRasterizedBeforeCaching() async throws { + let data = Data( + ##""##.utf8 + ) + + let renderable = try #require( + await FeatureProjectFaviconImageDecoder.renderableData(from: data) + ) + #expect(UIImage(data: renderable) != nil) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("project-favicon-cache-\(UUID().uuidString)") + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift b/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift new file mode 100644 index 000000000000..45e4eac43884 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/PullRequestDiffTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Pull request diff") +struct PullRequestDiffTests { + @Test + func parsesFilesAndReviewPositions() throws { + let patch = """ + diff --git a/Sources/App.swift b/Sources/App.swift + --- a/Sources/App.swift + +++ b/Sources/App.swift + @@ -10,2 +10,3 @@ + let old = true + -let value = 1 + +let value = 2 + +let extra = true + """ + + let files = PullRequestDiffParser.parse(patch) + let file = try #require(files.first) + + #expect(file.path == "Sources/App.swift") + #expect(file.lines.count == 5) + #expect(file.lines[1].oldLine == 10) + #expect(file.lines[2].position == .deleted(11)) + #expect(file.lines[3].position == .added(11)) + #expect(file.lines[4].position == .added(12)) + } + + @Test + func keepsRenamedFileContext() throws { + let patch = """ + diff --git a/Old.swift b/New.swift + --- a/Old.swift + +++ b/New.swift + @@ -1 +1 @@ + -old + +new + """ + + let file = try #require(PullRequestDiffParser.parse(patch).first) + + #expect(file.oldPath == "Old.swift") + #expect(file.path == "New.swift") + } + + @Test + func missingNewlineMarkersDoNotChangeReviewLineNumbers() throws { + let patch = """ + diff --git a/App.swift b/App.swift + --- a/App.swift + +++ b/App.swift + @@ -1,2 +1,2 @@ + -old + \\ No newline at end of file + +new + \\ No newline at end of file + context + """ + + let file = try #require(PullRequestDiffParser.parse(patch).first) + + #expect(file.lines.count == 4) + #expect(file.lines[1].position == .deleted(1)) + #expect(file.lines[2].position == .added(1)) + #expect(file.lines[3].oldLine == 2) + #expect(file.lines[3].newLine == 2) + } + + @Test + func repeatedDiffCursorsStopPaginationAndMarkDiffIncomplete() { + var pagination = PullRequestDiffPagination() + + #expect(pagination.append( + PullRequestDiffResult( + patch: "first", + truncated: false, + nextCursor: "first-cursor", + omittedFileStats: nil + ) + ) == "first-cursor") + #expect(pagination.append( + PullRequestDiffResult( + patch: "second", + truncated: false, + nextCursor: "second-cursor", + omittedFileStats: nil + ) + ) == "second-cursor") + #expect(pagination.append( + PullRequestDiffResult( + patch: "third", + truncated: false, + nextCursor: "first-cursor", + omittedFileStats: nil + ) + ) == nil) + + #expect(pagination.patch == "firstsecondthird") + #expect(pagination.isIncomplete) + } +} + +@MainActor +@Suite("Pull request pagination") +struct PullRequestPaginationTests { + @Test + func initialLoadStopsAfterTheFirstPage() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [page( + environmentID: "studio", + numbers: [3], + nextCursor: "studio-page-two" + )] + let model = PullRequestsModel(client: client) + + await model.load() + + #expect(model.rows.map(\.entry.number) == [3]) + #expect(model.hasMorePages) + #expect(client.initialRequests.count == 1) + #expect(client.targetedRequests.isEmpty) + } + + @Test + func additionalPagesKeepCursorsSeparateAndRemoveDuplicateRows() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [ + page(environmentID: "first", numbers: [3], nextCursor: "first-page-two"), + page(environmentID: "second", numbers: [4], nextCursor: "second-page-two"), + ] + client.targetedPages = [ + "first": page(environmentID: "first", numbers: [3, 2]), + "second": page(environmentID: "second", numbers: [4, 1]), + ] + let model = PullRequestsModel(client: client) + model.state = .closed + model.involvement = .authored + model.draftFilter = "only" + model.query = "Fix" + + await model.load() + await model.loadMore() + + #expect(model.rows.map(\.entry.number) == [4, 3, 2, 1]) + #expect(!model.hasMorePages) + #expect(client.targetedRequests.map(\.environmentID) == ["first", "second"]) + #expect(client.targetedRequests[0].input.cursors == [ + "github.com pingdotgg/t3code": "first-page-two", + ]) + #expect(client.targetedRequests[1].input.cursors == [ + "github.com pingdotgg/t3code": "second-page-two", + ]) + #expect(client.targetedRequests.allSatisfy { + $0.input.state == .closed + && $0.input.involvement == .authored + && $0.input.filters?.draft == "only" + && $0.input.query == "Fix" + }) + } + + @Test + func failedComputerKeepsItsRowsWhileOtherComputersContinue() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [ + page(environmentID: "offline", numbers: [3], nextCursor: "offline-page-two"), + page(environmentID: "online", numbers: [2], nextCursor: "online-page-two"), + ] + client.failedEnvironmentIDs = ["offline"] + client.targetedPages = [ + "online": page(environmentID: "online", numbers: [1]), + ] + let model = PullRequestsModel(client: client) + + await model.load() + await model.loadMore() + + #expect(model.rows.map(\.entry.number) == [3, 2, 1]) + #expect(model.environments.first?.errorMessage == "This computer is offline.") + #expect(model.environments.last?.errorMessage == nil) + #expect(model.hasMorePages) + #expect(client.targetedRequests.map(\.environmentID) == ["offline", "online"]) + } + + @Test + func stalePaginationCannotReplaceANewerReload() async { + let client = PullRequestPaginationClientStub() + client.firstPages = [page( + environmentID: "studio", + numbers: [3], + nextCursor: "studio-page-two" + )] + let model = PullRequestsModel(client: client) + await model.load() + + let started = AsyncStream.makeStream() + var response: CheckedContinuation<[FeaturePullRequestEnvironmentList], any Error>? + client.beforeTargetedResponse = { _, _ in + try await withCheckedThrowingContinuation { continuation in + response = continuation + started.continuation.yield() + } + } + + let pagination = Task { await model.loadMore() } + var requests = started.stream.makeAsyncIterator() + await requests.next() + await model.loadMore() + #expect(client.targetedRequests.count == 1) + + client.firstPages = [page(environmentID: "studio", numbers: [5])] + await model.load() + response?.resume(returning: [page(environmentID: "studio", numbers: [2])]) + await pagination.value + + #expect(model.rows.map(\.entry.number) == [5]) + #expect(!model.isLoadingMore) + #expect(!model.hasMorePages) + } + + private func page( + environmentID: String, + numbers: [Int], + nextCursor: String? = nil + ) -> FeaturePullRequestEnvironmentList { + FeaturePullRequestEnvironmentList( + environmentID: environmentID, + environmentName: environmentID.capitalized, + result: PullRequestListResult( + viewers: [:], + providers: [], + entries: numbers.map(entry(number:)), + errors: [], + truncated: nextCursor != nil, + nextCursors: nextCursor.map { + ["github.com pingdotgg/t3code": $0] + } ?? [:] + ), + errorMessage: nil + ) + } + + private func entry(number: Int) -> PullRequestListEntry { + PullRequestListEntry( + provider: .github, + host: "github.com", + projectId: "project", + projectTitle: "T3 Code", + repository: "pingdotgg/t3code", + number: number, + title: "Fix issue \(number)", + url: "https://github.com/pingdotgg/t3code/pull/\(number)", + author: nil, + headBranch: "fix-\(number)", + baseBranch: "main", + state: .open, + isDraft: false, + mergeability: .mergeable, + additions: 1, + deletions: 0, + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-2\(number)T00:00:00Z", + viewerReviewRequested: false, + labels: [], + reviewDecision: nil, + checksState: nil + ) + } +} + +@MainActor +private final class PullRequestPaginationClientStub: FeatureClient { + struct TargetedRequest { + let environmentID: String + let input: PullRequestListInput + } + + enum Failure: LocalizedError { + case offline + + var errorDescription: String? { "This computer is offline." } + } + + var firstPages: [FeaturePullRequestEnvironmentList] = [] + var targetedPages: [String: FeaturePullRequestEnvironmentList] = [:] + var failedEnvironmentIDs: Set = [] + var initialRequests: [PullRequestListInput] = [] + var targetedRequests: [TargetedRequest] = [] + var beforeTargetedResponse: + ((String, PullRequestListInput) async throws -> [FeaturePullRequestEnvironmentList])? + + func pullRequestLists(_ input: PullRequestListInput) async throws + -> [FeaturePullRequestEnvironmentList] + { + initialRequests.append(input) + return firstPages + } + + func pullRequestLists( + _ input: PullRequestListInput, + environmentID: String + ) async throws -> [FeaturePullRequestEnvironmentList] { + targetedRequests.append(TargetedRequest(environmentID: environmentID, input: input)) + if let beforeTargetedResponse { + return try await beforeTargetedResponse(environmentID, input) + } + if failedEnvironmentIDs.contains(environmentID) { + throw Failure.offline + } + return targetedPages[environmentID].map { [$0] } ?? [] + } + + func initialSnapshot() async throws -> FeatureSnapshot { FeatureSnapshot() } + func pair(endpoint: String, token: String?) async throws {} + + func createThread( + projectID: String, + title: String?, + selection: FeatureSelection? + ) async throws -> FeatureThread { + FeatureThread(id: "created", projectID: projectID, title: title ?? "Created") + } + + func renameThread(id: String, title: String) async throws {} + func setThreadArchived(id: String, archived: Bool) async throws {} + func deleteThread(id: String) async throws {} + + func loadThread(id: String) async throws -> FeatureThreadDetail { + FeatureThreadDetail(thread: FeatureThread(id: id, projectID: "project", title: "Task")) + } + + func sendMessage(threadID: String, text: String, selection: FeatureSelection?) async throws {} + func cancelTurn(threadID: String) async throws {} + func resolveApproval(id: String, decision: FeatureApprovalDecision) async throws {} + func saveSettings(_ settings: FeatureSettings) async throws {} +} diff --git a/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift b/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift new file mode 100644 index 000000000000..12651552cfab --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/SubagentStatusTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Subagent status") +struct SubagentStatusTests { + @Test + func countsOnlyExplicitLiveSubagents() { + var tracker = FeatureActiveSubagentTracker() + + tracker.apply(activity( + id: "agent-start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "background-start", + kind: "task.started", + payload: ["taskId": .string("monitor-1"), "agentKind": .string("background")] + )) + tracker.apply(activity( + id: "legacy-start", + kind: "task.started", + payload: ["taskId": .string("legacy-1")] + )) + + #expect(tracker.activeCount == 1) + } + + @Test + func terminalRowsInheritKnownAgentMembership() { + var tracker = FeatureActiveSubagentTracker() + tracker.apply(activity( + id: "start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "complete", + kind: "task.completed", + payload: ["taskId": .string("agent-1"), "status": .string("completed")] + )) + + #expect(tracker.activeCount == 0) + } + + @Test + func idleAgentsCanStartAgainButTerminalAgentsDoNotReopenFromLateStarts() { + var tracker = FeatureActiveSubagentTracker() + tracker.apply(activity( + id: "start", + kind: "task.started", + payload: ["taskId": .string("agent-1"), "agentKind": .string("agent")] + )) + tracker.apply(activity( + id: "idle", + kind: "task.updated", + payload: ["taskId": .string("agent-1"), "status": .string("idle")] + )) + #expect(tracker.activeCount == 0) + + tracker.apply(activity( + id: "restart", + kind: "task.started", + payload: ["taskId": .string("agent-1")] + )) + #expect(tracker.activeCount == 1) + + tracker.apply(activity( + id: "failed", + kind: "task.updated", + payload: ["taskId": .string("agent-1"), "status": .string("failed")] + )) + tracker.apply(activity( + id: "late-start", + kind: "task.started", + payload: ["taskId": .string("agent-1")] + )) + #expect(tracker.activeCount == 0) + } + + @Test + func monitoringRemainsDistinctFromActiveAgentWork() { + let working = NativeFeatureClient.resolveThreadState( + latestTurn: nil, + session: nil, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: .working + ) + let monitoring = NativeFeatureClient.resolveThreadState( + latestTurn: nil, + session: nil, + hasApprovals: false, + hasUserInput: false, + backgroundLiveness: .monitoring + ) + + #expect(working == .working) + #expect(monitoring == .monitoring) + } + + @Test(arguments: [ + OrchestrationBackgroundLiveness.working, + OrchestrationBackgroundLiveness.monitoring, + ]) + func threadShellDecodesBackgroundLiveness( + _ backgroundLiveness: OrchestrationBackgroundLiveness + ) throws { + let shell = OrchestrationThreadShell( + id: "thread-1", + projectId: "project-1", + title: "Subagents", + modelSelection: ModelSelection(instanceId: "codex", model: "gpt-5.6-sol"), + runtimeMode: .fullAccess, + interactionMode: .default, + branch: nil, + worktreePath: nil, + latestTurn: nil, + createdAt: "2026-08-08T00:00:00Z", + updatedAt: "2026-08-08T00:00:00Z", + archivedAt: nil, + settledOverride: nil, + settledAt: nil, + snoozedUntil: nil, + snoozedAt: nil, + pinnedAt: nil, + session: nil, + latestUserMessageAt: nil, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: backgroundLiveness + ) + + let decoded = try JSONDecoder.t3.decode( + OrchestrationThreadShell.self, + from: JSONEncoder.t3.encode(shell) + ) + + #expect(decoded.backgroundLiveness == backgroundLiveness) + } + + private func activity( + id: String, + kind: String, + payload: [String: JSONValue] + ) -> OrchestrationActivity { + OrchestrationActivity( + id: id, + tone: "info", + kind: kind, + summary: kind, + payload: .object(payload), + turnId: "turn-1", + sequence: nil, + createdAt: "2026-08-08T00:00:00Z" + ) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift b/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift new file mode 100644 index 000000000000..259fdca781fa --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/T3ConnectNativeCapabilityTests.swift @@ -0,0 +1,513 @@ +import CryptoKit +import XCTest +@testable import T3Code + +@MainActor +final class T3ConnectNativeCapabilityTests: XCTestCase { + func testNativeConnectValidatesPersistsActivatesAndPublishesImmediately() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-native-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let store = EnvironmentStore(fileURL: catalogURL) + let credentials = InMemoryCredentialStore() + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "managed-1") + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient( + runtime: runtime, + t3ConnectController: controller, + fallbackPollingInitialDelay: .seconds(30) + ) + var events = client.events().makeAsyncIterator() + let managed = try await bootstrapCredential(signer: signer) + + try await client.connectT3Environment(managed) + + let event = await events.next() + guard case let .snapshot(snapshot)? = event else { + return XCTFail("Managed connect did not publish its initial Home snapshot") + } + XCTAssertEqual(snapshot.connection.state, .connected) + XCTAssertEqual(snapshot.connection.environmentName, "Managed Studio") + + let saved = try await store.load() + XCTAssertEqual(saved.count, 1) + XCTAssertEqual(saved[0].id, "managed-1") + XCTAssertEqual(saved[0].kind, .managedDPoP) + let activeEnvironmentID = try await store.activeEnvironmentID() + XCTAssertEqual(activeEnvironmentID, "managed-1") + let credential = await credentials.credential(for: "managed-1") + XCTAssertEqual(credential?.authorizationMethod, .dpop) + XCTAssertEqual(credential?.accessToken, "native-access-token") + XCTAssertNotEqual(credential?.accessToken, managed.bootstrapCredential) + + let catalog = try String(contentsOf: catalogURL, encoding: .utf8) + XCTAssertFalse(catalog.contains("one-use-bootstrap")) + XCTAssertFalse(catalog.contains("native-access-token")) + XCTAssertFalse(catalog.contains("ws-ticket")) + + let requests = await transport.requests + XCTAssertEqual( + Array(requests.prefix(3).map(\.url?.path)), + ["/.well-known/t3/environment", "/oauth/token", "/api/orchestration/shell"] + ) + let shellRequest = try XCTUnwrap( + requests.first(where: { $0.url?.path == "/api/orchestration/shell" }) + ) + XCTAssertEqual( + shellRequest.value(forHTTPHeaderField: "Authorization"), + "DPoP native-access-token" + ) + XCTAssertNotNil(shellRequest.value(forHTTPHeaderField: "DPoP")) + await client.disconnect() + } + + func testDescriptorMismatchLeavesManualEnvironmentAndCredentialsUntouched() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-mismatch-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let manual = Environment( + id: "manual-1", + label: "Big O", + httpBaseURL: URL(string: "https://big-o.example")!, + webSocketBaseURL: URL(string: "wss://big-o.example")! + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([manual]) + try await store.setActiveEnvironment(id: manual.id) + let manualCredential = EnvironmentCredential(accessToken: "manual-secret") + let credentials = InMemoryCredentialStore(credentials: [manual.id: manualCredential]) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "wrong-server") + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient(runtime: runtime, t3ConnectController: controller) + + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("A descriptor for another environment was accepted") + } catch T3ConnectRelayError.environmentMismatch { + // Expected identity rejection. + } + + let savedEnvironments = try await store.load() + let activeEnvironmentID = try await store.activeEnvironmentID() + let savedManualCredential = await credentials.credential(for: manual.id) + let savedManagedCredential = await credentials.credential(for: "managed-1") + XCTAssertEqual(savedEnvironments, [manual]) + XCTAssertEqual(activeEnvironmentID, manual.id) + XCTAssertEqual(savedManualCredential, manualCredential) + XCTAssertNil(savedManagedCredential) + let requests = await transport.requests + XCTAssertEqual(requests.map(\.url?.path), ["/.well-known/t3/environment"]) + } + + func testInjectedRuntimeWithoutManagedAuthorizationReportsUnavailable() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-unavailable-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: InMemoryCredentialStore() + ) + let client = NativeFeatureClient(runtime: runtime) + XCTAssertNotNil(client.t3ConnectController.unavailableReason) + + let signer = try testSigner() + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("An unconfigured runtime presented a working managed connection") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + } + + func testSignOutRemovesManagedStateWhenClerkFailsAndPreservesManualPairing() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-sign-out-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let manual = Environment( + id: "manual-1", + label: "Big O", + httpBaseURL: URL(string: "http://100.64.0.1:3773")!, + webSocketBaseURL: URL(string: "ws://100.64.0.1:3773/ws")! + ) + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ) + try await store.save([managed, manual]) + try await store.setActiveEnvironment(id: managed.id) + let manualCredential = EnvironmentCredential(accessToken: "manual-secret") + let managedCredential = EnvironmentCredential.managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ) + let credentials = InMemoryCredentialStore( + credentials: [ + manual.id: manualCredential, + managed.id: managedCredential, + ] + ) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: managed.id) + let controller = T3ConnectController( + resolution: .available( + T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ) + ), + transport: transport, + signer: signer, + signOutOperation: { throw T3ConnectNativeTestError.clerkSignOutFailed } + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient( + runtime: runtime, + t3ConnectController: controller + ) + + await client.signOutT3Connect() + + let remainingEnvironments = try await runtime.environments() + let activeEnvironmentID = try await store.activeEnvironmentID() + let remainingManualCredential = await credentials.credential(for: manual.id) + let remainingManagedCredential = await credentials.credential(for: managed.id) + XCTAssertEqual(remainingEnvironments, [manual]) + XCTAssertEqual(activeEnvironmentID, manual.id) + XCTAssertEqual(remainingManualCredential, manualCredential) + XCTAssertNil(remainingManagedCredential) + XCTAssertEqual( + controller.errorMessage, + T3ConnectNativeTestError.clerkSignOutFailed.localizedDescription + ) + } + + func testSignOutRevokesManagedCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-revoke-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([managed]) + let credentials = InMemoryCredentialStore(credentials: [ + managed.id: .managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: managed.id) + let controller = T3ConnectController( + resolution: .available( + T3ConnectConfiguration( + clerkPublishableKey: "pk_test", + relayHTTPURL: URL(string: "https://relay.example")! + ) + ), + transport: transport, + signer: signer, + signOutOperation: {} + ) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials, + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: controller) + ) + let client = NativeFeatureClient(runtime: runtime, t3ConnectController: controller) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + await client.signOutT3Connect() + + let remainingCredential = await credentials.credential(for: managed.id) + XCTAssertNil(remainingCredential) + XCTAssertNotNil(controller.errorMessage) + } + + func testManagedEnvironmentRemovalRevokesCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-account-change-revoke-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let managed = Environment( + id: "managed-1", + label: "Managed Studio", + httpBaseURL: URL(string: "https://managed.example")!, + webSocketBaseURL: URL(string: "wss://managed.example")!, + kind: .managedDPoP + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([managed]) + let credentials = InMemoryCredentialStore(credentials: [ + managed.id: .managedDPoP( + accessToken: "managed-secret", + expiresAt: Date().addingTimeInterval(300), + scopes: T3ConnectManagedEnvironmentAuthorizer.standardScopes, + environmentID: managed.id, + proofKeyThumbprint: "proof-key" + ), + ]) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + let client = NativeFeatureClient(runtime: runtime) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + + do { + try await client.removeEnvironment(id: managed.id) + XCTFail("Managed environment removal succeeded with an unwritable catalog") + } catch { + let remainingCredential = await credentials.credential(for: managed.id) + XCTAssertNil(remainingCredential) + } + } + + func testManualEnvironmentRemovalKeepsCredentialWhenCatalogRemovalFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-manual-removal-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let catalogURL = directory.appendingPathComponent("environments.json") + let manual = Environment( + id: "manual-1", + label: "Manual Studio", + httpBaseURL: URL(string: "https://manual.example")!, + webSocketBaseURL: URL(string: "wss://manual.example")! + ) + let store = EnvironmentStore(fileURL: catalogURL) + try await store.save([manual]) + let credential = EnvironmentCredential(accessToken: "manual-secret") + let credentials = InMemoryCredentialStore(credentials: [manual.id: credential]) + let runtime = EnvironmentRuntime( + environmentStore: store, + credentialStore: credentials + ) + let client = NativeFeatureClient(runtime: runtime) + + try FileManager.default.removeItem(at: catalogURL) + try FileManager.default.createDirectory(at: catalogURL, withIntermediateDirectories: true) + + do { + try await client.removeEnvironment(id: manual.id) + XCTFail("Manual environment removal succeeded with an unwritable catalog") + } catch { + let remainingCredential = await credentials.credential(for: manual.id) + XCTAssertEqual(remainingCredential, credential) + } + } + + func testInjectedManagedRuntimeRequiresItsMatchingController() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-connect-controller-mismatch-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + let signer = try testSigner() + let transport = T3ConnectNativeHTTPTransport(descriptorEnvironmentID: "managed-1") + let runtimeController = T3ConnectController( + resolution: .unavailable(reason: "Authentication is injected by this test."), + transport: transport, + signer: signer + ) + let runtime = EnvironmentRuntime( + environmentStore: EnvironmentStore( + fileURL: directory.appendingPathComponent("environments.json") + ), + credentialStore: InMemoryCredentialStore(), + httpTransport: transport, + webSocketConnector: T3ConnectBlockingConnector(), + managedAuthorization: T3ConnectRuntimeAuthorization(controller: runtimeController) + ) + let client = NativeFeatureClient(runtime: runtime) + + XCTAssertEqual( + client.t3ConnectController.unavailableReason, + "This client runtime requires its matching T3 Connect controller." + ) + do { + try await client.connectT3Environment( + try await bootstrapCredential(signer: signer) + ) + XCTFail("A managed runtime accepted an unrelated controller") + } catch let error as T3ConnectRelayError { + guard case .invalidConfiguration = error else { + return XCTFail("Unexpected T3 Connect error: \(error)") + } + } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + private func testSigner() throws -> T3ConnectDPoPSigner { + var scalar = Data(repeating: 0, count: 32) + scalar[31] = 11 + return try T3ConnectDPoPSigner(privateKeyRawRepresentation: scalar) + } + + private func bootstrapCredential( + signer: T3ConnectDPoPSigner + ) async throws -> T3ConnectManagedEnvironmentCredential { + T3ConnectManagedEnvironmentCredential( + environmentID: "managed-1", + label: "Managed Studio", + endpoint: T3ConnectManagedEndpoint( + httpBaseUrl: "https://managed.example", + wsBaseUrl: "wss://managed.example", + providerKind: .t3Relay + ), + bootstrapCredential: "one-use-bootstrap", + bootstrapExpiresAt: "2030-08-01T12:00:00.000Z", + proofKeyThumbprint: try await signer.thumbprint() + ) + } +} + +private actor T3ConnectNativeHTTPTransport: HTTPTransport { + private let descriptorEnvironmentID: String + private(set) var requests: [URLRequest] = [] + + init(descriptorEnvironmentID: String) { + self.descriptorEnvironmentID = descriptorEnvironmentID + } + + func data(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: Data + switch request.url?.path { + case "/.well-known/t3/environment": + body = Data( + """ + { + "environmentId": "\(descriptorEnvironmentID)", + "label": "Managed Studio", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.0.0", + "capabilities": {"repositoryIdentity": true} + } + """.utf8 + ) + case "/oauth/token": + body = Data( + """ + { + "access_token": "native-access-token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "DPoP", + "expires_in": 300, + "scope": "orchestration:read orchestration:operate terminal:operate review:write relay:read" + } + """.utf8 + ) + case "/api/orchestration/shell": + body = Data( + #"{"snapshotSequence":1,"projects":[],"threads":[],"updatedAt":"2026-08-01T12:00:00.000Z"}"#.utf8 + ) + case "/api/auth/websocket-ticket": + body = Data( + #"{"ticket":"ws-ticket","expiresAt":"2026-08-01T12:05:00.000Z"}"#.utf8 + ) + default: + throw T3ConnectNativeTestError.unexpectedPath(request.url?.path) + } + return ( + body, + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + ) + } +} + +private enum T3ConnectNativeTestError: Error { + case unexpectedPath(String?) + case clerkSignOutFailed +} + +private actor T3ConnectBlockingConnector: WebSocketConnecting { + private let connection = T3ConnectBlockingConnection() + + func connect(to _: URL) -> any WebSocketConnection { + connection + } +} + +private actor T3ConnectBlockingConnection: WebSocketConnection { + private var receiveContinuation: CheckedContinuation? + + func send(_: Data) {} + + func receive() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + func close() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift new file mode 100644 index 000000000000..f34e528286f6 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/TranscriptViewportGeometryTests.swift @@ -0,0 +1,227 @@ +import CoreGraphics +import Testing +import UIKit +@testable import T3Code + +@Suite("Transcript viewport anchoring") +struct TranscriptViewportGeometryTests { + @Test + func firstLoadedTranscriptAnchorsToLatestMessage() { + let empty = TranscriptViewportGeometry( + contentHeight: 0, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let loaded = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + + #expect( + loaded.restoredBottomOffset( + after: empty, + maintainsBottomAnchor: true, + isInteracting: false + ) == 500 + ) + } + + @Test + func keyboardViewportChangeKeepsLatestMessageVisible() { + let beforeKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let afterKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 400, + topInset: 0, + bottomInset: 0 + ) + + #expect( + afterKeyboard.restoredBottomOffset( + after: beforeKeyboard, + maintainsBottomAnchor: true, + isInteracting: false + ) == 800 + ) + } + + @Test + func readerPositionIsUntouchedAwayFromLatestMessage() { + let beforeKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let afterKeyboard = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 400, + topInset: 0, + bottomInset: 0 + ) + + #expect( + afterKeyboard.restoredBottomOffset( + after: beforeKeyboard, + maintainsBottomAnchor: false, + isInteracting: false + ) == nil + ) + } + + @Test + func activeTranscriptGestureOwnsItsScrollPosition() { + let before = TranscriptViewportGeometry( + contentHeight: 1_200, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + let after = TranscriptViewportGeometry( + contentHeight: 1_260, + viewportHeight: 700, + topInset: 0, + bottomInset: 0 + ) + + #expect( + after.restoredBottomOffset( + after: before, + maintainsBottomAnchor: true, + isInteracting: true + ) == nil + ) + } + + @Test + func verticalPanFailsBeforeItCanCompeteWithTranscriptScrolling() { + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 40, y: 120))) + #expect(!ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: -120, y: 0))) + } + + @Test + func horizontalPanCanLeaveTheThreadFromAnywhereOnTheSurface() { + #expect(ThreadBackSwipeGesture.shouldBegin(with: CGPoint(x: 120, y: 20))) + #expect( + ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 16) + ) + ) + } + + @Test + func slowHorizontalPanUsesTranslationWhenVelocityIsUnavailable() { + #expect( + ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 16, y: 2) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldBegin( + with: .zero, + translation: CGPoint(x: 4, y: 16) + ) + ) + } + + @Test + func shortOrDiagonalPanDoesNotLeaveTheThread() { + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 71, y: 0) + ) + ) + #expect( + !ThreadBackSwipeGesture.shouldNavigateBack( + with: CGPoint(x: 96, y: 80) + ) + ) + } + + @Test + @MainActor + func horizontalScrollContentSharesOnlyAtItsLeadingEdge() { + let transcript = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + transcript.contentSize = CGSize(width: 120, height: 480) + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: transcript)) + + let codeBlock = UIScrollView(frame: CGRect(x: 0, y: 0, width: 120, height: 120)) + codeBlock.contentSize = CGSize(width: 480, height: 120) + codeBlock.alwaysBounceVertical = true + #expect(ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldAllowSimultaneousRecognition(with: codeBlock)) + } + + @Test + @MainActor + func horizontalScrollAncestorsCanReceiveBackPanAtLeadingEdge() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let codeBlock = UIScrollView(frame: host.bounds) + codeBlock.contentSize = CGSize(width: 480, height: 240) + let label = UILabel(frame: .zero) + codeBlock.addSubview(label) + host.addSubview(codeBlock) + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + codeBlock.contentOffset = CGPoint(x: 100, y: 0) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: label, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + + let detachedHost = UIView(frame: host.bounds) + let detachedCodeBlock = UIScrollView(frame: detachedHost.bounds) + detachedCodeBlock.contentSize = CGSize(width: 480, height: 240) + let detachedLabel = UILabel(frame: .zero) + detachedCodeBlock.addSubview(detachedLabel) + detachedHost.addSubview(detachedCodeBlock) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: detachedLabel, host: host)) + } + + @Test + @MainActor + func activeTextInteractionsKeepHorizontalDrags() { + let host = UIView(frame: CGRect(x: 0, y: 0, width: 240, height: 240)) + let textField = UITextField(frame: .zero) + let textView = UITextView(frame: .zero) + textView.text = "Selectable transcript text" + let textViewContent = UIView(frame: .zero) + textView.addSubview(textViewContent) + host.addSubview(textField) + host.addSubview(textView) + + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textField, host: host)) + textView.isEditable = false + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 1) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + + textView.selectedRange = NSRange(location: 0, length: 0) + textView.isEditable = true + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textView, host: host)) + + let window = UIWindow(frame: host.bounds) + let rootViewController = UIViewController() + window.rootViewController = rootViewController + rootViewController.view.addSubview(host) + window.makeKeyAndVisible() + textView.isEditable = false + #expect(textView.becomeFirstResponder()) + #expect(!ThreadBackSwipeGesture.shouldReceiveTouch(in: textViewContent, host: host)) + textView.resignFirstResponder() + window.isHidden = true + + #expect(ThreadBackSwipeGesture.shouldReceiveTouch(in: host, host: host)) + } +} diff --git a/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift b/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift new file mode 100644 index 000000000000..427a6f8f0360 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/UsageModelsTests.swift @@ -0,0 +1,509 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Usage reporting") +struct UsageModelsTests { + @Test + func pastDayRequestsTwentyFourMinuteAlignedHourlyBuckets() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T12:34:56Z") + ) + + let input = UsageWindow.make(days: 1, now: now, timeZone: timeZone) + let since = try #require(input.sinceTime) + let until = try #require(input.untilTime) + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let sinceDate = try #require(parser.date(from: since)) + let untilDate = try #require(parser.date(from: until)) + + #expect(input.resolution == .hour) + #expect(untilDate.timeIntervalSince(sinceDate) == 24 * 60 * 60) + #expect(Calendar.current.component(.second, from: untilDate) == 0) + #expect(UsageWindow.hours(in: input).count == 24) + } + + @Test + func hourlyBucketsMergeAcrossEnvironmentsAndProviders() { + let hour = "2026-08-18T12:00:00.000Z" + let first = FeatureEnvironmentUsage( + environmentID: "a", + label: "First", + summary: summary(provider: .codex, costUsd: 2, hourStart: hour), + errorMessage: nil + ) + let second = FeatureEnvironmentUsage( + environmentID: "b", + label: "Second", + summary: summary(provider: .claude, costUsd: 3, hourStart: hour), + errorMessage: nil + ) + + let merged = UsageMerger.merge([first, second]) + + #expect(merged.hourly.count == 1) + #expect(merged.hourly[0].hourStart == hour) + #expect(merged.hourly[0].costUsd == 5) + #expect(merged.hourly[0].byProvider[.codex]?.costUsd == 2) + #expect(merged.hourly[0].byProvider[.claude]?.costUsd == 3) + } + + @Test + func mergeDoesNotCountReasoningTokensTwice() { + let report = FeatureEnvironmentUsage( + environmentID: "environment-a", + label: "Studio", + summary: summary( + provider: .codex, + costUsd: 12, + uncachedInput: 100, + cachedInput: 200, + cacheCreation: 30, + output: 40, + reasoning: 10 + ), + errorMessage: nil + ) + + let merged = UsageMerger.merge([report]) + + #expect(merged.totalTokens == 370) + #expect(merged.reasoningTokens == 10) + #expect(merged.providers.first?.totalTokens == 370) + #expect(merged.sessions == 1) + } + + @Test + func duplicateTranscriptSourcesAreCountedOnce() { + let first = FeatureEnvironmentUsage( + environmentID: "a", + label: "First", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let duplicate = FeatureEnvironmentUsage( + environmentID: "b", + label: "Second", + summary: summary(provider: .codex, costUsd: 50), + errorMessage: nil + ) + + let merged = UsageMerger.merge([duplicate, first]) + + #expect(merged.costUsd == 10) + #expect(merged.contributingEnvironments == ["a"]) + #expect(merged.duplicateSources == ["Second: /Users/theo/.codex"]) + } + + @Test + func healthySourceOwnsFingerprintInsteadOfEarlierFailedSource() { + let failed = FeatureEnvironmentUsage( + environmentID: "a", + label: "Failed", + summary: summary(provider: .codex, costUsd: 50, sourceStatus: .failed), + errorMessage: nil + ) + let healthy = FeatureEnvironmentUsage( + environmentID: "b", + label: "Healthy", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + + let merged = UsageMerger.merge([failed, healthy]) + + #expect(merged.costUsd == 10) + #expect(merged.contributingEnvironments == ["b"]) + #expect(merged.duplicateSources == ["Failed: /Users/theo/.codex"]) + } + + @Test + func staleContractsDoNotChangeTotals() { + let current = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let stale = FeatureEnvironmentUsage( + environmentID: "stale", + label: "Stale", + summary: summary(contractVersion: 2, provider: .claude, costUsd: 25), + errorMessage: nil + ) + + let merged = UsageMerger.merge([current, stale]) + + #expect(merged.costUsd == 10) + #expect(merged.staleEnvironments == ["stale"]) + } + + @Test + func calendarWindowStaysInclusiveAcrossDaylightSavingTime() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2024-03-10T19:00:00Z") + ) + + let window = UsageWindow.make(days: 7, now: now, timeZone: timeZone) + + #expect(window.sinceDay == "2024-03-04") + #expect(window.untilDay == "2024-03-10") + #expect(UsageWindow.days(in: window).count == 7) + } + + @Test + func refreshRecomputesTheSelectedWindowAfterMidnight() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let beforeMidnight = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T13:59:00Z") + ) + let afterMidnight = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T14:01:00Z") + ) + var state = UsageLoadState(days: 30, now: beforeMidnight, timeZone: timeZone) + let initial = state.begin(days: 30, now: beforeMidnight, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let receivedInitial = state.receive([previous], for: initial) + #expect(receivedInitial) + + let refresh = state.begin(days: 30, now: afterMidnight, timeZone: timeZone) + + #expect(refresh.input.untilDay == "2026-08-11") + #expect(refresh.input.sinceDay == "2026-07-13") + #expect(state.windowInput.untilDay == "2026-08-10") + #expect(state.merged.costUsd == 10) + + let current = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 11), + errorMessage: nil + ) + let receivedRefresh = state.receive([current], for: refresh) + #expect(receivedRefresh) + #expect(state.windowInput.untilDay == "2026-08-11") + #expect(state.merged.costUsd == 11) + } + + @Test(arguments: [7, 30, 90]) + func refreshAndRetryRecomputeEveryWindowLength(days: Int) throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let firstDay = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + let nextDay = try #require( + ISO8601DateFormatter().date(from: "2026-08-11T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: firstDay, timeZone: timeZone) + + let refresh = state.begin(days: days, now: firstDay, timeZone: timeZone) + #expect(UsageWindow.days(in: refresh.input).count == days) + let recordedRefreshFailure = state.fail(TestUsageError.unavailable, for: refresh) + #expect(recordedRefreshFailure) + + let retry = state.begin(days: days, now: nextDay, timeZone: timeZone) + #expect(UsageWindow.days(in: retry.input).count == days) + #expect(retry.input.untilDay == "2026-08-11") + #expect(state.errorMessage == nil) + } + + @Test + func failedRefreshKeepsTheLastTruthfulTotalsAndWindow() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let initial = state.begin(days: 30, now: now, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let receivedInitial = state.receive([previous], for: initial) + #expect(receivedInitial) + let truthfulWindow = state.windowInput + + let refresh = state.begin(days: 30, now: now, timeZone: timeZone) + let recordedRefreshFailure = state.fail(TestUsageError.unavailable, for: refresh) + #expect(recordedRefreshFailure) + + #expect(state.windowInput == truthfulWindow) + #expect(state.environments == [previous]) + #expect(state.merged.costUsd == 10) + #expect(state.errorMessage != nil) + } + + @Test + func staleOrCancelledLoadCannotOverwriteTheNewestLoad() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let stale = state.begin(days: 30, now: now, timeZone: timeZone) + let previous = FeatureEnvironmentUsage( + environmentID: "previous", + label: "Previous", + summary: summary(provider: .codex, costUsd: 30), + errorMessage: nil + ) + let receivedPrevious = state.receive([previous], for: stale) + #expect(receivedPrevious) + state.selectWindow(days: 7, now: now, timeZone: timeZone) + #expect(state.environments.isEmpty) + #expect(state.merged == MergedUsage()) + #expect(state.errorMessage == nil) + #expect(state.isLoading) + #expect(UsageWindow.days(in: state.windowInput).count == 7) + let staleResult = FeatureEnvironmentUsage( + environmentID: "stale", + label: "Stale", + summary: summary(provider: .codex, costUsd: 99), + errorMessage: nil + ) + let currentResult = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 7), + errorMessage: nil + ) + + let receivedStale = state.receive([staleResult], for: stale) + #expect(!receivedStale) + let recordedStaleFailure = state.fail(TestUsageError.unavailable, for: stale) + #expect(!recordedStaleFailure) + #expect(state.errorMessage == nil) + state.finish(stale) + #expect(state.isLoading) + + let current = state.begin(days: 7, now: now, timeZone: timeZone) + let receivedCurrent = state.receive([currentResult], for: current) + #expect(receivedCurrent) + state.finish(current) + + #expect(!state.isLoading) + #expect(state.environments == [currentResult]) + #expect(state.merged.costUsd == 7) + #expect(UsageWindow.days(in: state.windowInput).count == 7) + } + + @Test + func sameWindowOverlapOnlyLetsTheNewestLoadCommitOrFinish() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let superseded = state.begin(days: 30, now: now, timeZone: timeZone) + let current = state.begin(days: 30, now: now, timeZone: timeZone) + let result = FeatureEnvironmentUsage( + environmentID: "current", + label: "Current", + summary: summary(provider: .codex, costUsd: 30), + errorMessage: nil + ) + + let receivedSuperseded = state.receive([result], for: superseded) + #expect(!receivedSuperseded) + let recordedSupersededFailure = state.fail( + TestUsageError.unavailable, + for: superseded + ) + #expect(!recordedSupersededFailure) + state.finish(superseded) + #expect(state.isLoading) + + let receivedCurrent = state.receive([result], for: current) + #expect(receivedCurrent) + state.finish(current) + #expect(!state.isLoading) + #expect(state.environments == [result]) + } + + @Test + func partialEnvironmentFailureRemainsVisibleBesideTruthfulTotals() throws { + let timeZone = try #require(TimeZone(identifier: "Australia/Sydney")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-10T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let request = state.begin(days: 30, now: now, timeZone: timeZone) + let available = FeatureEnvironmentUsage( + environmentID: "available", + label: "Available", + summary: summary(provider: .codex, costUsd: 10), + errorMessage: nil + ) + let unavailable = FeatureEnvironmentUsage( + environmentID: "unavailable", + label: "Unavailable", + summary: nil, + errorMessage: "This environment could not report usage." + ) + + let receivedPartial = state.receive([available, unavailable], for: request) + #expect(receivedPartial) + + #expect(state.merged.costUsd == 10) + #expect(state.environments.filter { $0.errorMessage != nil } == [unavailable]) + #expect(state.errorMessage == nil) + } + + @Test + func rollingServerVersionsLoadWhenAnotherEnvironmentIsOffline() throws { + let timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require( + ISO8601DateFormatter().date(from: "2026-08-18T12:00:00Z") + ) + var state = UsageLoadState(days: 30, now: now, timeZone: timeZone) + let request = state.begin(days: 30, now: now, timeZone: timeZone) + let currentServer = FeatureEnvironmentUsage( + environmentID: "current-server", + label: "Current server", + summary: summary( + contractVersion: usageContractVersion, + provider: .codex, + costUsd: 10 + ), + errorMessage: nil + ) + let previousServer = FeatureEnvironmentUsage( + environmentID: "previous-server", + label: "Previous server", + summary: summary( + contractVersion: minimumCompatibleUsageContractVersion, + provider: .claude, + costUsd: 20 + ), + errorMessage: nil + ) + let offlineServer = FeatureEnvironmentUsage( + environmentID: "offline-server", + label: "Offline server", + summary: nil, + errorMessage: "This environment could not report usage." + ) + + let received = state.receive( + [currentServer, previousServer, offlineServer], + for: request + ) + #expect(received) + + #expect(state.merged.costUsd == 30) + #expect( + state.merged.contributingEnvironments == ["current-server", "previous-server"] + ) + #expect(state.merged.staleEnvironments.isEmpty) + #expect(state.environments.filter { $0.errorMessage != nil } == [offlineServer]) + } + + @Test + func grokUsageMergesWithOlderServersAndAppearsInCharts() { + let environments = [ + FeatureEnvironmentUsage( + environmentID: "new", label: "New", + summary: summary(contractVersion: 5, provider: .grok, costUsd: 15), + errorMessage: nil + ), + FeatureEnvironmentUsage( + environmentID: "older", label: "Older", + summary: summary(contractVersion: 4, provider: .codex, costUsd: 10), + errorMessage: nil + ), + FeatureEnvironmentUsage( + environmentID: "legacy", label: "Legacy", + summary: summary(contractVersion: 3, provider: .claude, costUsd: 5), + errorMessage: nil + ), + ] + let merged = UsageMerger.merge(environments) + #expect(merged.costUsd == 30) + #expect(Set(merged.providers.map(\.provider)) == [.grok, .codex, .claude]) + #expect(merged.daily.first?.byProvider[.grok]?.costUsd == 15) + #expect(merged.models.contains { $0.provider == .grok }) + #expect(merged.staleEnvironments.isEmpty) + #expect(!isCompatibleUsageContractVersion(2)) + #expect(!isCompatibleUsageContractVersion(6)) + } + + private func summary( + contractVersion: Int = usageContractVersion, + provider: UsageProviderKind, + costUsd: Double, + uncachedInput: Int = 100, + cachedInput: Int = 0, + cacheCreation: Int = 0, + output: Int = 20, + reasoning: Int = 0, + sourceStatus: UsageSourceStatus = .ok, + hourStart: String? = nil + ) -> UsageSummary { + let path = "/Users/theo/.\(provider.rawValue)" + return UsageSummary( + contractVersion: contractVersion, + readAt: "2026-08-09T12:00:00.000Z", + timeZone: "America/Los_Angeles", + sinceDay: "2026-08-03", + untilDay: "2026-08-09", + buckets: [ + UsageBucket( + day: "2026-08-09", + hourStart: hourStart, + provider: provider, + model: "\(provider.rawValue)-test-model", + totals: UsageTokenTotals( + uncachedInputTokens: uncachedInput, + cachedInputTokens: cachedInput, + cacheCreationTokens: cacheCreation, + outputTokens: output, + reasoningTokens: reasoning + ), + costUsd: costUsd, + cacheSavingsUsd: 0, + costSource: .modelPriced, + records: 1, + unpricedRecords: 0, + sessions: 1 + ), + ], + sources: [ + UsageSource( + fingerprint: UsageSourceFingerprint( + hostId: "host", + provider: provider, + resolvedHomePath: path, + volumeId: "1:2" + ), + status: sourceStatus, + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: nil + ), + ], + pricing: UsagePricing( + status: .fresh, + source: "LiteLLM", + fetchedAt: nil, + knownModels: 1 + ), + scanDurationMs: 1 + ) + } +} + +private enum TestUsageError: Error { + case unavailable +} diff --git a/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift b/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift new file mode 100644 index 000000000000..354e7a4dbfc3 --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/UserInputAnswerTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("User input answers") +struct UserInputAnswerTests { + @Test + func testCodableShapeMatchesProviderWireValues() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let textData = try encoder.encode(FeatureInputAnswer.text("Deploy")) + let selectionsData = try encoder.encode( + FeatureInputAnswer.selections(["Server", "Web"]) + ) + + #expect(try decoder.decode(JSONValue.self, from: textData) == .string("Deploy")) + #expect( + try decoder.decode(JSONValue.self, from: selectionsData) + == .array([.string("Server"), .string("Web")]) + ) + #expect(try decoder.decode(FeatureInputAnswer.self, from: textData) == .text("Deploy")) + #expect( + try decoder.decode(FeatureInputAnswer.self, from: selectionsData) + == .selections(["Server", "Web"]) + ) + } + + @Test + func testNativeJSONMappingPreservesStringAndArrayTypes() { + #expect(FeatureInputAnswer.text("Deploy").jsonValue == .string("Deploy")) + #expect( + FeatureInputAnswer.selections(["Server", "Web"]).jsonValue + == .array([.string("Server"), .string("Web")]) + ) + } + + @Test + func testMultiSelectTogglesWithoutFlatteningSelections() { + let first = FeatureInputAnswer.selections([]) + .togglingOption("Server", allowsMultiple: true) + let second = first.togglingOption("Web", allowsMultiple: true) + let deselected = second.togglingOption("Server", allowsMultiple: true) + + #expect(first == .selections(["Server"])) + #expect(second == .selections(["Server", "Web"])) + #expect(deselected == .selections(["Web"])) + #expect( + second.togglingOption("CLI", allowsMultiple: false) + == .text("CLI") + ) + } + + @Test + func testAnswersNormalizeBeforeSubmission() { + #expect(FeatureInputAnswer.text(" ship it ").normalized == .text("ship it")) + #expect( + FeatureInputAnswer.selections([" Server ", "", "Server", "Web"]).normalized + == .selections(["Server", "Web"]) + ) + #expect(FeatureInputAnswer.text(" ").normalized == nil) + #expect(FeatureInputAnswer.selections([]).normalized == nil) + } + + @Test + func testMultiSelectCustomTextStaysInTheSelectionArray() { + let question = FeatureInputQuestion( + id: "surfaces", + header: "Surfaces", + question: "Where should this ship?", + options: [ + .init(label: "Server", detail: "Backend"), + .init(label: "Web", detail: "Browser"), + ], + allowsMultiple: true + ) + let selected = FeatureInputAnswer.selections(["Server"]) + let withCustom = FeatureComposerCustomAnswer.replacingText( + in: selected, + with: "CLI", + for: question + ) + + #expect(withCustom == .selections(["Server", "CLI"])) + #expect(FeatureComposerCustomAnswer.text(in: withCustom, for: question) == "CLI") + #expect( + FeatureComposerCustomAnswer.replacingText( + in: withCustom, + with: "", + for: question + ) == .selections(["Server"]) + ) + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json new file mode 100644 index 000000000000..3ecef92a489c --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/shell-snapshot.json @@ -0,0 +1,63 @@ +{ + "snapshotSequence": 42, + "projects": [ + { + "id": "project-fixture", + "title": "Fixture project", + "workspaceRoot": "/workspace/fixture", + "repositoryIdentity": null, + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "scripts": [], + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "threads": [ + { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "titleRegeneration": null, + "session": null, + "latestUserMessageAt": "2026-08-07T12:00:00.000Z", + "hasPendingApprovals": false, + "hasPendingUserInput": false, + "hasActionableProposedPlan": false, + "backgroundLiveness": null, + "planProgress": null + } + ], + "updatedAt": "2026-08-07T12:00:00.000Z" +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json new file mode 100644 index 000000000000..84d07781ede6 --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/shell-stream-snapshot.json @@ -0,0 +1,66 @@ +{ + "kind": "snapshot", + "snapshot": { + "snapshotSequence": 42, + "projects": [ + { + "id": "project-fixture", + "title": "Fixture project", + "workspaceRoot": "/workspace/fixture", + "repositoryIdentity": null, + "defaultModelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "scripts": [], + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "threads": [ + { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "titleRegeneration": null, + "session": null, + "latestUserMessageAt": "2026-08-07T12:00:00.000Z", + "hasPendingApprovals": false, + "hasPendingUserInput": false, + "hasActionableProposedPlan": false, + "backgroundLiveness": null, + "planProgress": null + } + ], + "updatedAt": "2026-08-07T12:00:00.000Z" + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json new file mode 100644 index 000000000000..995dfbe2270f --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/thread-detail-snapshot.json @@ -0,0 +1,55 @@ +{ + "snapshotSequence": 42, + "thread": { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "titleRegeneration": null, + "deletedAt": null, + "messages": [ + { + "id": "message-fixture", + "role": "user", + "text": "Verify the native wire contract", + "attachments": [], + "turnId": "turn-fixture", + "streaming": false, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "proposedPlans": [], + "activities": [], + "checkpoints": [], + "session": null + }, + "page": { + "beforeCursor": "fixture-cursor", + "hasMore": true, + "snapshotSequence": 42, + "threadSequence": 40 + } +} diff --git a/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json b/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json new file mode 100644 index 000000000000..08254cfe38df --- /dev/null +++ b/apps/swift-ios/Tests/Fixtures/Wire/thread-stream-snapshot.json @@ -0,0 +1,58 @@ +{ + "kind": "snapshot", + "snapshot": { + "snapshotSequence": 42, + "thread": { + "id": "thread-fixture", + "projectId": "project-fixture", + "title": "Fixture thread", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.6-sol", + "options": [ + { + "id": "effort", + "value": "high" + } + ] + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": "main", + "worktreePath": null, + "latestTurn": null, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "snoozedUntil": null, + "snoozedAt": null, + "pinnedAt": null, + "titleRegeneration": null, + "deletedAt": null, + "messages": [ + { + "id": "message-fixture", + "role": "user", + "text": "Verify the native wire contract", + "attachments": [], + "turnId": "turn-fixture", + "streaming": false, + "createdAt": "2026-08-07T12:00:00.000Z", + "updatedAt": "2026-08-07T12:00:00.000Z" + } + ], + "proposedPlans": [], + "activities": [], + "checkpoints": [], + "session": null + }, + "page": { + "beforeCursor": "fixture-cursor", + "hasMore": true, + "snapshotSequence": 42, + "threadSequence": 40 + } + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift new file mode 100644 index 000000000000..a8f7659847da --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformAgentAwarenessTests.swift @@ -0,0 +1,326 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Agent awareness projection") +struct PlatformAgentAwarenessTests { + @Test + func settingsDefaultToSystemAppearanceAndRoundTripLightMode() throws { + #expect(FeatureSettings().appearance == .system) + + var settings = FeatureSettings() + settings.appearance = .light + let roundTrip = try JSONDecoder.t3.decode( + FeatureSettings.self, + from: JSONEncoder.t3.encode(settings) + ) + + #expect(roundTrip.appearance == .light) + } + + @Test + func legacySettingsEnableLiveActivitiesWithoutResettingOtherPreferences() throws { + let legacy = Data( + #"{"appearance":"system","hapticsEnabled":false,"notificationsEnabled":false,"autoSettleOnMerge":false,"autoSettleAfterDays":14}"#.utf8 + ) + let decoded = try JSONDecoder.t3.decode(FeatureSettings.self, from: legacy) + + #expect(decoded.appearance == .system) + #expect(!decoded.hapticsEnabled) + #expect(!decoded.notificationsEnabled) + #expect(decoded.liveActivitiesEnabled) + + var disabled = decoded + disabled.liveActivitiesEnabled = false + let encoded = try JSONEncoder.t3.encode(disabled) + let roundTrip = try JSONDecoder.t3.decode(FeatureSettings.self, from: encoded) + #expect(!roundTrip.liveActivitiesEnabled) + let encodedSettings = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + #expect(encodedSettings["autoSettleOnMerge"] == nil) + #expect(encodedSettings["autoSettleAfterDays"] == nil) + } + + @Test + func ranksAttentionThenFailuresThenWorkAndDropsOldTerminalRows() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let project = FeatureProject( + id: "project", + wireID: "project-wire", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let snapshot = FeatureSnapshot( + projects: [project], + threads: [ + Self.thread( + id: "working", + state: .working, + updatedAt: now.addingTimeInterval(-10) + ), + Self.thread( + id: "approval", + state: .waitingForApproval, + updatedAt: now.addingTimeInterval(-5) + ), + Self.thread( + id: "failure", + state: .failed, + updatedAt: now.addingTimeInterval(-20) + ), + Self.thread( + id: "old-complete", + state: .completed, + updatedAt: now.addingTimeInterval(-3_600) + ), + ], + providersByEnvironment: [ + "environment": [ + FeatureProvider( + id: "claude", + name: "Claude", + models: [FeatureModel(id: "claude-opus-5", name: "Opus 5")] + ), + ], + ] + ) + + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + + #expect(aggregate.activeCount == 2) + #expect(aggregate.subtitle == "1 task needs attention") + #expect(aggregate.activities.map(\.threadId) == ["approval", "failure", "working"]) + #expect(aggregate.activities.first?.modelTitle == "Opus 5") + #expect( + aggregate.activities.first?.nativeDeepLinkURL?.scheme + == PlatformRoute.nativeScheme + ) + } + + @Test + func widgetSnapshotUsesTheSameBoundedRowsAsTheLiveActivity() { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let threads = (0..<8).map { index in + Self.thread( + id: "thread-\(index)", + state: .working, + updatedAt: now.addingTimeInterval(TimeInterval(-index)) + ) + } + let snapshot = FeatureSnapshot(projects: [project], threads: threads) + + let aggregate = PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: now + ) + let widget = PlatformAgentAwarenessProjection.widgetSnapshot( + snapshot: snapshot, + now: now + ) + + #expect(aggregate.activities.count == PlatformAgentAwarenessProjection.maximumRows) + #expect(widget.tasks == aggregate.activities) + #expect(widget.updatedAt == aggregate.updatedAt) + } + + @Test + func terminalRowsExposeTheirExpiryAndDisappearAtTheBoundary() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let updatedAt = now.addingTimeInterval(-60) + let expiry = updatedAt.addingTimeInterval( + PlatformAgentAwarenessProjection.terminalVisibilityWindow + ) + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let snapshot = FeatureSnapshot( + projects: [project], + threads: [Self.thread(id: "done", state: .completed, updatedAt: updatedAt)] + ) + + #expect( + PlatformAgentAwarenessProjection.nextTerminalExpiry( + snapshot: snapshot, + now: now + ) == expiry + ) + #expect( + PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: expiry.addingTimeInterval(-0.001) + ).activities.count == 1 + ) + #expect( + PlatformAgentAwarenessProjection.aggregate( + snapshot: snapshot, + now: expiry + ).activities.isEmpty + ) + } + + @Test + @MainActor + func signOutEndsActivitiesWithoutRepublishingTheCachedProjection() async { + let recorder = PlatformAgentAwarenessOperationRecorder() + let coordinator = PlatformAgentAwarenessCoordinator( + updateLiveActivity: { _, _, _ in + recorder.recordUpdate() + }, + endLiveActivities: { + recorder.recordEnd() + } + ) + + coordinator.synchronize(snapshot: FeatureSnapshot(), liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(1) + + coordinator.resetAndResynchronizeLiveActivity() + await recorder.waitForEndCount(1) + await Task.yield() + + #expect(recorder.updateCount == 1) + #expect(recorder.endCount == 1) + } + + @Test + @MainActor + func latestSnapshotCancelsAnInFlightReversionToOlderState() async { + let recorder = GatedPlatformAgentAwarenessRecorder() + let coordinator = PlatformAgentAwarenessCoordinator( + updateLiveActivity: { _, _, _ in + await recorder.recordUpdate() + }, + endLiveActivities: {} + ) + let ready = FeatureSnapshot() + let project = FeatureProject( + id: "project", + environmentID: "environment", + name: "t3code", + path: "/repo" + ) + let working = FeatureSnapshot( + projects: [project], + threads: [Self.thread(id: "working", state: .working, updatedAt: .now)] + ) + + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(1) + await Task.yield() + + coordinator.synchronize(snapshot: working, liveActivitiesEnabled: true) + await recorder.waitForUpdateCount(2) + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + recorder.releaseSecondUpdate() + await Task.yield() + await Task.yield() + + coordinator.synchronize(snapshot: ready, liveActivitiesEnabled: true) + await Task.yield() + #expect(recorder.updateCount == 2) + } + + private static func thread( + id: String, + state: FeatureThreadState, + updatedAt: Date + ) -> FeatureThread { + FeatureThread( + id: id, + wireID: id, + projectID: "project", + environmentID: "environment", + title: "Task \(id)", + updatedAt: updatedAt, + state: state, + providerID: "claude", + modelID: "claude-opus-5" + ) + } +} + +@MainActor +private final class GatedPlatformAgentAwarenessRecorder { + private(set) var updateCount = 0 + private var secondUpdateContinuation: CheckedContinuation? + private var updateWaiters: [(Int, CheckedContinuation)] = [] + + func recordUpdate() async { + updateCount += 1 + let ready = updateWaiters.filter { updateCount >= $0.0 } + updateWaiters.removeAll { updateCount >= $0.0 } + ready.forEach { $0.1.resume() } + if updateCount == 2 { + await withCheckedContinuation { continuation in + secondUpdateContinuation = continuation + } + } + } + + func waitForUpdateCount(_ count: Int) async { + guard updateCount < count else { return } + await withCheckedContinuation { continuation in + updateWaiters.append((count, continuation)) + } + } + + func releaseSecondUpdate() { + secondUpdateContinuation?.resume() + secondUpdateContinuation = nil + } +} + +@MainActor +private final class PlatformAgentAwarenessOperationRecorder { + private(set) var updateCount = 0 + private(set) var endCount = 0 + private var updateWaiters: [(Int, CheckedContinuation)] = [] + private var endWaiters: [(Int, CheckedContinuation)] = [] + + func recordUpdate() { + updateCount += 1 + resumeReadyWaiters(&updateWaiters, count: updateCount) + } + + func recordEnd() { + endCount += 1 + resumeReadyWaiters(&endWaiters, count: endCount) + } + + func waitForUpdateCount(_ count: Int) async { + guard updateCount < count else { return } + await withCheckedContinuation { continuation in + updateWaiters.append((count, continuation)) + } + } + + func waitForEndCount(_ count: Int) async { + guard endCount < count else { return } + await withCheckedContinuation { continuation in + endWaiters.append((count, continuation)) + } + } + + private func resumeReadyWaiters( + _ waiters: inout [(Int, CheckedContinuation)], + count: Int + ) { + let ready = waiters.filter { count >= $0.0 } + waiters.removeAll { count >= $0.0 } + ready.forEach { $0.1.resume() } + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift new file mode 100644 index 000000000000..357a5904e9b3 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformBackgroundRefreshTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Background refresh") +struct PlatformBackgroundRefreshTests { + @Test + @MainActor + func usesThePermittedIdentifierAndAConservativeRetryWindow() { + #expect( + PlatformBackgroundRefreshCoordinator.identifier + == "\(Bundle.main.bundleIdentifier ?? "com.t3tools.t3code.swiftui").refresh" + ) + #expect(PlatformBackgroundRefreshPolicy.minimumDelay == 15 * 60) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift new file mode 100644 index 000000000000..86c74aa643a2 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformCloudDeliveryTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Cloud delivery registration") +struct PlatformCloudDeliveryTests { + @Test + func installationIdentityIsStableWithinAnInstall() throws { + let suiteName = "cloud-delivery-\(UUID())" + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + + let first = PlatformInstallationIdentity.value(defaults: suite) + let second = PlatformInstallationIdentity.value(defaults: suite) + + #expect(!first.isEmpty) + #expect(first == second) + } + + @Test + func registrationCarriesRoutingAndUserPreferences() { + var settings = FeatureSettings() + settings.notificationsEnabled = false + settings.liveActivitiesEnabled = true + + let registration = PlatformCloudDeliveryRegistrationFactory.registration( + deviceID: "device-1", + deviceName: "Big O", + systemVersion: OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0), + appVersion: "1.2.3", + bundleID: "com.t3tools.t3code.swiftui", + pushToken: "apns-token", + pushToStartToken: "activity-token", + settings: settings, + apsEnvironment: .sandbox + ) + + #expect(registration.platform == "ios") + #expect(registration.iosMajorVersion == 26) + #expect(registration.bundleId == "com.t3tools.t3code.swiftui") + #expect(registration.apsEnvironment == .sandbox) + #expect(registration.pushToken == "apns-token") + #expect(registration.pushToStartToken == "activity-token") + #expect(!registration.preferences.notificationsEnabled) + #expect(registration.preferences.liveActivitiesEnabled) + } + + @Test + @MainActor + func installingNewControllerReleasesPreviousController() throws { + let suiteName = "cloud-delivery-controller-\(UUID())" + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + suite.set("existing-registration", forKey: "swift-ios.cloud-delivery-device.v1") + + let coordinator = PlatformCloudDeliveryCoordinator( + defaults: suite, + deviceID: "test-device" + ) + let currentController = T3ConnectController( + resolution: .unavailable(reason: "Authentication is not needed for this test.") + ) + weak var previousController: T3ConnectController? + + do { + let controller = T3ConnectController( + resolution: .unavailable(reason: "Authentication is not needed for this test.") + ) + previousController = controller + coordinator.install(controller: controller) + #expect( + suite.string(forKey: "swift-ios.cloud-delivery-device.v1") + == "existing-registration" + ) + coordinator.install(controller: currentController) + } + + #expect(previousController == nil) + #expect( + suite.string(forKey: "swift-ios.cloud-delivery-device.v1") + == "existing-registration" + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift new file mode 100644 index 000000000000..576836f75522 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformDeepLinkTests.swift @@ -0,0 +1,317 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Platform deep links") +struct PlatformDeepLinkTests { + @Test + func parsesWidgetThreadRoute() throws { + let route = try PlatformDeepLinkParser.parse( + "t3code://threads/environment-1/thread-7" + ) + + #expect(route == .thread(environmentID: "environment-1", threadID: "thread-7")) + } + + @Test + func parsesProjectAndEnvironmentQueryRoutes() throws { + #expect( + try PlatformDeepLinkParser.parse("t3code://projects/project-7?environment=environment-1") + == .project(environmentID: "environment-1", projectID: "project-7") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://environments/environment-2") + == .environment(id: "environment-2") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://new-task?environment=environment-2&project=project-8") + == .newTask(environmentID: "environment-2", projectID: "project-8") + ) + } + + @Test + func unwrapsPairingURL() throws { + let route = try PlatformDeepLinkParser.parse( + "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3DPAIR" + ) + + #expect(route == .connection(endpoint: "https://remote.example.com", token: "PAIR")) + } + + @Test + func parsesTrustedWebThreadRoute() throws { + let route = try PlatformDeepLinkParser.parse( + "https://app.t3.codes/environment-1/thread-7" + ) + + #expect(route == .thread(environmentID: "environment-1", threadID: "thread-7")) + } + + @Test + func rejectsUntrustedWebNavigationRoute() { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parse("https://malicious.example/threads/env/thread") + } + } + + @Test + func rejectsConnectionParametersFromUntrustedWebHosts() { + #expect(throws: PlatformDeepLinkError.unsupportedURL) { + try PlatformDeepLinkParser.parse( + "https://malicious.example/connect?endpoint=https%3A%2F%2Fattacker.example&token=x" + ) + } + } + + @Test + func routeURLsRoundTrip() throws { + let routes: [PlatformRoute] = [ + .environment(id: "environment 1"), + .project(environmentID: "environment 1", projectID: "project/1"), + .thread(environmentID: "environment 1", threadID: "thread 1"), + .newTask(environmentID: "environment 1", projectID: "project 1"), + .connection(endpoint: "https://remote.example.com", token: "PAIR"), + ] + + for route in routes { + let url = try #require(route.url) + #expect(url.scheme == PlatformRoute.nativeScheme) + let parsed = try PlatformDeepLinkParser.parse(url) + #expect(parsed == route, "Failed to round-trip \(route) through \(url.absoluteString)") + } + } + + @Test + func acceptsLinksFromBothSwiftUIIdentitiesAndLegacyRoutes() throws { + #expect( + try PlatformDeepLinkParser.parse("t3code-swiftui://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code-swiftui-dev://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3code://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + #expect( + try PlatformDeepLinkParser.parse("t3://threads/environment/thread") + == .thread(environmentID: "environment", threadID: "thread") + ) + } + + @Test + func clerkCallbackUsesCurrentAppIdentity() { + #expect(T3ConnectAuthCallback.scheme == PlatformRoute.nativeScheme) + #expect( + T3ConnectAuthCallback.redirectURL + == "\(PlatformRoute.nativeScheme)://clerk-callback" + ) + } + + @Test + func mailboxConsumesExactlyOnce() throws { + let suiteName = "PlatformDeepLinkTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let mailbox = PlatformRouteMailbox(defaults: defaults, key: "pending") + let route = PlatformRoute.thread(environmentID: "env", threadID: "thread") + + mailbox.put(route) + + #expect(mailbox.peek() == route) + #expect(mailbox.take() == route) + #expect(mailbox.take() == nil) + } + + @Test + func opensNativeThreadLinksInsideTheApp() throws { + let snapshot = Self.linkedSnapshot() + let expected = PlatformRoute.thread(environmentID: "environment-1", threadID: "thread-7") + + for link in [ + "\(PlatformRoute.nativeScheme)://threads/environment-1/thread-7", + "t3code://threads/environment-1/thread-7", + "t3://threads/environment-1/thread-7", + "https://app.t3.codes/environment-1/thread-7", + "https://app.t3.codes/threads/thread-7?environment=environment-1", + ] { + let url = try #require(URL(string: link)) + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) == expected, + "Expected \(link) to open in the app" + ) + } + } + + @Test + func opensThreadLinksByWireIdentifierWithoutAnEnvironment() throws { + let snapshot = Self.linkedSnapshot() + let url = try #require(URL(string: "t3code://threads/wire-thread-7")) + + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) + == .thread(environmentID: nil, threadID: "wire-thread-7") + ) + } + + @Test + func leavesLinksThisDeviceCannotShowToTheSystem() throws { + let snapshot = Self.linkedSnapshot() + + for link in [ + // Nothing on this device matches the destination. + "t3code://threads/environment-1/thread-missing", + "t3code://threads/environment-missing/thread-7", + "t3code://projects/environment-1/project-missing", + "t3code://environments/environment-missing", + "t3code://new-task?environment=environment-1&project=project-missing", + // Trusted web pages that are not thread destinations. + "https://app.t3.codes/docs/getting-started", + "https://app.t3.codes/settings", + // Ordinary links inside message content. + "https://example.com/environment-1/thread-7", + "mailto:someone@example.com", + ] { + let url = try #require(URL(string: link)) + #expect( + PlatformInAppLinkRouter.route(for: url, in: snapshot) == nil, + "Expected \(link) to keep its system behavior" + ) + } + } + + @Test + func leavesPairingLinksToOnboarding() throws { + let snapshot = Self.linkedSnapshot() + let url = try #require( + URL(string: "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3DPAIR") + ) + + #expect(PlatformInAppLinkRouter.route(for: url, in: snapshot) == nil) + } + + @Test + func opensProjectEnvironmentAndNewTaskLinksInsideTheApp() throws { + let snapshot = Self.linkedSnapshot() + + let project = try #require(URL(string: "t3code://projects/environment-1/project-3")) + #expect( + PlatformInAppLinkRouter.route(for: project, in: snapshot) + == .project(environmentID: "environment-1", projectID: "project-3") + ) + + let environment = try #require(URL(string: "t3code://environments/environment-1")) + #expect( + PlatformInAppLinkRouter.route(for: environment, in: snapshot) + == .environment(id: "environment-1") + ) + + let newTask = try #require( + URL(string: "t3code://new-task?environment=environment-1&project=project-3") + ) + #expect( + PlatformInAppLinkRouter.route(for: newTask, in: snapshot) + == .newTask(environmentID: "environment-1", projectID: "project-3") + ) + } + + private static func linkedSnapshot() -> FeatureSnapshot { + let environment = FeatureEnvironment( + id: "environment-1", + name: "Environment 1", + endpoint: "https://environment-1.example", + isActive: true + ) + let project = FeatureProject( + id: "project-3", + wireID: "wire-project-3", + environmentID: environment.id, + name: "Project 3", + path: "/project-3" + ) + let thread = FeatureThread( + id: "thread-7", + wireID: "wire-thread-7", + projectID: project.id, + environmentID: environment.id, + title: "Thread 7" + ) + return FeatureSnapshot( + environments: [environment], + projects: [project], + threads: [thread] + ) + } + + @Test + func resolverRequiresEnvironmentForDuplicateWireIDs() throws { + let active = FeatureEnvironment( + id: "active", + name: "Active", + endpoint: "https://active.example", + isActive: true + ) + let passive = FeatureEnvironment( + id: "passive", + name: "Passive", + endpoint: "https://passive.example" + ) + let activeProject = FeatureProject( + id: "project-active", + wireID: "shared-project", + environmentID: active.id, + name: "Active project", + path: "/active" + ) + let passiveProject = FeatureProject( + id: "project-passive", + wireID: "shared-project", + environmentID: passive.id, + name: "Passive project", + path: "/passive" + ) + let activeThread = FeatureThread( + id: "thread-active", + wireID: "shared-thread", + projectID: activeProject.id, + environmentID: active.id, + title: "Active thread" + ) + let passiveThread = FeatureThread( + id: "thread-passive", + wireID: "shared-thread", + projectID: passiveProject.id, + environmentID: passive.id, + title: "Passive thread" + ) + let snapshot = FeatureSnapshot( + environments: [active, passive], + projects: [passiveProject, activeProject], + threads: [passiveThread, activeThread] + ) + + #expect( + PlatformRouteResolver.thread( + in: snapshot, + environmentID: nil, + id: "shared-thread" + ) == nil + ) + #expect( + PlatformRouteResolver.thread( + in: snapshot, + environmentID: passive.id, + id: "shared-thread" + )?.id == passiveThread.id + ) + #expect( + PlatformRouteResolver.project( + in: snapshot, + environmentID: passive.id, + id: "shared-project" + )?.id == passiveProject.id + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift new file mode 100644 index 000000000000..eb71e845b2bf --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformFeedbackTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Platform feedback") +struct PlatformFeedbackTests { + @Test + func initialSnapshotDoesNotEmitSignals() { + let current = [thread(id: "one", state: .waitingForApproval)] + + #expect(PlatformThreadTransitionClassifier.signals(previous: nil, current: current).isEmpty) + } + + @Test + func classifiesAttentionFailureAndCompletionTransitions() { + let previous: [String: FeatureThreadState] = [ + "approval": .working, + "failure": .working, + "complete": .working, + "monitor-complete": .monitoring, + "idle-complete": .idle, + ] + let current = [ + thread(id: "approval", state: .waitingForApproval), + thread(id: "failure", state: .failed), + thread(id: "complete", state: .completed), + thread(id: "monitor-complete", state: .completed), + thread(id: "idle-complete", state: .completed), + ] + + let signals = PlatformThreadTransitionClassifier.signals(previous: previous, current: current) + + #expect(signals.map(\.thread.id) == ["approval", "failure", "complete", "monitor-complete"]) + #expect(signals.map(\.kind) == [.warning, .error, .success, .success]) + } + + @Test + func notificationPayloadSupportsURLAndServerFields() { + let routeFromURL = PlatformNotificationPayload.route(from: [ + "deep_link": "t3code://threads/environment/thread", + ]) + let routeFromFields = PlatformNotificationPayload.route(from: [ + "environmentId": "environment", + "threadId": "thread", + ]) + + #expect(routeFromURL == .thread(environmentID: "environment", threadID: "thread")) + #expect(routeFromFields == .thread(environmentID: "environment", threadID: "thread")) + } + + @Test + func recentThreadStoreSortsLimitsAndSkipsArchived() throws { + let suiteName = "PlatformFeedbackTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = PlatformRecentThreadStore(defaults: defaults, key: "recent") + var threads = (0 ..< 14).map { index in + thread( + id: "thread-\(index)", + state: .idle, + updatedAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + threads[13].isArchived = true + + store.update(from: threads) + let records = store.records() + + #expect(records.count == 12) + #expect(records.first?.id == "thread-12") + #expect(!records.contains { $0.id == "thread-13" }) + } + + private func thread( + id: String, + state: FeatureThreadState, + updatedAt: Date = .now + ) -> FeatureThread { + FeatureThread( + id: id, + wireID: "wire-\(id)", + projectID: "project", + environmentID: "environment", + title: "Thread \(id)", + updatedAt: updatedAt, + state: state + ) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift new file mode 100644 index 000000000000..730d97109a52 --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformIncomingShareTests.swift @@ -0,0 +1,448 @@ +import Foundation +import Testing +@testable import T3Code + +@Suite("Incoming share import") +struct PlatformIncomingShareTests { + @Test + func decodesSchemaOneImageEnvelopeWithoutFiles() throws { + let json = #"{"schemaVersion":1,"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","createdAt":"1970-01-01T00:01:40Z","text":"old","images":[{"id":"12345678-1234-1234-1234-123456789abc","fileName":"reference.png","typeIdentifier":"public.png","relativePath":"image.png","byteCount":2}],"warnings":[]}"# + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let envelope = try decoder.decode(T3IncomingShareEnvelope.self, from: Data(json.utf8)) + + #expect(envelope.schemaVersion == 1) + #expect(envelope.images.count == 1) + #expect(envelope.files.isEmpty) + } + + @Test + func rejectsSharedFilePathOutsideTheInbox() throws { + let root = URL(fileURLWithPath: "/tmp/t3-share-root", isDirectory: true) + + #expect(T3IncomingShareStore.fileURL( + relativePath: "../outside.txt", + rootURL: root + ) == nil) + } + + @Test + func genericFileImportRetriesWithoutReplacingTheOwnedCopy() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let sourceURL = directory.appendingPathComponent("report.txt") + try Data("report".utf8).write(to: sourceURL) + let attachmentID = try #require(UUID(uuidString: "12345678-1234-1234-1234-123456789abc")) + let envelope = Self.envelope(files: [Self.file( + id: attachmentID.uuidString, + byteCount: 6 + )]) + let recorder = IncomingShareTestRecorder() + let ownedRoot = directory.appendingPathComponent("owned", isDirectory: true) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in await recorder.record("remove") }, + fileURL: { _ in sourceURL } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, attachments, _, _ in + await recorder.record("import") + if await recorder.events.count == 1 { + throw IncomingShareTestError.saveFailed + } + return FeatureComposerDraft(attachments: attachments) + } + ), + attachmentFileStore: ManagedAttachmentFileStore(rootURL: ownedRoot) + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the first draft write to fail") + } catch { + #expect(error as? IncomingShareTestError == .saveFailed) + } + let draft = try await pipeline.importEnvelope(envelope, into: Self.project()) + + #expect(draft.attachments.first?.id == attachmentID) + #expect(draft.attachments.first?.ownedFile?.byteCount == 6) + #expect(await recorder.events == ["import", "import", "remove"]) + } + + @Test + func persistsMergedDraftBeforeRemovingInboxEnvelope() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let selection = FeatureSelection(providerID: "codex", modelID: "gpt-5.6-sol") + let workspace = FeatureComposerWorkspaceDraft( + mode: .worktree, + branch: "main", + worktreePath: nil, + startFromOrigin: true + ) + let existingAttachment = Self.attachment(id: UUID(), value: 1) + let existing = FeatureComposerDraft( + text: "Existing prompt", + attachments: [existingAttachment], + selection: selection, + workspace: workspace + ) + let imageID = try #require(UUID(uuidString: "12345678-1234-1234-1234-123456789abc")) + let envelope = Self.envelope( + text: "Shared context", + images: [Self.image(id: imageID.uuidString)] + ) + let project = Self.project() + let expectedKey = FeatureComposerDraftStore.newTaskKey(project: project) + try await store.setDraft(existing, for: expectedKey) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { image in + await recorder.record("read:\(image.id)") + return Data([0xCA, 0xFE]) + }, + remove: { id in await recorder.record("remove:\(id)") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + let draft = try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + await recorder.capture(draft: draft, key: key) + await recorder.record("import:\(key)") + return draft + } + ), + prepareImage: { data, ordinal in + await recorder.record("prepare:\(ordinal)") + return FeatureDraftAttachment( + data: data, + filename: "Image \(ordinal).jpg", + mimeType: "image/jpeg" + ) + } + ) + + let merged = try await pipeline.importEnvelope(envelope, into: project) + let captured = await recorder.capturedDraft + let events = await recorder.events + + #expect(merged.text == "Existing prompt\n\nShared context") + #expect(merged.selection == selection) + #expect(merged.workspace == workspace) + #expect(merged.attachments.count == 2) + #expect(merged.attachments.last?.id == imageID) + #expect(captured?.key == expectedKey) + #expect(captured?.draft == merged) + #expect(events.suffix(2) == ["import:\(expectedKey)", "remove:\(envelope.id)"]) + #expect(try await store.draft(for: expectedKey) == merged) + } + + @Test + func failedDraftSaveLeavesTheInboxUntouched() async { + let recorder = IncomingShareTestRecorder() + let envelope = Self.envelope(text: "Keep me") + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + await recorder.record("import") + throw IncomingShareTestError.saveFailed + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the draft write to fail") + } catch { + #expect(error as? IncomingShareTestError == .saveFailed) + } + + #expect(await recorder.events == ["import"]) + } + + @Test + func groupedProjectImportUsesTheSameDraftKeyAsTheComposer() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let project = Self.project( + repositoryIdentity: FeatureRepositoryIdentity(canonicalKey: "github.com/t3/example") + ) + let snapshot = FeatureSnapshot(projects: [project]) + let draftKey = FeatureComposerDraftStore.newTaskKey(project: project, in: snapshot) + let envelope = Self.envelope(text: "Keep shared context") + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + _ = try await pipeline.importEnvelope(envelope, into: project, draftKey: draftKey) + + #expect(draftKey == "logical-project:github.com/t3/example:new-task") + #expect(try await store.draft(for: draftKey)?.text == "Keep shared context") + #expect( + try await store.draft(for: FeatureComposerDraftStore.newTaskKey(project: project)) == nil + ) + } + + @Test + func imageFailureDoesNotPersistOrRemoveTheEnvelope() async { + let recorder = IncomingShareTestRecorder() + let envelope = Self.envelope( + images: [Self.image(id: UUID().uuidString)] + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in throw IncomingShareTestError.imageFailed }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in + await recorder.record("import") + return FeatureComposerDraft() + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected image loading to fail") + } catch { + #expect(error as? IncomingShareTestError == .imageFailed) + } + + #expect(await recorder.events.isEmpty) + } + + @Test + func attachmentLimitPreservesTheWholeEnvelope() async throws { + let recorder = IncomingShareTestRecorder() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let existing = FeatureComposerDraft( + attachments: (0..<7).map { Self.attachment(id: UUID(), value: UInt8($0)) } + ) + let envelope = Self.envelope( + images: [ + Self.image(id: UUID().uuidString), + Self.image(id: UUID().uuidString), + ] + ) + let pipeline = PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in + await recorder.record("read") + return Data() + }, + remove: { _ in await recorder.record("remove") } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { shareID, text, attachments, key, maximumCount in + await recorder.record("import") + return try await store.importSharedContent( + shareID: shareID, + text: text, + attachments: attachments, + for: key, + maximumAttachmentCount: maximumCount + ) + } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + let key = FeatureComposerDraftStore.newTaskKey(project: Self.project()) + try await store.setDraft(existing, for: key) + + do { + _ = try await pipeline.importEnvelope(envelope, into: Self.project()) + Issue.record("Expected the attachment limit to reject the import") + } catch let error as FeatureComposerDraftImportError { + if case let .attachmentLimitExceeded(available) = error { + #expect(available == 1) + } + } + + #expect(!(await recorder.events.contains("remove"))) + #expect(try await store.draft(for: key) == existing) + } + + @Test + func repeatedAtomicImportIsIdempotent() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = FeatureComposerDraftStore( + fileURL: directory.appendingPathComponent("drafts.json") + ) + let key = "environment:one:new-task:project" + let attachment = Self.attachment(id: UUID(), value: 1) + try await store.setDraft(FeatureComposerDraft(text: "Existing"), for: key) + + let once = try await store.importSharedContent( + shareID: "share-id", + text: "Shared", + attachments: [attachment], + for: key + ) + var edited = once + edited.text += "\nUser edit" + try await store.setDraft(edited, for: key) + let twice = try await store.importSharedContent( + shareID: "share-id", + text: "Shared", + attachments: [attachment], + for: key + ) + + #expect(once.text == "Existing\n\nShared") + #expect(once.attachments == [attachment]) + #expect(twice == edited) + } + + @Test + @MainActor + func noProjectNoticeKeepsEnvelopePendingAndOnlyReportsOnce() async { + let envelope = Self.envelope(text: "Pending") + let coordinator = PlatformIncomingShareCoordinator( + pipeline: PlatformIncomingSharePipeline( + source: PlatformIncomingShareSource( + loadAll: { [envelope] }, + data: { _ in Data() }, + remove: { _ in } + ), + drafts: PlatformIncomingShareDraftRepository( + importContent: { _, _, _, _, _ in FeatureComposerDraft() } + ), + prepareImage: { _, _ in Self.attachment(id: UUID(), value: 1) } + ) + ) + + #expect(await coordinator.refresh(hasProjects: false)) + #expect(coordinator.pendingEnvelope == envelope) + #expect(!(await coordinator.refresh(hasProjects: false))) + #expect(coordinator.pendingEnvelope == envelope) + } + + private static func envelope( + text: String = "", + images: [T3IncomingShareImage] = [], + files: [T3IncomingShareFile] = [] + ) -> T3IncomingShareEnvelope { + T3IncomingShareEnvelope( + schemaVersion: T3IncomingShareEnvelope.schemaVersion, + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + createdAt: Date(timeIntervalSince1970: 100), + text: text, + images: images, + files: files, + warnings: [] + ) + } + + private static func file(id: String, byteCount: Int) -> T3IncomingShareFile { + T3IncomingShareFile( + id: id, + fileName: "report.txt", + mimeType: "text/plain", + relativePath: "report.txt", + byteCount: byteCount + ) + } + + private static func image(id: String) -> T3IncomingShareImage { + T3IncomingShareImage( + id: id, + fileName: "reference.png", + typeIdentifier: "public.png", + relativePath: "image.png", + byteCount: 2 + ) + } + + private static func attachment(id: UUID, value: UInt8) -> FeatureDraftAttachment { + FeatureDraftAttachment( + id: id, + data: Data([value]), + filename: "Image.jpg", + mimeType: "image/jpeg" + ) + } + + private static func project( + repositoryIdentity: FeatureRepositoryIdentity? = nil + ) -> FeatureProject { + FeatureProject( + id: "project:environment:project", + wireID: "project", + environmentID: "environment", + name: "t3code", + path: "/repo", + repositoryIdentity: repositoryIdentity + ) + } +} + +private enum IncomingShareTestError: Error, Equatable { + case imageFailed + case saveFailed +} + +private actor IncomingShareTestRecorder { + private(set) var events: [String] = [] + private(set) var capturedDraft: (draft: FeatureComposerDraft, key: String)? + + func record(_ event: String) { + events.append(event) + } + + func capture(draft: FeatureComposerDraft, key: String) { + capturedDraft = (draft, key) + } +} diff --git a/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift b/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift new file mode 100644 index 000000000000..dc30dc1b461a --- /dev/null +++ b/apps/swift-ios/Tests/PlatformTests/PlatformRootViewTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import T3Code + +@MainActor +@Suite("Platform account session transitions") +struct PlatformRootViewTests { + @Test + func accountSignOutRemovesManagedEnvironments() { + #expect(PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: nil, + isSigningOut: false + )) + } + + @Test + func changingAccountsRemovesManagedEnvironments() { + #expect(PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: "account-2", + isSigningOut: false + )) + } + + @Test + func loadingAnExistingAccountKeepsManagedEnvironments() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: nil, + accountID: "account-1", + isSigningOut: false + )) + } + + @Test + func unchangedAccountsKeepManagedEnvironments() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: "account-1", + isSigningOut: false + )) + } + + @Test + func explicitSignOutOwnsItsManagedEnvironmentCleanup() { + #expect(!PlatformRootView.shouldRemoveManagedEnvironments( + previousAccountID: "account-1", + accountID: nil, + isSigningOut: true + )) + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 4c9396cc725b..e767081c7369 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", + "@q1code/core": "workspace:*", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx deleted file mode 100644 index 791004b74fad..000000000000 --- a/apps/web/src/AppRoot.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { describe, expect, it } from "vite-plus/test"; - -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; -import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; -import type { AppRouter } from "./router"; -import { AppRoot } from "./AppRoot"; - -describe("AppRoot", () => { - it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); - - expect(root.type).toBe(AppAtomRegistryProvider); - const children = Children.toArray( - (root as ReactElement<{ readonly children: ReactNode }>).props.children, - ); - expect(children).toHaveLength(4); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); - }); -}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 84ff979e4e89..5a9738c9fbfa 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -32,14 +32,6 @@ export function useAssetUrlState( ); } -export function useAssetUrl( - environmentId: EnvironmentId | null, - resource: AssetResource | null, -): string | null { - const result = useAssetUrlState(environmentId, resource); - return result._tag === "Success" ? result.url : null; -} - export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..b44e919af4b3 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,85 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); + try { + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + bitmap.close(); + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dfe2b51d0400 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf0595..5b46aa3199a6 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -24,9 +24,9 @@ describe("branding", () => { value: { desktopBridge: { getAppBranding: () => ({ - baseName: "T3 Code", + baseName: "q1code", stageLabel: "Nightly", - displayName: "T3 Code (Nightly)", + displayName: "q1code (Nightly)", }), }, }, @@ -34,9 +34,9 @@ describe("branding", () => { const branding = await import("./branding"); - expect(branding.APP_BASE_NAME).toBe("T3 Code"); + expect(branding.APP_BASE_NAME).toBe("q1code"); expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + expect(branding.APP_DISPLAY_NAME).toBe("q1code (Nightly)"); }); it("normalizes hosted app channel metadata", async () => { @@ -47,7 +47,7 @@ describe("branding", () => { expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); expect(branding.APP_STAGE_LABEL).toBe("Nightly"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); + expect(branding.APP_DISPLAY_NAME).toBe("q1code (Nightly)"); }); it("does not label the latest hosted app channel", async () => { @@ -58,7 +58,7 @@ describe("branding", () => { expect(branding.HOSTED_APP_CHANNEL).toBe("latest"); expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Latest"); expect(branding.APP_STAGE_LABEL).toBe("Latest"); - expect(branding.APP_DISPLAY_NAME).toBe("T3 Code"); + expect(branding.APP_DISPLAY_NAME).toBe("q1code"); }); it("ignores unknown hosted app channels", async () => { @@ -84,18 +84,18 @@ describe("branding logic", () => { it("updates the display name for nightly primary server versions", () => { expect( resolveServerBackedAppDisplayName({ - baseName: "T3 Code", + baseName: "q1code", fallbackDisplayName: "T3 Code (Alpha)", fallbackStageLabel: "Alpha", primaryServerVersion: "0.0.28-nightly.20260616.12", }), - ).toBe("T3 Code (Nightly)"); + ).toBe("q1code (Nightly)"); }); it("keeps the fallback display name for stable primary server versions", () => { expect( resolveServerBackedAppDisplayName({ - baseName: "T3 Code", + baseName: "q1code", fallbackDisplayName: "T3 Code (Alpha)", fallbackStageLabel: "Alpha", primaryServerVersion: "0.0.27", @@ -106,7 +106,7 @@ describe("branding logic", () => { it("keeps the fallback display name for malformed nightly primary server versions", () => { expect( resolveServerBackedAppDisplayName({ - baseName: "T3 Code", + baseName: "q1code", fallbackDisplayName: "T3 Code (Alpha)", fallbackStageLabel: "Alpha", primaryServerVersion: "0.0.28-nightly.20260616", diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 7fc57cf0d03f..bac90d3dfdbd 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,3 +1,4 @@ +import { BRAND } from "@q1code/core/brand"; // fork: base import type { DesktopAppBranding } from "@t3tools/contracts"; import { formatAppDisplayName } from "./branding.logic"; @@ -16,7 +17,7 @@ export const HOSTED_APP_CHANNEL = hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; export const HOSTED_APP_CHANNEL_LABEL = HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; -export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; +export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? BRAND.productName; // fork: base export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? HOSTED_APP_CHANNEL_LABEL ?? diff --git a/apps/web/src/browser/BrowserDeviceToolbar.test.ts b/apps/web/src/browser/BrowserDeviceToolbar.test.ts index ee4987794c33..087b5d109b40 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.test.ts +++ b/apps/web/src/browser/BrowserDeviceToolbar.test.ts @@ -1,10 +1,7 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { - commitViewportAndAspectRatio, - reconcileLockedAspectRatio, -} from "./browserDeviceToolbarState"; +import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; describe("commitViewportAndAspectRatio", () => { it("commits the aspect ratio only after the viewport succeeds", async () => { @@ -39,11 +36,3 @@ describe("commitViewportAndAspectRatio", () => { expect(onAspectRatioChange).not.toHaveBeenCalled(); }); }); - -describe("reconcileLockedAspectRatio", () => { - it("tracks external viewport ratios only while the lock remains active", () => { - expect(reconcileLockedAspectRatio(1.5, 16 / 9)).toBe(16 / 9); - expect(reconcileLockedAspectRatio(null, 16 / 9)).toBeNull(); - expect(reconcileLockedAspectRatio(1.5, null)).toBeNull(); - }); -}); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000000..4a241befef74 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..42d5bcfb35b8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index bac9600c182b..ed86cde1c9c8 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index eaae409568a2..6141b1a52fa9 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserDeviceToolbarState.ts b/apps/web/src/browser/browserDeviceToolbarState.ts index 9986ee022829..70a8e597428b 100644 --- a/apps/web/src/browser/browserDeviceToolbarState.ts +++ b/apps/web/src/browser/browserDeviceToolbarState.ts @@ -1,12 +1,5 @@ import type { PreviewViewportSetting } from "@t3tools/contracts"; -export function reconcileLockedAspectRatio( - current: number | null, - viewportAspectRatio: number | null, -): number | null { - return current === null || viewportAspectRatio === null ? null : viewportAspectRatio; -} - export async function commitViewportAndAspectRatio( setting: PreviewViewportSetting, aspectRatio: number | null, diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c96f..a60362c43bd5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572747..7ecffb4593d5 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..5cfe614f2985 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, @@ -241,6 +243,35 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..c7825961abbc 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -516,10 +516,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..64a4345dfb0d 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -4,7 +4,7 @@ type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; -export class BrowserViewportCommitTimeoutError extends Error { +class BrowserViewportCommitTimeoutError extends Error { override readonly name = "BrowserViewportCommitTimeoutError"; constructor(readonly tabId: string) { diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d275d7..c5338ecf4ccf 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e73e5..a320e3ba34da 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..2a1d122eedbf 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } const api = readLocalApi(); diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 2267f4a812dc..606244d43643 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,6 +1,6 @@ export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; -export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; -export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; +const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; export interface WebviewCrashRecoveryState { readonly attempts: number; diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts index 695bcff20e95..55adc129c3b2 100644 --- a/apps/web/src/browserFaviconLogic.ts +++ b/apps/web/src/browserFaviconLogic.ts @@ -9,7 +9,7 @@ export type BrowserFaviconEntry = { }; export const BROWSER_FAVICON_MAX_ENTRIES = 40; -export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 4c0a560817bb..7909fef95700 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -14,7 +14,7 @@ export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; export const BROWSER_HISTORY_MAX_PROJECTS = 20; -export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; const MAX_VALID_DATE_MS = 8_640_000_000_000_000; @@ -35,7 +35,7 @@ export function normalizeHistoryUrl(raw: string): string | null { return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; } -export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { +function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c80a..a86177b48eb3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", '{"wordWrap":"invalid"}'])( + "does not treat invalid saved settings as absent: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + it("preserves saved settings across a transient read failure", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6eccf..e1c1459facb3 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -2,7 +2,7 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; -export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; function hasWindow(): boolean { return typeof window !== "undefined"; @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { return null; } - try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch (error) { - console.error("Could not read persisted client settings.", error); - return null; - } + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); } export function writeBrowserClientSettings(settings: ClientSettings): void { diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 815715da2499..0bc65080cf8c 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -12,7 +12,7 @@ import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./ const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; -export function resolveConnectCliOAuthClientId(): string | null { +function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 7ae5e7ed03a9..3ae0dbd74289 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -26,10 +26,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth"; import { - collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - normalizeRelayBaseUrl, readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, @@ -155,48 +152,6 @@ afterEach(() => { }); describe("web cloud link environment client", () => { - it("normalizes relay URLs and de-duplicates cloud link targets", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - expect( - collectCloudLinkTargets({ - primary: TARGET, - saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], - }).map((target) => target.environmentId), - ).toEqual(["environment-1", "environment-2"]); - }); - - it.effect("lists relay-managed environments through the typed relay client", () => - Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValue( - Response.json({ - environments: [ - { - environmentId: "environment-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-06T00:00:00.000Z", - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const environments = yield* withServices( - listManagedCloudEnvironments({ clerkToken: "clerk-token" }), - ); - - expect(environments).toHaveLength(1); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - }), - ); - it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValue( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 29353e480f50..f88e7863969d 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -16,7 +16,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { - type RelayClientEnvironmentRecord, type RelayEnvironmentLinkResponse, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; @@ -33,14 +32,6 @@ import { requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function relayUrl(): string | null { return resolveCloudPublicConfig().relayUrl; } @@ -194,53 +185,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export function collectCloudLinkTargets(input: { - readonly primary: CloudLinkTarget | null; - readonly saved: ReadonlyArray; -}): ReadonlyArray { - const byId = new Map(); - if (input.primary) { - byId.set(input.primary.environmentId, input.primary); - } - for (const environment of input.saved) { - if (!byId.has(environment.environmentId)) { - byId.set(environment.environmentId, environment); - } - } - return [...byId.values()]; -} - -export function listManagedCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not list relay-managed environments.", - cause, - }), - ), - ); - }); -} - export function readPrimaryCloudLinkState(input: { readonly target: CloudLinkTarget; }): Effect.Effect { diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 52f9b6496c95..b5ce11e842f5 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -13,7 +13,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const relayDpopSignerLayer = Layer.effect( +const relayDpopSignerLayer = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 9a56bde88514..c8f33d1d9d3d 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -33,7 +33,7 @@ const managedRelayAtomRuntime = Atom.runtime( ), ); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); const managedRelayMutationScheduler = createAtomCommandScheduler(); @@ -114,10 +114,3 @@ export function useManagedRelayDevices() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..c5871fa65d66 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -42,7 +42,7 @@ function targetKey(target: CloudLinkTarget): string { return JSON.stringify(target); } -export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { +function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { if (target) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 07407bcf21b3..7ad86106ae08 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,6 +6,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -56,6 +57,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -66,6 +69,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -185,6 +182,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -200,6 +198,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -506,7 +505,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, @@ -614,13 +613,14 @@ export function BranchToolbarBranchSelector({ }); // PR pill shown next to the branch selector when the active branch has one. - const branchPr = resolveThreadPr({ - threadBranch: resolveBranchToolbarPrBranch({ - activeThreadBranch, - resolvedActiveBranch, - }), - gitStatus: branchStatusQuery.data ?? null, + const branchPrBranch = resolveBranchToolbarPrBranch({ + activeThreadBranch, + resolvedActiveBranch, }); + const branchPr = + branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch + ? (branchStatusQuery.data.pr ?? null) + : null; const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. @@ -864,7 +864,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( Unsent draft ) : null; - const pinIndicator = props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null; + const pinIndicator = + props.isPinned && !sortable?.isDragging ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return (
  • - + - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. + {sortable?.isDragging ? ( + dragDestination + ) : ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - Woke - + aria-label="Un-settle thread" + onClick={handleUnsettleClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Un-settle thread ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - + )} + + )} {props.jumpLabel ? : null} {detailsTooltip} @@ -1472,26 +1623,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const diff = latestTurnDiff(thread); - const sortable = props.sortable; return (
  • - +
  • {title} @@ -1877,8 +2022,10 @@ export default function Sidebar() { snoozeThread, unsnoozeThread, pinThread, + unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, archiveThread, deleteThread, } = useThreadActions(); @@ -2098,8 +2245,6 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); - // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. // The selection lives in the persisted UI store next to the other sidebar @@ -2241,13 +2386,27 @@ export default function Sidebar() { [openProjectSettings], ); - // Settled threads stay in the live shell stream (settled ≠ archived), so - // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. + // Keep a dropped row at its destination while its server applies the + // lifecycle command and any order-key writes. The next pickup waits for + // this hold so a second drop cannot replace an unconfirmed placement. + const [optimisticDrop, setOptimisticDrop] = useState<{ + readonly key: string; + readonly sourceSection: SidebarSection; + readonly section: "pinned" | "active" | "settled"; + readonly occurredAt: string; + readonly clearsSnooze: boolean; + /** Full destination order for pinned and active drops. */ + readonly order: readonly string[] | null; + /** Destination order keys before the drop, to recognize concurrent writes. */ + readonly keysAtDrop: ReadonlyMap; + /** The keys this drop writes (one per planned assignment). The + override holds until all of them appear in canonical state. */ + readonly assignedKeys: ReadonlyMap; + } | null>(null); const { pinnedThreads, - reorderablePinnedKeys, + draggableThreadKeys, + activeReorderableThreadKeys, activeThreads, snoozedThreads, settledThreads, @@ -2269,17 +2428,42 @@ export default function Sidebar() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + const draggable = new Set(); + const activeReorderable = new Set(); for (const thread of visible) { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + const supportsSettlement = capabilities?.threadSettlement === true; + const supportsSnooze = capabilities?.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); + // Older servers retain their existing drag actions. Active placement + // additionally requires its own ordering capability at the drop target. + if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { + draggable.add(threadKey); + } + if (optimisticDrop?.key === threadKey) { + const projected = applySidebarThreadDrop( + thread, + optimisticDrop.section, + optimisticDrop.occurredAt, + optimisticDrop.assignedKeys.get(threadKey), + ); + (optimisticDrop.section === "pinned" + ? pinned + : optimisticDrop.section === "settled" + ? settled + : active + ).push( + optimisticDrop.clearsSnooze + ? projected + : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, + ); + } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + // Snooze outranks settlement and pinning until the thread wakes. snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); @@ -2294,18 +2478,27 @@ export default function Sidebar() { // Server capability only gates DRAGGING — it must not influence the // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. + const sortedPinned = sortPinnedThreadsForSidebar(pinned); + const sortedActive = sortThreadsForSidebar(active); return { - pinnedThreads: sortPinnedThreadsForSidebar(pinned), - reorderablePinnedKeys: new Set( - pinned - .filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === - true, - ) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - activeThreads: sortThreadsForSidebar(active), + pinnedThreads: + optimisticDrop?.section !== "pinned" || optimisticDrop.order === null + ? sortedPinned + : orderItemsByPreferredIds({ + items: sortedPinned, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), + draggableThreadKeys: draggable, + activeReorderableThreadKeys: activeReorderable, + activeThreads: + optimisticDrop?.section !== "active" || optimisticDrop.order === null + ? sortedActive + : orderItemsByPreferredIds({ + items: sortedActive, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2315,7 +2508,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -2760,77 +2953,122 @@ export default function Sidebar() { }, [unsnoozeThread], ); - // Drag-to-reorder for the pinned block. A drop computes ONE fractional key - // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case, which - // instead rewrites every key in the section). The optimistic order keeps - // the card where it was dropped until EVERY key the drop wrote is - // reflected in canonical state — a section rewrite is several sequential - // writes, and releasing on the first landed key would expose the - // half-written canonical order, reshuffling the block once per write. - // A failed write clears the override (the card snaps back) with a toast. - // A key we did NOT write landing (a concurrent client's reorder that must - // win) and ANY membership change (new pin, unpin, snooze/wake) also - // release it: the override can't say where members it never saw belong, - // and holding it would launder a stale order into later drags. - const pinnedDndSensors = useSensors( + const listMotionRef = useRef | null>(null); + const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { + listMotionRef.current?.dispose(); + listMotionRef.current = node === null ? null : createSidebarListMotion(node); + listMotionRef.current?.update(false); + }, []); + + // Hold the chosen section and order until every key write arrives. This + // also covers first-time ordering, which assigns keys to keyless neighbors. + // A failed write, concurrent reorder, or membership change releases the hold. + const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ - readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop — the baseline that tells a - concurrent client's write apart from one of our own landing. */ - readonly keysAtDrop: ReadonlyMap; - /** The keys this drop writes (one per planned assignment). The - override holds until all of them appear in canonical state. */ - readonly assignedKeys: ReadonlyMap; + const [dragState, setDragState] = useState<{ + readonly activeKey: string; + readonly activeSection: SidebarSection; + readonly occurredAt: string; + readonly activationY: number | null; } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); + const [dragTargetSection, setDragTargetSection] = useState(null); + const sectionByThreadKey = useMemo(() => { + const map = new Map(); + const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { + for (const thread of list) { + map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section); + } + }; + add(pinnedThreads, "pinned"); + add(activeThreads, "active"); + add(snoozedThreads, "snoozed"); + add(settledThreads, "settled"); + return map; + }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); + const pinnedKeys = useMemo( + () => + pinnedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [pinnedThreads], + ); + const activeKeys = useMemo( + () => + activeThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeThreads], + ); useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + if (optimisticDrop === null) return; + const canonicalByKey = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread, + ]), ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + const thread = canonicalByKey.get(optimisticDrop.key); + if (thread === undefined || thread.archivedAt !== null) { + setOptimisticDrop(null); + return; + } + const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active"; + if ( + canonicalSection !== optimisticDrop.sourceSection && + canonicalSection !== optimisticDrop.section + ) { + setOptimisticDrop(null); + return; + } + if (optimisticDrop.order === null) { + // Settle also emits unpin/unsnooze events. Wait for the entire move + // before releasing the projected fields and sort timestamps. + if ( + canonicalSection === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + ) { + setOptimisticDrop(null); + } + return; + } + if (canonicalSection !== optimisticDrop.section) return; + if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; + const canonicalDestination = destinationKeys.flatMap((key) => { + const canonical = canonicalByKey.get(key); + return canonical === undefined ? [] : [canonical]; + }); + const keyByThread = new Map( + canonicalDestination.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, + ]), ); - // The override represents one drop against one snapshot of the world. - // Release it when the world moves on: membership changed (pin/unpin/ - // snooze/wake — the override can't say where members it never saw - // belong), a key changed to something we did NOT write (a concurrent - // client's reorder that must win), every key we wrote has landed, or - // canonical already matches. Releasing on the FIRST landed key instead - // of the last exposes the half-written order mid-materialization and - // the block visibly reshuffles once per write. + const heldOrder = optimisticDrop.order; + const heldKeys = new Set(heldOrder); const membershipChanged = - canonicalKeys.length !== optimisticPinnedOrder.order.length || - canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const foreignKeyLanded = canonical.some((thread, index) => { - const threadKey = canonicalKeys[index]!; - const currentKey = thread.pinOrderKey ?? null; - if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; - return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); + destinationKeys.length !== heldOrder.length || + destinationKeys.some((key) => !heldKeys.has(key)); + const foreignKeyLanded = destinationKeys.some((threadKey) => { + const currentKey = keyByThread.get(threadKey) ?? null; + if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; + return currentKey !== optimisticDrop.assignedKeys.get(threadKey); }); - const currentKeyByThreadKey = new Map( - canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), + const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every( + ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey, ); - const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( - ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, - ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { - setOptimisticPinnedOrder(null); + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { + setOptimisticDrop(null); } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); + }, [activeKeys, optimisticDrop, pinnedKeys, threads]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -2872,71 +3110,338 @@ export default function Sidebar() { [confirmAndUnpinThread], ); - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { + const handleThreadDragStart = useCallback( + (event: DragStartEvent) => { const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, + const activeSection = sectionByThreadKey.get(activeKey); + if (activeSection === undefined) return; + // Stop normal section motion before dnd-kit measures the picked-up row. + listMotionRef.current?.suspend(); + setDragState({ + activeKey, + activeSection, + occurredAt: new Date().toISOString(), + activationY: + event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), + setDragTargetSection(activeSection); + }, + [sectionByThreadKey], + ); + const handleThreadDragCancel = useCallback(() => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + }, []); + // Include every visible row in the measured order. Older servers disable + // pickup on their rows without changing where those rows render. + const sidebarListItems = useMemo((): readonly SidebarListItem[] => { + const rowsOf = ( + list: readonly EnvironmentThreadShell[], + section: SidebarSection, + ): SidebarListItem[] => + list.map((thread) => { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return { kind: "thread", key, section }; + }); + if ( + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 + ) { + return []; + } + const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; + const pinnedRows = rowsOf(pinnedThreads, "pinned"); + items.push(...pinnedRows); + items.push({ kind: "marker", marker: "pinned-divider" }); + const activeRows = rowsOf(activeThreads, "active"); + if (activeRows.length === 0) { + items.push({ kind: "marker", marker: "active-placeholder" }); + } + items.push(...activeRows); + if (snoozedThreads.length > 0) { + items.push({ kind: "marker", marker: "snoozed-header" }); + items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); + } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + if (settledRows.length === 0) { + items.push({ kind: "marker", marker: "settled-placeholder" }); + } + items.push(...settledRows); + return items; + }, [ + activeThreads, + pinnedThreads, + renderedSettledThreads, + settledThreads.length, + snoozedThreads.length, + visibleSnoozedThreads, + ]); + const listMotionPaused = dragState !== null; + useLayoutEffect(() => { + // Drag release clears the baseline, so its commit cannot replay the + // sortable preview. Later thread actions can animate while writes settle. + // Draft navigation can reveal a frozen row without changing the draft count. + listMotionRef.current?.update( + !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, + ); + }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]); + const handleThreadDragOver = useCallback( + (event: DragOverEvent) => { + const target = event.over + ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) + : null; + setDragTargetSection(target?.section ?? null); + }, + [sidebarListItems], + ); + const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); + const draggedSettledOrder = useMemo(() => { + const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); + if (dragState === null || thread === undefined) return []; + const key = (candidate: EnvironmentThreadShell) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); + return sortSettledThreadsForSidebar([ + ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), + applySidebarThreadDrop(thread, "settled", dragState.occurredAt), + ]).map(key); + }, [dragState, settledThreads, threadByKey]); + const sidebarSortingStrategy = useMemo( + () => + createSidebarSortingStrategy({ + items: sidebarListItems, + settledOrder: draggedSettledOrder, + settledExpanded: settledShelfExpanded, + settledVisibleCount, + routeThreadKey, + snoozedThreadCount: snoozedThreads.length, + }), + [ + draggedSettledOrder, + routeThreadKey, + settledShelfExpanded, + settledVisibleCount, + sidebarListItems, + snoozedThreads.length, + ], + ); + // Hidden and filtered threads keep their keys. Reserve those slots without + // including the rows in the visible drop order or writing to them. + const { pinnedKeysById, activeKeysById } = useMemo( + () => ({ + pinnedKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.pinOrderKey ?? null, + ]), + ), + activeKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.activeOrderKey ?? null, + ]), + ), + }), + [threads], + ); + const dndCollisionDetection = useMemo(() => { + if (dragState === null) return createSidebarCollisionDetection(() => true); + const source = threadByKey.get(dragState.activeKey); + if (source === undefined) return createSidebarCollisionDetection(() => false); + return createSidebarCollisionDetection( + (id) => { + const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id); + if (target === null) return false; + return ( + planSidebarThreadDrop({ + activeKey: dragState.activeKey, + activeSection: dragState.activeSection, + activePinned: source.pinnedAt != null, + activeSettled: source.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === + true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, + }).kind !== "none" + ); + }, + { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY }, + ); + }, [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + dragState, + draggableThreadKeys, + pinnedKeys, + sidebarListItems, + threadByKey, + ]); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + const activeKey = String(event.active.id); + const activeSection = sectionByThreadKey.get(activeKey); + const target = + event.over === null + ? null + : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); + const activeThread = threadByKey.get(activeKey); + if (activeSection === undefined || target === null || activeThread === undefined) return; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const plan = planSidebarThreadDrop({ + activeKey, + activeSection, + activePinned: activeThread.pinnedAt != null, + activeSettled: activeThread.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(activeThread.environmentId)?.environment.capabilities + .threadSettlement === true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, }); + if (plan.kind === "none") return; + if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + const assignments = + plan.kind === "pin" + ? [ + ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]), + ...plan.extraAssignments, + ] + : plan.kind === "reorder-pinned" || plan.kind === "move-active" + ? plan.assignments + : []; + const drop = { + key: activeKey, + sourceSection: activeSection, + section: target.section, + occurredAt: new Date().toISOString(), + clearsSnooze: + plan.kind === "pin" || + plan.kind === "settle" || + (plan.kind === "move-active" && plan.unsnooze), + order: plan.kind === "settle" ? null : plan.order, + keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById, + assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])), + }; + setOptimisticDrop(drop); void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) — unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - // Any failure — interrupted included — releases the override: - // a key that never lands would otherwise hold it until some - // unrelated world change came along. - setOptimisticPinnedOrder(null); - if (isAtomCommandInterrupted(result)) return; + const run = async ( + operation: Promise>, + title: string, + ) => { + const result = await operation; + if (result._tag === "Success") return true; + // A late failure must not cancel a newer drag's preview. + setOptimisticDrop((current) => (current === drop ? null : current)); + if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to reorder pinned threads", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); + } + return false; + }; + switch (plan.kind) { + case "settle": { + settlingThreadKeysRef.current.add(activeKey); + const navigateAfterSettle = planForwardNavigation(activeKey); + const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( + () => settlingThreadKeysRef.current.delete(activeKey), + ); + if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); return; } + case "move-active": + // The drag expresses unpin intent; button/menu confirmation is unchanged. + if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) + return; + if ( + plan.unsettle && + !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) + ) + return; + if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread"))) + return; + break; + case "pin": + if ( + !(await run( + pinThread( + threadRef, + plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, + ), + "Failed to pin thread", + )) + ) + return; + break; + case "reorder-pinned": + break; + } + // Stop on failure; each successful key write remains a valid placement. + const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; + for (const assignment of keyWrites) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + if ( + !(await run( + (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ), + plan.kind === "move-active" + ? "Failed to reorder active threads" + : "Failed to reorder pinned threads", + )) + ) + return; } })(); }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + draggableThreadKeys, + pinThread, + pinnedKeys, + planForwardNavigation, + reorderPinnedThread, + reorderActiveThread, + sectionByThreadKey, + settleThread, + sidebarListItems, + threadByKey, + unpinThread, + unsettleThread, + unsnoozeThread, + ], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); @@ -3021,8 +3526,9 @@ export default function Sidebar() { // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what // the actions will touch. - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( - (threadKey) => threadByKeyRef.current.has(threadKey), + const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + const threadKeys = selectedThreadKeys.filter((threadKey) => + threadByKeyRef.current.has(threadKey), ); if (threadKeys.length === 0) return; const count = threadKeys.length; @@ -3213,33 +3719,32 @@ export default function Sidebar() { ); if (confirmed._tag === "Failure" || !confirmed.value) return; } - // Grown as deletions actually land, never seeded with the whole batch: - // orphaned-worktree detection must only discount threads that are - // really gone, or the first delete would treat still-alive batch mates - // as deleted and remove a worktree they still point at. - const deletedThreadKeys = new Set(); - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - deletedThreadKeys.add(threadKey); + const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ + entries: threadKeys.map((threadKey) => ({ threadKey })), + delete: async ({ threadKey }, deletedThreadKeys) => { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return null; + return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + }, + }); + if (firstFailure !== null) { + const firstError = squashAtomCommandFailure(firstFailure); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); } - removeFromSelection(threadKeys); + removeFromSelection( + getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef !== null && readThreadShell(threadRef) !== null; + }), + ); }, [ attemptSettle, @@ -3580,11 +4085,6 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); - // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu @@ -3974,283 +4474,287 @@ export default function Sidebar() { closeDelay={0} timeout={400} > -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - , - pinnedThreads.length > 0 ? ( -
    • - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} + + +
        + {(() => { + const renderThreadRowInner = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + sortable?: SortableThreadRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + -
          - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); + {(bag) => renderThreadRowInner(thread, section, bag)} + + ); + }; + const from = dragState?.activeSection ?? null; + const previewPinnedCount = + pinnedThreads.length + + (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) - + (from === "pinned" && + dragTargetSection !== null && + dragTargetSection !== "pinned" + ? 1 + : 0); + const items: ReactNode[] = [ + , + ]; + for (const item of sidebarListItems) { + if (item.kind === "thread") { + items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + continue; + } + switch (item.marker) { + case "pinned-header": + items.push( + , + ); + break; + case "pinned-divider": + items.push( + 0} + />, + ); + break; + case "active-placeholder": + items.push( + , + ); + break; + case "snoozed-header": + items.push( + - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} -
        - - - - ) : null, - ]; - if (pinnedThreads.length > 0) { - items.push( -
      • , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled — out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
      • - -
      • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } - } - if (settledThreads.length > 0) { - items.push( -
      • + toggle={{ + expanded: snoozedShelfExpanded, + onToggle: toggleSnoozedShelf, + }} + />, + ); + break; + case "settled-header": + items.push( + , + ); + break; + case "settled-placeholder": + items.push( + , + ); + break; + } + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
      • -
      • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      +
    • + ) : null} +
    + + ) : null} {!isSearchingThreads && diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index eca741af8c89..c34eec58316d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -4,9 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, - resolveSidebarStageFocusRingOffsetClass, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -24,15 +22,6 @@ describe("SidebarStageBackdrop", () => { expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); }); - it("matches the focus-ring offset to each artwork palette", () => { - expect(resolveSidebarStageFocusRingOffsetClass("nightly")).toBe( - "focus-visible:ring-offset-(--stage-night-bottom)", - ); - expect(resolveSidebarStageFocusRingOffsetClass("dev")).toBe( - "focus-visible:ring-offset-(--stage-art-bottom)", - ); - }); - it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { @@ -48,26 +37,4 @@ describe("SidebarStageBackdrop", () => { expect(new Set(ids).size).toBe(ids.length); }, ); - - it("paints each artwork variant with theme-owned color tokens", () => { - const nightlyMarkup = renderToStaticMarkup(); - const devMarkup = renderToStaticMarkup(); - - expect(nightlyMarkup).toContain("var(--stage-night-bottom)"); - expect(nightlyMarkup).toContain("var(--stage-night-line)"); - expect(devMarkup).toContain("var(--stage-art-bottom)"); - expect(devMarkup).toContain("var(--stage-art-line)"); - expect(nightlyMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - expect(devMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - }); - - it.each([ - ["nightly", "96 0 8192 96"], - ["dev", "64 0 8192 96"], - ] as const)("uses the compact %s crop inside the send button", (variant, viewBox) => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain(`viewBox="${viewBox}"`); - expect(markup).toContain(`stage-${variant === "dev" ? "blueprint" : "nightly"}`); - }); }); diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index cd5074518719..5890e99d2010 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -15,8 +15,7 @@ export type ThreadCommandSubtitleVariant = | "favicon-workspace" | "favicon-branch-harness"; -export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = - "favicon-workspace-harness"; +const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = "favicon-workspace-harness"; export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 21949a1d1808..7dd8c79c9b46 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,7 +1,5 @@ import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { AtomRegistry } from "effect/unstable/reactivity"; import { GitMergeIcon, GitPullRequestClosedIcon, @@ -11,15 +9,8 @@ import { import { ChangeRequestStatusIcon, - nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveDisplayedThreadPr, - resolveDisplayedThreadPrProvider, - resolveThreadPr, settledPrHoverColorClass, - threadChangeRequestSnapshotsEqual, - threadChangeRequestSnapshotsAtom, - type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; @@ -57,25 +48,6 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } -function mergedFeaturePr(): NonNullable { - return { - number: 42, - title: "Feature PR", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRef: "main", - headRef: "feature/current", - state: "merged", - }; -} - -function snapshotFor( - branch: string, - pr: NonNullable, - sourceControlProvider?: VcsStatusResult["sourceControlProvider"], -): ThreadChangeRequestSnapshot { - return { branch, pr, sourceControlProvider }; -} - function pullRequestSummary( state: PullRequestSummary["state"], updatedAt: string, @@ -117,503 +89,6 @@ describe("shared pull request state", () => { }); }); -describe("resolveThreadPr", () => { - it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "feature/other", - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "stack/base", - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("hides PR indicators when thread branch metadata is missing", () => { - expect( - resolveThreadPr({ - threadBranch: null, - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("shows the PR when the live checkout matches the stored thread branch", () => { - const gitStatus = status(); - - expect( - resolveThreadPr({ - threadBranch: "feature/current", - gitStatus, - }), - ).toBe(gitStatus.pr); - }); -}); - -describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { - const featureBranch = "feature/current"; - const mergedPr = mergedFeaturePr(); - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const provider = { - kind: "github" as const, - name: "GitHub", - baseUrl: "https://github.com", - }; - - it("returns the live merged PR when the checkout matches the feature branch", () => { - const gitStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBe(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); - }); - - it("shows a linked pull request when the checkout has a different branch", () => { - const linkedPullRequestStatus = { - pr: mergedPr, - sourceControlProvider: provider, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(provider); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual({ - branch: "feature/other", - pr: mergedPr, - sourceControlProvider: provider, - linkedPullRequest, - }); - }); - - it("keeps a matching linked pull request snapshot while its status reloads", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toEqual(mergedPr); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeUndefined(); - }); - - it("clears an old snapshot when a different pull request is linked", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest: { ...linkedPullRequest, number: 41 }, - }; - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeNull(); - }); - - it("removes a linked pull request snapshot after the link is cleared", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("after caching a merged PR, resolves main status back to the cached feature PR", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); - - const mainStatus = status({ - refName: "main", - isDefaultRef: true, - pr: { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "main", - headRef: "main", - state: "open", - }, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); - }); - - it("never attaches a PR reported by main to the feature thread", () => { - const mainPr = { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "develop", - headRef: "main", - state: "merged" as const, - }; - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("does not show a cached open PR across a branch mismatch", () => { - const openSnapshot = snapshotFor(featureBranch, { - ...mergedPr, - state: "open", - title: "Still open", - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("retains a cached closed PR across a branch mismatch", () => { - const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; - const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: closedSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(closedPr); - }); - - it("does not retain or display a terminal PR when a worktree switches branches", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - const mismatchedStatus = status({ refName: "feature/other", pr: null }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeUndefined(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - }); - - it("retains a local terminal snapshot when thread metadata follows the new branch", () => { - const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: otherBranchSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("clears an open snapshot when a local thread moves to a branch with no PR", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears an open snapshot when a local checkout moves to a different branch", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears a retained snapshot when the thread branch is cleared", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - }); - - it("does not erase a terminal snapshot when VCS data is missing", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("retains a merged PR after a main checkout", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).not.toBeNull(); - expect(cached).not.toBeUndefined(); - - const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); - const displayed = resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }); - expect(displayed?.state).toBe("merged"); - }); - - it("refreshes a cached snapshot when a pull request becomes ready", () => { - const readyPr = { ...mergedPr, state: "open" as const }; - const draftPr = { ...readyPr, isDraft: true }; - - expect( - threadChangeRequestSnapshotsEqual( - snapshotFor(featureBranch, draftPr), - snapshotFor(featureBranch, readyPr), - ), - ).toBe(false); - }); -}); - -describe("threadChangeRequestSnapshotsAtom", () => { - it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => - Effect.gen(function* () { - const registry = AtomRegistry.make(); - const threadKey = "environment-1:thread-1"; - const snapshot = snapshotFor("feature/current", mergedFeaturePr()); - - const unmount = registry.mount(threadChangeRequestSnapshotsAtom); - registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); - unmount(); - - yield* Effect.yieldNow; - - const remount = registry.mount(threadChangeRequestSnapshotsAtom); - expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); - - remount(); - registry.dispose(); - }), - ); -}); - describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e7b24f80cb71..768154419927 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,8 +1,4 @@ -import { - scopeProjectRef, - scopedThreadKey, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { type EnvironmentId, @@ -10,17 +6,13 @@ import { type ThreadLinkedPullRequest, type VcsStatusResult, } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } +/** Keep cached summaries visible when an offscreen row stops live queries. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, + enabled = true, ): LinkedThreadPullRequestStatus | null { const queried = useEnvironmentQuery( - environmentId === null || linkedPullRequest == null + !enabled || environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ environmentId, @@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } ); } -export function resolveThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): ThreadPr | null { - const { threadBranch, gitStatus } = input; - if (gitStatus === null) { - return null; - } - - if (threadBranch === null || gitStatus.refName !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - -/** - * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement - * partitions move them, so terminal PR metadata must live above the row. - */ -export interface ThreadChangeRequestSnapshot { - readonly branch: string; - readonly pr: NonNullable; - readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest; -} - -export const threadChangeRequestSnapshotsAtom = Atom.make< - ReadonlyMap ->(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); - -function isTerminalChangeRequestState( - state: NonNullable["state"], -): state is "merged" | "closed" { - return state === "merged" || state === "closed"; -} - -function sourceControlProvidersEqual( - left: VcsStatusResult["sourceControlProvider"] | undefined, - right: VcsStatusResult["sourceControlProvider"] | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return left == null && right == null; - return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; -} - -function linkedPullRequestsEqual( - left: ThreadLinkedPullRequest | null | undefined, - right: ThreadLinkedPullRequest | null | undefined, -): boolean { - if (left == null || right == null) return left == null && right == null; - return ( - left.projectId === right.projectId && - left.repository === right.repository && - left.number === right.number && - left.url === right.url - ); -} - -export function threadChangeRequestSnapshotsEqual( - left: ThreadChangeRequestSnapshot, - right: ThreadChangeRequestSnapshot, -): boolean { - return ( - left.branch === right.branch && - left.pr.number === right.pr.number && - left.pr.title === right.pr.title && - left.pr.url === right.pr.url && - left.pr.baseRef === right.pr.baseRef && - left.pr.headRef === right.pr.headRef && - left.pr.state === right.pr.state && - left.pr.isDraft === right.pr.isDraft && - (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && - sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && - linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) - ); -} - -export function setThreadChangeRequestSnapshot( - threadKey: string, - snapshot: ThreadChangeRequestSnapshot | null, -): void { - appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { - const existing = current.get(threadKey); - if (snapshot === null) { - if (existing === undefined) return [false, current]; - const next = new Map(current); - next.delete(threadKey); - return [true, next]; - } - if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { - return [false, current]; - } - const next = new Map(current); - next.set(threadKey, snapshot); - return [true, next]; - }); -} - -/** - * Authoritative snapshot update from live VCS status. - * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone - * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear - * - snapshot: matching branch reports a PR — store/replace - */ -export function nextThreadChangeRequestSnapshot(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadChangeRequestSnapshot | null | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { - return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? undefined - : null; - } - return { - branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, - pr: linkedPullRequestStatus.pr, - sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, - linkedPullRequest, - }; - } - if (gitStatus === null) { - return snapshot?.linkedPullRequest === undefined ? undefined : null; - } - if (threadBranch === null) { - return null; - } - if (gitStatus.refName !== threadBranch) { - return retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ? undefined - : null; - } - if (gitStatus.pr == null) { - if ( - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return undefined; - } - return null; - } - return { - branch: threadBranch, - pr: gitStatus.pr, - sourceControlProvider: gitStatus.sourceControlProvider, - }; -} - -/** - * Live PR when the checkout matches the thread branch; otherwise, for local - * checkouts only, a cached merged/closed PR for the thread. Local thread - * metadata follows the shared checkout, so the cached branch intentionally - * survives that metadata changing to the newly checked-out branch. Open PRs - * are never retained — their state can still change. - */ -export function resolveDisplayedThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadPr | null { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.pr ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? (snapshot?.pr ?? null) - : null) - ); - } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.pr; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.pr; - } - - return null; -} - -export function resolveDisplayedThreadPrProvider(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): VcsStatusResult["sourceControlProvider"] | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.sourceControlProvider ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? snapshot?.sourceControlProvider - : undefined) - ); - } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.sourceControlProvider; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.sourceControlProvider; - } - - return undefined; -} - export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { @@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const lastVisitedAt = useUiStateStore( (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); - const threadProject = useProject( - useMemo( - () => scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd; - const linkedPullRequest = useLinkedThreadPullRequest( + const pullRequest = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest, - ); - const gitStatus = useEnvironmentQuery( - thread.linkedPullRequest == null && - (thread.branch != null || thread.worktreePath !== null) && - gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = - thread.linkedPullRequest == null - ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) - : (linkedPullRequest?.pr ?? null); - const prStatus = prStatusIndicator( - pr, - linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + thread.linkedPullRequest ?? thread.branchPullRequest, ); + const pr = pullRequest?.pr ?? null; + const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index d9dcd6e79936..1624a739bb1a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,11 +1,98 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { shouldClearTerminalSelectionAction, shouldHandleTerminalExit, + terminalContextMenuItems, terminalSelectionLineRange, + terminalSelectionMenuItems, + terminalThemeFromApp, } from "./ThreadTerminalDrawer"; +describe("terminal selection menus", () => { + it("omits Add to chat when the terminal has no chat target", () => { + expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); + expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + + expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ + "copy", + ]); + expect( + terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), + ).toEqual(["copy", "paste"]); + }); +}); + +describe("terminalThemeFromApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses terminal colors inherited by the mount instead of a light document theme", () => { + const root = { classList: { contains: () => false } }; + const body = {}; + const drawer = {}; + let canvasColor = "#000"; + const colors: Record = { + "#000": [0, 0, 0, 255], + "#fff": [255, 255, 255, 255], + "#ddd": [221, 221, 221, 255], + "#111": [17, 17, 17, 255], + }; + + vi.stubGlobal("document", { + documentElement: root, + body, + querySelector: () => drawer, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + clearRect: () => undefined, + fillRect: () => undefined, + get fillStyle() { + return canvasColor; + }, + set fillStyle(value: string) { + canvasColor = value; + }, + getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), + }), + }), + }); + vi.stubGlobal("getComputedStyle", (element: object) => { + const local = element === drawer; + const values = local + ? { + "--terminal-background": "#000", + "--terminal-foreground": "#fff", + "--terminal-cursor": "#ddd", + "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", + } + : { + "--terminal-background": "#fff", + "--terminal-foreground": "#111", + }; + return { + backgroundColor: local ? "#000" : "#fff", + color: local ? "#fff" : "#111", + colorScheme: local ? "dark" : "light", + getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", + }; + }); + + const theme = terminalThemeFromApp(); + + expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); + expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); + expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); + }); +}); + describe("terminal selection actions", () => { it("clears a pending or currently owned menu when the selection disappears", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9f4956aae682..d9ddf9225bdf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -41,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; -/** Post-selection popup: just the two selection actions, always enabled. */ -export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { +/** Post-selection popup: available selection actions, always enabled. */ +export function terminalSelectionMenuItems(options?: { + canAddToChat?: boolean; +}): ContextMenuItem<"add-to-chat" | "copy">[] { return [ - { id: "add-to-chat", label: "Add to chat" }, + ...(options?.canAddToChat === false + ? [] + : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), { id: "copy", label: "Copy" }, ]; } @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c */ export function terminalContextMenuItems(options: { hasSelection: boolean; + canAddToChat?: boolean; }): ContextMenuItem[] { + const { hasSelection, canAddToChat = true } = options; return [ - ...terminalSelectionMenuItems().map((item) => ({ + ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ ...item, - disabled: !options.hasSelection, + disabled: !hasSelection, })), { id: "paste", label: "Paste" }, ]; @@ -292,8 +315,9 @@ interface TerminalViewportProps { cwd: string; worktreePath?: string | null; runtimeEnv?: Record; + providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -317,6 +341,7 @@ export function TerminalViewport({ cwd, worktreePath, runtimeEnv, + providerInstanceId, onSessionExited, onAddTerminalContext, focusRequestId, @@ -357,8 +382,9 @@ export function TerminalViewport({ onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext(selection); + onAddTerminalContext?.(selection); }); + const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -383,6 +409,7 @@ export function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }); const writeTerminal = useEffectEvent((data: string) => @@ -635,7 +662,10 @@ export function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + canAddToChat: canAddSelectionToChat(), + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -648,7 +678,9 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - if (selectionAction) addSelectionToChat(selectionAction.selection); + if (selectionAction && canAddSelectionToChat()) { + addSelectionToChat(selectionAction.selection); + } return; case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); @@ -675,7 +707,10 @@ export function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show(terminalSelectionMenuItems(), nextAction.position) + .show( + terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), + nextAction.position, + ) .finally(() => { if (openSelectionMenuRequestIdRef.current === requestId) { openSelectionMenuRequestIdRef.current = null; @@ -686,7 +721,7 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -768,6 +803,14 @@ export function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + }).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); return; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2b19d6ec26ba..0a54a2a55abb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ApprovalRequestId, AssistantCitation, @@ -22,6 +23,7 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, memo, @@ -788,7 +790,6 @@ import { LockIcon, LockOpenIcon, PenLineIcon, - RotateCcwIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -803,7 +804,12 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + buildComposerPromptHistoryEntries, + stepComposerPromptHistory, + type ComposerPromptHistoryPosition, +} from "./composerPromptHistory"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import type { ContextWindowSnapshot } from "../../lib/contextWindow"; @@ -1172,6 +1178,8 @@ export interface ChatComposerProps { activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; + /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ + promptHistoryMessages: ReadonlyArray; isServerThread: boolean; isLocalDraftThread: boolean; forceExpandedOnMobile: boolean; @@ -1184,6 +1192,8 @@ export interface ChatComposerProps { sendDisabledReason: string | null; isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; + /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ + onUsageLimitsCommand?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -1244,6 +1254,8 @@ export interface ChatComposerProps { onRestingControlsVisibilityChange: (visible: boolean) => void; getTimelineScrollableNode: () => HTMLElement | null; isTimelineAtLogicalEnd: () => boolean; + /** Whether the timeline has more content than fits above the composer. */ + timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; /** * Whether the desktop resting layout is active. Reported from a layout @@ -1312,6 +1324,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, + promptHistoryMessages, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, @@ -1353,6 +1366,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRestingControlsVisibilityChange, getTimelineScrollableNode, isTimelineAtLogicalEnd, + timelineOverflows, onComposerOverlayHeightChange, onRestingChange, promptRef, @@ -1781,6 +1795,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + // Active ArrowUp recall. Cleared on edit and on thread switch. + const promptHistoryPositionRef = useRef(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( null, ); @@ -2536,6 +2552,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextPrompt; setPrompt(nextPrompt); + // Any edit ends browsing, even one later undone by hand: typing a + // character and deleting it leaves the text equal to the recall, and + // ArrowDown must move the caret then, not clear the composer. + if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { + promptHistoryPositionRef.current = null; + } if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( composerDraftTarget, @@ -2665,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }, [readComposerSnapshot]); + const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; @@ -2715,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onUsageLimitsCommand(); + } + return; + } const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -2755,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) applyPromptReplacement, handleInteractionModeChange, planModeUiEnabled, + onUsageLimitsCommand, resolveActiveComposerTrigger, ], ); @@ -2957,6 +2992,85 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, [setIsComposerFocused]); + // ------------------------------------------------------------------ + // Prompt history (ArrowUp / ArrowDown) + // ------------------------------------------------------------------ + // Entries are built on the keypress, not per render: the timeline changes + // on every streamed delta and ArrowUp is rare. + const promptHistoryMessagesRef = useRef(promptHistoryMessages); + promptHistoryMessagesRef.current = promptHistoryMessages; + + // The composer persists across threads. A recall from thread A must not + // be treated as active in thread B, where the text-match fallback could + // otherwise turn B's own draft into a browsing position. + const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); + useEffect(() => { + promptHistoryPositionRef.current = null; + }, [promptHistoryTargetKey]); + + const replacePromptFromHistory = useCallback( + (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }, + [composerDraftTarget, promptRef, setComposerDraftPrompt], + ); + + const navigatePromptHistory = useCallback( + (direction: "backward" | "forward", event: KeyboardEvent): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (isComposerApprovalState || pendingUserInputs.length > 0) return false; + // A composer holding an image, file, picked element, preview + // annotation, or review comment is not empty. Recalling text into it + // would send the old prompt with the new context, which is never what + // ArrowUp meant. + if ( + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerElementContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + // A typed draft with no active recall can never step, so skip the + // layout read and the entry build for that common case. + if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position: promptHistoryPositionRef.current, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position; + replacePromptFromHistory(step.prompt); + return true; + }, + [ + composerElementContextsRef, + composerFilesRef, + composerImagesRef, + composerPreviewAnnotations.length, + composerReviewComments.length, + isComposerApprovalState, + pendingUserInputs.length, + promptRef, + replacePromptFromHistory, + ], + ); + // ------------------------------------------------------------------ // Callbacks: command key // ------------------------------------------------------------------ @@ -2987,6 +3101,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } + if (key === "ArrowUp" || key === "ArrowDown") { + return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); + } const submissionIntent = key === "Enter" ? composerSubmissionIntentForEnter({ @@ -3601,6 +3718,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, collapseOnBlur: settings.composerCollapseOnBlur, + timelineOverflows, }); // The relocated controls live in the context strip whenever the composer is // collapsed for any reason, the desktop resting layout or the phone @@ -5155,7 +5273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> } > - + } > - + } > - + - - + + { - it("preserves the expanded composer geometry by default", () => { - const markup = renderToStaticMarkup(Model); - - expect(markup).toContain("h-7"); - expect(markup).toContain("min-h-7"); - expect(markup).toContain("gap-1.5"); - expect(markup).toContain("px-2.5"); - }); - - it("uses the shared xs geometry for resting controls", () => { - const markup = renderToStaticMarkup( - - Model - - , - ); - - expect(markup).toContain("sm:h-6"); - expect(markup).toContain("font-normal"); - expect(markup).toContain("text-muted-foreground/70"); - expect(markup).toContain("[--control-icon-color:currentColor]"); - expect(markup).toContain("svg[data-composer-control-chevron]]:ms-0"); - expect(markup).toContain("svg[data-composer-control-chevron]]:-me-1"); - expect(markup).not.toContain("min-h-7"); - expect(markup).not.toContain("gap-1.5"); - expect(markup).not.toContain("px-2.5"); - }); - - it("keeps the expanded chevron treatment unless resting overrides it", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-3.5"); - expect(expanded).toContain("text-icon-muted"); - expect(expanded).toContain('stroke-width="2.25"'); - expect(resting).toContain("size-3"); - expect(resting).toContain("text-current"); - expect(resting).toContain("opacity-50"); - expect(resting).not.toContain("size-3.5"); - expect(resting).not.toContain("text-icon-muted"); - }); - - it("owns resting icon geometry", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup(); - - expect(expanded).toContain("size-4"); - expect(resting).toContain("size-3"); - expect(resting).not.toContain("size-4"); - }); - - it("owns separator geometry for both composer sizes", () => { - const expanded = renderToStaticMarkup(); - const resting = renderToStaticMarkup( - , - ); - - expect(expanded).toContain("h-4"); - expect(expanded).not.toContain("h-3.5!"); - expect(resting).toContain("h-3.5!"); - expect(resting).not.toContain("h-4"); - expect(resting).toContain('data-resting-controls-separator="true"'); - }); -}); diff --git a/apps/web/src/components/chat/ComposerFeedback.tsx b/apps/web/src/components/chat/ComposerFeedback.tsx new file mode 100644 index 000000000000..8322b1a99462 --- /dev/null +++ b/apps/web/src/components/chat/ComposerFeedback.tsx @@ -0,0 +1,49 @@ +import { + codexFeedbackNotice, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { MessageSquareIcon } from "lucide-react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +export function feedbackBannerItem( + submission: CodexFeedbackSubmission, + onDismiss: () => void, +): ComposerBannerStackItem | null { + const notice = codexFeedbackNotice(submission); + if (!notice) return null; + return { + id: `feedback:${submission.id}`, + variant: + submission.status === "failed" ? "error" : submission.status === "sent" ? "success" : "info", + priority: submission.status === "uploading" ? "activity" : "notice", + icon: , + ...notice, + actions: + submission.status === "sent" ? ( + + ) : undefined, + ...(submission.status !== "uploading" + ? { dismissLabel: "Dismiss feedback notice", onDismiss } + : {}), + }; +} diff --git a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx index 7373403a7c39..8d59485b7d15 100644 --- a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx @@ -39,7 +39,7 @@ function buildTooltipContent(context: ElementContextDraft): string { return lines.join("\n"); } -export function ComposerPendingElementContextChip({ +function ComposerPendingElementContextChip({ context, onRemove, }: ComposerPendingElementContextChipProps) { diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx index 37c05eab2d0d..e2b3109f17a5 100644 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx @@ -1,4 +1,3 @@ -import { cn } from "~/lib/utils"; import { type TerminalContextDraft, formatTerminalContextLabel, @@ -6,11 +5,6 @@ import { } from "~/lib/terminalContext"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -interface ComposerPendingTerminalContextsProps { - contexts: ReadonlyArray; - className?: string; -} - interface ComposerPendingTerminalContextChipProps { context: TerminalContextDraft; } @@ -26,19 +20,3 @@ export function ComposerPendingTerminalContextChip({ return ; } - -export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { - const { contexts, className } = props; - - if (contexts.length === 0) { - return null; - } - - return ( -
    - {contexts.map((context) => ( - - ))} -
    - ); -} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 92f24c833db8..45ef93568cf6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -15,7 +15,7 @@ vi.mock("../SidebarStageBackdrop", () => ({ useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), })); -import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; function renderPendingActions(isRunning: boolean) { return renderToStaticMarkup( @@ -92,96 +92,6 @@ afterEach(() => { stageArtworkState.variant = null; }); -describe("formatPendingPrimaryActionLabel", () => { - it("returns 'Submitting...' while responding", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: true, - questionIndex: 0, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submitting...' while responding regardless of other flags", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: true, - questionIndex: 3, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submit' in compact mode on the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit"); - }); - - it("returns 'Next' in compact mode when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: false, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Next"); - }); - - it("returns 'Next question' when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Next question"); - }); - - it("returns singular 'Submit answer' on the last question when it is the only question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit answer"); - }); - - it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Submit answers"); - }); - - it("returns plural 'Submit answers' for higher question indices", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 5, - }), - ).toBe("Submit answers"); - }); -}); - describe("ComposerPrimaryActions", () => { it("disables and labels the send button while feedback is uploading", () => { const markup = renderSendButton("Sending feedback"); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 46323aa2b09a..91c54b75ed03 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -37,7 +37,7 @@ interface ComposerPrimaryActionsProps { onImplementPlanInNewThread: () => void; } -export const formatPendingPrimaryActionLabel = (input: { +const formatPendingPrimaryActionLabel = (input: { compact: boolean; isLastQuestion: boolean; isResponding: boolean; diff --git a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx b/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx index 4c5aa60ff06a..7431932ce9a9 100644 --- a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx +++ b/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx @@ -1,8 +1,8 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; -import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; +import { CircleAlertIcon, DownloadIcon } from "lucide-react"; import { useId, useState } from "react"; -import { observeVisibleAnimation } from "../../lib/visibleAnimation"; import { serverUpdateStageLabel } from "../ServerUpdateAction"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ComposerBanner } from "./ComposerBanner"; @@ -13,13 +13,7 @@ export function ComposerServerUpdateIcon({ readonly status: ServerUpdateState["status"]; }) { if (status === "running") { - return ( - - ); + return ; } if (status === "failed") { return ; diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx index ae2f4aa5849a..2eca3e23739a 100644 --- a/apps/web/src/components/chat/ComposerTasksBadge.tsx +++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx @@ -19,6 +19,12 @@ export interface ComposerTaskStep { const MAX_TASK_SEGMENTS = 10; +const taskStatusLabels = { + pending: "Pending", + inProgress: "Running", + completed: "Completed", +} satisfies Record; + function keyedTaskSteps(steps: readonly ComposerTaskStep[]) { const occurrences = new Map(); return steps.map((step) => { @@ -84,7 +90,7 @@ function TaskSummary({ className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined} data-composer-task-progress="true" > - {progress.completedSteps}/{progress.totalSteps} + {progress.completedSteps}/{progress.totalSteps} complete @@ -185,6 +191,9 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({ {step.step}
    + + {taskStatusLabels[step.status]} + void, +): ComposerBannerStackItem { + const [first] = report.accounts; + const single = report.accounts.length === 1 && first ? first : null; + const summary = single + ? [accountLabel(single), single.plan].filter(Boolean).join(" · ") + : `${report.accounts.length} accounts`; + return { + id, + variant: "info", + priority: "notice", + icon: , + title: "Usage limits", + description: summary, + dismissLabel: "Dismiss usage limits", + onDismiss, + children: , + }; +} + +function UsageLimitsBannerBody({ + report, + environmentId, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; +}) { + const now = Date.parse(report.createdAt); + return ( + + + {report.accounts.map((account) => { + const notice = limitsNotice(account.limits); + return ( +
    + {report.accounts.length > 1 ? ( + + {[accountLabel(account), account.plan].filter(Boolean).join(" · ")} + + ) : null} + {notice ? ( + {notice} + ) : ( + + )} + {account.instanceId && account.limits.resetCredits ? ( + + ) : null} +
    + ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index be3dacb05e92..6ff2b6e0a660 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -9,8 +9,8 @@ import { } from "../../providerInstances"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; -export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; -export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; +const CLAUDE_RESUME_COMPACTION_MINUTES = 70; +const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 04bbeb6ce49b..98f9caa2300a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -149,8 +149,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - if (project.defaultModelSelection) { - setModelSelection(draftId, project.defaultModelSelection, { + const defaultModelSelection = + project.defaultModelSelection ?? + environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 94727086f186..e1cf7f0dc6e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { MessageId, TurnId } from "@t3tools/contracts"; +import { + CheckpointRef, + EnvironmentId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { + applyThreadDetailEvent, + createEnvironmentThreadDetailAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, +} from "@t3tools/client-runtime/state/threads"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -12,6 +29,7 @@ import { shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, + type MessagesTimelineRowsProjection, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; import { @@ -19,6 +37,7 @@ import { deriveTimelineEntries, deriveTimelineEntriesWithState, type WorkLogEntry, + type TimelineEntriesProjection, } from "../../session-logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; @@ -96,8 +115,8 @@ describe("streaming row projection", () => { runningTurnId: turnId, isWorking: true, activeTurnStartedAt: time(5), - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, } satisfies Parameters[0]; return { messages, work, timeline, input, time, turnId, historyTurnId }; } @@ -242,6 +261,262 @@ describe("streaming row projection", () => { }, ); + it("owns checkpoint lookups across streaming and equal source snapshots", () => { + const initial = fixture("Partial"); + let checkpointLookupReads = 0; + const summary: TurnDiffSummary = { + turnId: initial.historyTurnId, + get checkpointTurnCount() { + checkpointLookupReads += 1; + return 1; + }, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), + status: "ready", + files: [], + get assistantMessageId() { + checkpointLookupReads += 1; + return MessageId.make("history-assistant"); + }, + get completedAt() { + checkpointLookupReads += 1; + return initial.time(4); + }, + }; + const input = { + ...initial.input, + turnDiffSummaries: [summary], + supportsConversationRollback: true, + expandedTurnIds: new Set([initial.historyTurnId]), + expandedWorkGroupIds: new Set(), + }; + const previous = deriveMessagesTimelineRowsWithState(input); + expect(checkpointLookupReads).toBeGreaterThan(0); + expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( + true, + ); + const last = initial.messages.at(-1)!; + const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; + const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); + const nextInput = { + ...input, + timelineEntries: timeline.entries, + turnDiffSummaries: [...input.turnDiffSummaries], + latestTurn: { ...input.latestTurn }, + expandedTurnIds: new Set(input.expandedTurnIds), + expandedWorkGroupIds: new Set(input.expandedWorkGroupIds), + }; + checkpointLookupReads = 0; + const next = deriveMessagesTimelineRowsWithState(nextInput, previous); + expect(checkpointLookupReads).toBe(0); + expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput)); + for (const [index, row] of previous.rows.entries()) { + if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { + expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); + } else { + expect(next.rows[index]).toBe(row); + } + } + + const changed = deriveMessagesTimelineRowsWithState( + { ...nextInput, turnDiffSummaries: [{ ...summary, checkpointTurnCount: 3 }] }, + next, + ); + expect( + changed.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: 2 }); + const unsupported = deriveMessagesTimelineRowsWithState( + { ...changed.input, supportsConversationRollback: false }, + changed, + ); + expect( + unsupported.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: undefined }); + expect( + previous.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: 0 }); + }); + + it("reuses long-thread rows through detail events, selectors, and attachment previews", () => { + const initial = fixture("Partial"); + let checkpointLookupReads = 0; + const history = Array.from({ length: 250 }, (_, index) => { + const turnId = TurnId.make(`older-turn-${index}`); + const user = { + ...initial.messages[0]!, + id: MessageId.make(`older-user-${index}`), + createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5)).toISOString(), + attachments: [ + { + type: "image" as const, + id: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 42, + }, + ], + }; + const assistant = { + ...initial.messages[1]!, + id: MessageId.make(`older-assistant-${index}`), + turnId, + createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5 + 3)).toISOString(), + }; + const checkpoint: TurnDiffSummary = { + turnId, + get checkpointTurnCount() { + checkpointLookupReads += 1; + return index + 1; + }, + checkpointRef: CheckpointRef.make(`refs/t3/checkpoints/older-${index}`), + status: "ready", + files: [], + get assistantMessageId() { + checkpointLookupReads += 1; + return assistant.id; + }, + get completedAt() { + checkpointLookupReads += 1; + return assistant.createdAt; + }, + }; + return { user, assistant, checkpoint }; + }); + const liveMessage = initial.messages.at(-1)!; + let thread: OrchestrationThread = { + id: ThreadId.make("streaming-thread"), + projectId: ProjectId.make("project"), + title: "Long thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + ...initial.input.latestTurn, + requestedAt: initial.time(5), + assistantMessageId: liveMessage.id, + }, + createdAt: initial.time(0), + updatedAt: initial.time(7), + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [ + ...history.flatMap(({ user, assistant }) => [user, assistant]), + ...initial.messages, + ], + proposedPlans: [], + activities: [], + checkpoints: history.map(({ checkpoint }) => checkpoint), + session: null, + }; + const state = Atom.make( + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), + ); + const details = createEnvironmentThreadDetailAtoms(() => state); + const ref = { environmentId: EnvironmentId.make("local"), threadId: thread.id }; + const registry = AtomRegistry.make(); + const unmount = registry.mount(details.detailAtom(ref)); + const preview = createMessageAttachmentPreviewProjector(); + let imageUrl = "https://first.test/image"; + let timeline: TimelineEntriesProjection | null = null; + let projection: MessagesTimelineRowsProjection | null = null; + const project = () => { + const selected = registry.get(details.detailAtom(ref)); + if (selected === null) throw new Error("Missing thread detail"); + const messages = selected.messages.map((message) => preview(message, () => imageUrl)); + timeline = deriveTimelineEntriesWithState( + messages, + selected.proposedPlans, + initial.work, + timeline, + ); + projection = deriveMessagesTimelineRowsWithState( + { + timelineEntries: timeline.entries, + latestTurn: selected.latestTurn, + runningTurnId: + selected.latestTurn?.state === "running" ? selected.latestTurn.turnId : null, + isWorking: selected.latestTurn?.state === "running", + activeTurnStartedAt: selected.latestTurn?.startedAt ?? null, + turnDiffSummaries: selected.checkpoints, + supportsConversationRollback: true, + }, + projection, + ); + return projection; + }; + const send = (text: string, sequence: number, streaming = true) => { + const result = applyThreadDetailEvent(thread, { + eventId: EventId.make(`delta-${sequence}`), + sequence, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + occurredAt: initial.time(8 + sequence), + aggregateKind: "thread", + aggregateId: thread.id, + type: "thread.message-sent", + payload: { + threadId: thread.id, + messageId: liveMessage.id, + role: "assistant", + text, + turnId: initial.turnId, + streaming, + createdAt: liveMessage.createdAt, + updatedAt: initial.time(8 + sequence), + }, + }); + if (result.kind !== "updated") throw new Error("Message event did not update the thread"); + thread = result.thread; + registry.set( + state, + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), + ); + return project(); + }; + + try { + const first = project(); + const saved = structuredClone(first.rows); + expect(checkpointLookupReads).toBeGreaterThan(0); + checkpointLookupReads = 0; + for (let index = 0; index < 10; index += 1) { + const next = send(` ${index}`, index + 1); + for (const [rowIndex, row] of first.rows.entries()) { + if ( + (row.kind === "message" || row.kind === "assistant-meta") && + row.message.id === liveMessage.id + ) + continue; + expect(next.rows[rowIndex]).toBe(row); + } + } + expect(checkpointLookupReads).toBe(0); + const streamed = project(); + expect(streamed.rows).toEqual(deriveMessagesTimelineRows(streamed.input)); + + imageUrl = "https://renewed.test/image"; + const renewed = project(); + expect(renewed.rows[0]).not.toBe(first.rows[0]); + expect(renewed.rows[0]).toMatchObject({ + message: { attachments: [{ previewUrl: imageUrl }] }, + }); + const completed = send("Complete", 11, false); + expect(completed.rows).toEqual(deriveMessagesTimelineRows(completed.input)); + expect( + completed.rows.find((row) => row.kind === "message" && row.message.id === liveMessage.id), + ).toMatchObject({ message: { text: "Complete" }, assistantCopyStreaming: false }); + expect(first.rows).toEqual(saved); + } finally { + unmount(); + registry.dispose(); + } + }); + it.each(["completion", "turn", "role", "ordering"] as const)( "rebuilds row structure for a %s change with otherwise unchanged controls", (change) => { @@ -351,7 +626,7 @@ describe("streaming row projection", () => { ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) } : message, ); - check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) }); + check({ supportsConversationRollback: true }); }); }); @@ -518,8 +793,8 @@ describe("work entry labels", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const directRow = rows.find((row) => row.kind === "work"); expect(directRow).toMatchObject({ @@ -831,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows).toEqual([ @@ -894,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -948,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -1005,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map([ - ["assistant-1" as never, assistantTurnDiffSummary], - ]), - revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]), + turnDiffSummaries: [assistantTurnDiffSummary], + supportsConversationRollback: true, }); const userRow = rows.find( @@ -1086,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const foldRow = collapsedRows.find( @@ -1109,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(expandedRows.map((row) => row.id)).toEqual([ @@ -1179,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const rows = deriveMessagesTimelineRows({ ...input, timelineEntries }); @@ -1262,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]); @@ -1365,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:14Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const foldRow = rows.find( @@ -1402,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows).toEqual([ @@ -1470,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.id)).toEqual([ @@ -1524,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); @@ -1618,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); @@ -1698,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); @@ -1771,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); @@ -1823,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set([turnId]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work")).toMatchObject({ @@ -1891,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]); @@ -1938,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => { latestTurn: null, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "work-live")).toBe(false); @@ -1999,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([ @@ -2044,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const workLiveRow = rows.find((row) => row.kind === "work-live"); @@ -2092,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initialRows = deriveRows(null); @@ -2170,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => { runningTurnId: "turn-2" as never, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ @@ -2215,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -2253,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRow = rows.find( @@ -2317,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const collapsedRows = deriveMessagesTimelineRows(baseInput); const expandedRows = deriveMessagesTimelineRows({ @@ -2411,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(row).toMatchObject({ @@ -2453,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set([turnId]), runningTurnId: isWorking ? turnId : null, activeTurnStartedAt: isWorking ? createdAt : null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const expandedRows = deriveMessagesTimelineRows({ ...input, @@ -2493,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ @@ -2540,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ @@ -2597,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => { runningTurnId: turnId, isWorking: true, activeTurnStartedAt: startedAt, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const assistantEntry = { id: "assistant-entry", @@ -2675,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initial = computeStableMessagesTimelineRows(rows, { @@ -2724,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const firstRows = createRows(); @@ -2780,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initial = computeStableMessagesTimelineRows(firstRows, { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 36c90c89563c..08e4f8d26037 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -14,12 +14,11 @@ import { } from "@t3tools/client-runtime/work-log/presentation"; export { normalizeCompactToolLabel, - summarizeToolGroup, toolGroupAction, - workLogEntryIsLocalCodeSearch, } from "@t3tools/client-runtime/work-log/presentation"; import { formatDuration, + inferCheckpointTurnCountByTurnId, isStreamingMessageTextUpdate, workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolSuccess, @@ -32,11 +31,11 @@ import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../.. import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -export const TIMELINE_MINIMAP_ITEM_SPACING = 8; +const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; -export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; -export const TIMELINE_CONTENT_MAX_WIDTH = 768; -export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; +const TIMELINE_CONTENT_MAX_WIDTH = 768; +const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); @@ -146,7 +145,7 @@ export interface TimelineEndState { * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming * reliable while streaming content is still growing under the viewport. */ -export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; +const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { if (!state) { @@ -208,9 +207,9 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } -export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; -export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; -export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; +const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; /** * The minimap overlays the viewport's left edge while the content column is @@ -778,6 +777,45 @@ function attachTrailingToolGroupsToAssistant( return result; } +/** Match each user message to the next assistant checkpoint. */ +function buildRevertTurnCountByUserMessageId(input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; +}): Map { + const byUserMessageId = new Map(); + const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; + for (let index = 0; index < entryCount; index += 1) { + const entry = input.timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { + const nextEntry = input.timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + return byUserMessageId; +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestTurn?: TimelineLatestTurn | null; @@ -786,9 +824,23 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - revertTurnCountByUserMessageId: ReadonlyMap; + turnDiffSummaries: ReadonlyArray; + supportsConversationRollback: boolean; }): MessagesTimelineRow[] { + const turnDiffSummaryByAssistantMessageId = new Map(); + for (const summary of input.turnDiffSummaries) { + if (summary.assistantMessageId) { + turnDiffSummaryByAssistantMessageId.set(summary.assistantMessageId, summary); + } + } + const revertTurnCountByUserMessageId = buildRevertTurnCountByUserMessageId({ + supportsConversationRollback: input.supportsConversationRollback, + timelineEntries: input.timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: input.supportsConversationRollback + ? inferCheckpointTurnCountByTurnId(input.turnDiffSummaries) + : {}, + }); const nextRows: MessagesTimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), @@ -1136,11 +1188,11 @@ export function deriveMessagesTimelineRows(input: { assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" - ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) + ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) : undefined, revertTurnCount: timelineEntry.message.role === "user" - ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id) + ? revertTurnCountByUserMessageId.get(timelineEntry.message.id) : undefined, }); } @@ -1170,9 +1222,30 @@ function replaceStreamingMessageRows( input: MessagesTimelineRowsInput, previous: MessagesTimelineRowsProjection, ): MessagesTimelineRow[] | null { - const { timelineEntries: previousEntries, ...previousContext } = previous.input; - const { timelineEntries, ...context } = input; - if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) { + const { + timelineEntries: previousEntries, + turnDiffSummaries: previousSummaries, + latestTurn: previousLatestTurn, + expandedTurnIds: previousExpandedTurns, + expandedWorkGroupIds: previousExpandedGroups, + ...previousContext + } = previous.input; + const { + timelineEntries, + turnDiffSummaries, + latestTurn, + expandedTurnIds, + expandedWorkGroupIds, + ...context + } = input; + if ( + timelineEntries.length !== previousEntries.length || + !shallow(previousContext, context) || + !shallow(previousSummaries, turnDiffSummaries) || + !shallow(previousLatestTurn, latestTurn) || + !shallow(previousExpandedTurns, expandedTurnIds) || + !shallow(previousExpandedGroups, expandedWorkGroupIds) + ) { return null; } const replacements = new Map(); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 16a0cbd83ce3..115f4f739254 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,5 +1,4 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; -import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -184,11 +183,11 @@ function buildProps() { listRef: createRef(), latestTurn: null, runningTurnId: null, - turnDiffSummaryByAssistantMessageId: new Map(), + turnDiffSummaries: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, + supportsConversationRollback: false, + onRevertToTurnCount: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, @@ -279,6 +278,7 @@ describe("MessagesTimeline", () => { isScrollCollapsed: composer.isComposerScrollCollapsed, hasExpandedChrome: false, collapseOnBlur: true, + timelineOverflows: true, }); }); return ( @@ -327,61 +327,6 @@ describe("MessagesTimeline", () => { }, ); - it("renders a feedback command and its pending response as normal thread messages", () => { - const submission = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: MESSAGE_CREATED_AT, - status: "uploading" as const, - }; - const messages = [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ]; - const markup = renderToStaticMarkup( - ({ - id: message.id, - kind: "message" as const, - createdAt: message.createdAt, - message, - }))} - />, - ); - - expect(markup).toContain("/feedback The agent stopped early."); - expect(markup).toContain("Sending feedback to OpenAI..."); - }); - - it("renders the returned Codex thread ID in the feedback response", () => { - const submission = { - id: MessageId.make("feedback-command"), - command: "/feedback The agent stopped early.", - createdAt: MESSAGE_CREATED_AT, - status: "sent" as const, - feedbackId: "codex-thread-1", - }; - const messages = [ - codexFeedbackMessage(submission), - codexFeedbackMessage(submission, "assistant"), - ]; - const markup = renderToStaticMarkup( - ({ - id: message.id, - kind: "message" as const, - createdAt: message.createdAt, - message, - }))} - />, - ); - - expect(markup).toContain("Feedback sent to OpenAI."); - expect(markup).toContain("codex-thread-1"); - }); - it("renders elapsed time for a completed turn", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); @@ -447,22 +392,17 @@ describe("MessagesTimeline", () => { }, }, ]} - turnDiffSummaryByAssistantMessageId={ - new Map([ - [ - assistantMessageId, - { - turnId, - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-with-files"), - status: "ready", - files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], - assistantMessageId, - completedAt: MESSAGE_CREATED_AT, - }, - ], - ]) - } + turnDiffSummaries={[ + { + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-with-files"), + status: "ready", + files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], + assistantMessageId, + completedAt: MESSAGE_CREATED_AT, + }, + ]} />, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6c6901a08881..a113157aca33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -113,7 +113,10 @@ import { } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; -import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; +import { + CHAT_TIMELINE_ANCHOR_OFFSET, + timelineContentOverflowsViewport, +} from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; @@ -200,7 +203,7 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; - onRevertUserMessage: (messageId: MessageId) => void; + onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; @@ -308,11 +311,11 @@ interface MessagesTimelineProps { timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; - turnDiffSummaryByAssistantMessageId: Map; + turnDiffSummaries: ReadonlyArray; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; - revertTurnCountByUserMessageId: Map; - onRevertUserMessage: (messageId: MessageId) => void; + supportsConversationRollback: boolean; + onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -335,6 +338,11 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + /** + * Whether the real rows extend past the viewport above the composer. + * Reported after scrolls, row size changes, and viewport resizes. + */ + onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -361,11 +369,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - turnDiffSummaryByAssistantMessageId, + turnDiffSummaries, routeThreadKey, onOpenTurnDiff, - revertTurnCountByUserMessageId, - onRevertUserMessage, + supportsConversationRollback, + onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, isRevertingCheckpoint, onImageExpand, @@ -382,6 +390,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, hideEmptyPlaceholder = false, @@ -534,8 +543,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, - turnDiffSummaryByAssistantMessageId, - revertTurnCountByUserMessageId, + turnDiffSummaries, + supportsConversationRollback, }, previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -554,8 +563,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, - turnDiffSummaryByAssistantMessageId, - revertTurnCountByUserMessageId, + turnDiffSummaries, + supportsConversationRollback, ]); const rows = useStableRows(rawRows); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -602,12 +611,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [anchoredEndSpace, contentInsetEndAdjustment], ); + const measureContentOverflow = useCallback( + () => + timelineContentOverflowsViewport(listRef.current?.getState?.(), { + composerInset: contentInsetEndAdjustment, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + }), + [contentInsetEndAdjustment, listRef], + ); + // LegendList lays rows out from layout effects, so a read on the next frame + // sees the settled positions. One frame is shared across bursts of size + // changes. + const contentOverflowFrameRef = useRef(null); + const cancelContentOverflowFrame = useCallback(() => { + if (contentOverflowFrameRef.current !== null) { + cancelAnimationFrame(contentOverflowFrameRef.current); + contentOverflowFrameRef.current = null; + } + }, []); + const reportContentOverflow = useCallback(() => { + if (!onContentOverflowChange || contentOverflowFrameRef.current !== null) return; + contentOverflowFrameRef.current = requestAnimationFrame(() => { + contentOverflowFrameRef.current = null; + onContentOverflowChange(measureContentOverflow()); + }); + }, [measureContentOverflow, onContentOverflowChange]); + useEffect(() => cancelContentOverflowFrame, [cancelContentOverflowFrame]); + // The list's own layout effects have already run here, so estimated row + // positions are in place. Reporting before the first paint lets a thread + // open in its final composer layout instead of correcting it a frame later. + // A frame scheduled with the previous inset would overwrite this read, so + // it is dropped first. + useLayoutEffect(() => { + cancelContentOverflowFrame(); + onContentOverflowChange?.(measureContentOverflow()); + }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } + reportContentOverflow(); if (!state || minimapItems.length === 0) { return; } @@ -630,7 +676,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [citationPositioning, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + citationPositioning, + listRef, + minimapItems, + minimapStripMap, + onIsAtEndChange, + reportContentOverflow, + ]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -649,6 +702,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ current === nextHasPersistentGutter ? current : nextHasPersistentGutter, ); setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); + reportContentOverflow(); }; const frame = requestAnimationFrame(measure); @@ -660,7 +714,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(frame); observer.disconnect(); }; - }, [timelineViewportElement, rows.length]); + }, [timelineViewportElement, rows.length, reportContentOverflow]); const sharedState = useMemo( () => ({ @@ -675,7 +729,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, @@ -699,7 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, @@ -789,6 +843,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } maintainScrollAtEndThreshold={1} onScroll={handleScroll} + onItemSizeChanged={reportContentOverflow} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "topbar-scroll-fade", @@ -1281,7 +1336,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); - const canRevertAgentWork = typeof row.revertTurnCount === "number"; + const revertTurnCount = row.revertTurnCount; return (
    @@ -1443,7 +1498,9 @@ function UserTimelineRow({ row }: { row: Extract
    - {canRevertAgentWork && } + {typeof revertTurnCount === "number" && ( + + )} {displayedUserMessage.copyText && ( )} @@ -1454,7 +1511,7 @@ function UserTimelineRow({ row }: { row: Extract ctx.onRevertUserMessage(messageId)} + onClick={() => ctx.onRevertToTurnCount(turnCount)} aria-label="Revert to this message" /> } @@ -3069,7 +3126,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
    diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index b9bf831c14d9..9f8e81bdba67 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -294,13 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({
    - } + render={", + componentName: null, + source: null, + styles: "", + }, + ]); + expect(recallableComposerPrompt(`Ultrathink:\n${withElement}`)).toBe("Investigate this"); + }); + + it("removes inline terminal labels along with their trailing block", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 12, + lineEnd: 13, + text: "git status", + }; + const typed = materializeInlineTerminalContextPrompt("Look at \uFFFC please", [context]); + expect(typed).toBe("Look at @terminal-1:12-13 please"); + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("Look at please"); + }); + + it("removes one label per chip and leaves other whitespace alone", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "ls", + }; + const typed = "@terminal-1:4 typed twice: @terminal-1:4\n indented code"; + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("typed twice: @terminal-1:4\n indented code"); + }); + + it("does not strip a typed label that only starts with the chip label", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "ls", + }; + const typed = "see @terminal-1:40 and @terminal-1:4-12 then @terminal-1:4"; + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("see @terminal-1:40 and @terminal-1:4-12 then"); + }); + + it("strips only the review comments appended at the end", () => { + const comment = buildFileReviewComment({ + id: "comment-1", + filePath: "src/app.ts", + startLine: 2, + endLine: 3, + text: "Keep this configurable.", + contents: "one\ntwo\nthree", + }); + const sent = appendReviewCommentsToPrompt("Please update this.", [comment]); + expect(recallableComposerPrompt(sent)).toBe("Please update this."); + const midPrompt = appendReviewCommentsToPrompt("Before", [comment]) + "\n\nAfter"; + expect(recallableComposerPrompt(midPrompt)).toBe(midPrompt); + // A typed block earlier in the prompt survives when the trailing one goes. + const both = appendReviewCommentsToPrompt(midPrompt, [comment]); + expect(recallableComposerPrompt(both)).toBe(midPrompt); + }); + + it("returns an empty string for app-composed sends", () => { + expect(recallableComposerPrompt(" ")).toBe(""); + expect(recallableComposerPrompt(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe(""); + expect(recallableComposerPrompt(buildPlanImplementationPrompt("# Plan\n1. do it"))).toBe(""); + }); +}); + +describe("buildComposerPromptHistoryEntries", () => { + it("keeps user messages with text, oldest first", () => { + expect(entries.map((entry) => entry.prompt)).toEqual(["first", "second", "third"]); + }); + + it("collapses consecutive duplicates onto the newest message id", () => { + const collapsed = buildComposerPromptHistoryEntries([ + { id: "m1", role: "user", text: "same" }, + { id: "m2", role: "user", text: "same" }, + { id: "m3", role: "user", text: "other" }, + { id: "m4", role: "user", text: "same" }, + ]); + expect(collapsed).toEqual([ + { id: "m2", prompt: "same" }, + { id: "m3", prompt: "other" }, + { id: "m4", prompt: "same" }, + ]); + }); +}); + +describe("stepComposerPromptHistory", () => { + it("does not start browsing from a non-empty draft", () => { + expect(backward(null, "typing")).toBeNull(); + }); + + it("walks back from the newest entry", () => { + const first = backward(null, ""); + expect(first).toEqual({ position: { entryId: "m3", recalled: "third" }, prompt: "third" }); + expect(backward(first!.position, "third")?.prompt).toBe("second"); + }); + + it("is a no-op at the oldest entry so the caret keeps moving", () => { + expect(backward({ entryId: "m1", recalled: "first" }, "first")).toBeNull(); + }); + + it("walks forward and empties the composer past the newest entry", () => { + const newer = forward({ entryId: "m2", recalled: "second" }, "second"); + expect(newer?.prompt).toBe("third"); + expect(forward(newer!.position, "third")).toEqual({ position: null, prompt: "" }); + }); + + it("treats an edited recall as a fresh draft", () => { + const position: ComposerPromptHistoryPosition = { entryId: "m3", recalled: "third" }; + expect(backward(position, "third edited")).toBeNull(); + expect(forward(position, "third edited")).toBeNull(); + // Sent and cleared: ArrowUp starts over from the newest entry. + expect(backward(position, "")?.position).toEqual({ entryId: "m3", recalled: "third" }); + }); + + it("does nothing on forward when not browsing", () => { + expect(forward(null, "")).toBeNull(); + }); + + it("follows the entry by id when the list changes under it", () => { + const grown = buildComposerPromptHistoryEntries([ + { id: "m0", role: "user", text: "zeroth" }, + { id: "m1", role: "user", text: "A" }, + { id: "m2", role: "user", text: "B" }, + { id: "m3", role: "user", text: "A" }, + ]); + const older = stepComposerPromptHistory({ + direction: "backward", + entries: grown, + position: { entryId: "m1", recalled: "A" }, + currentPrompt: "A", + }); + expect(older?.prompt).toBe("zeroth"); + // Unknown id with no matching text: browsing is over. + const missing = stepComposerPromptHistory({ + direction: "forward", + entries: grown, + position: { entryId: "gone", recalled: "not sent" }, + currentPrompt: "not sent", + }); + expect(missing).toBeNull(); + }); + + it("falls back to matching text when a duplicate collapse retires the id", () => { + const collapsed = buildComposerPromptHistoryEntries([ + { id: "m1", role: "user", text: "first" }, + { id: "m3", role: "user", text: "A" }, + ]); + const step = stepComposerPromptHistory({ + direction: "backward", + entries: collapsed, + position: { entryId: "m2", recalled: "A" }, + currentPrompt: "A", + }); + expect(step?.prompt).toBe("first"); + }); +}); diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts new file mode 100644 index 000000000000..c4b6b4c0f93f --- /dev/null +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -0,0 +1,212 @@ +import { extractTrailingElementContexts } from "../../lib/elementContext"; +import { extractTrailingPreviewAnnotation } from "../../lib/previewAnnotation"; +import { extractTrailingTerminalContexts } from "../../lib/terminalContext"; +import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; + +/** + * Terminal-style prompt recall for the composer. ArrowUp on an empty + * composer walks back through the active thread's sent prompts, ArrowDown + * walks forward and restores the unsent draft past the newest entry. + * + * History is per thread and text only. It is derived from the thread's user + * messages on every keypress, so there is no store to persist or sync. + */ + +const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n"; +const REVIEW_COMMENT_BLOCK_PATTERN = /]*>[\s\S]*?<\/review_comment>/g; + +/** Text sent in place of an empty prompt when a message is attachments only. */ +export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; + +export interface ComposerPromptHistoryMessage { + readonly id: string; + readonly role: string; + readonly text: string; +} + +export interface ComposerPromptHistoryEntry { + readonly id: string; + readonly prompt: string; +} + +/** + * Active recall. `entryId` is resolved against the current entries on every + * step, so a server ack replacing an optimistic message or an older page + * loading cannot move the position. `recalled` is the text put in the + * composer; once the composer no longer matches it, the user has edited or + * sent and browsing is over. + */ +export interface ComposerPromptHistoryPosition { + readonly entryId: string; + readonly recalled: string; +} + +/** + * Prefer the id. A consecutive duplicate collapse can retire the recalled + * id while the same text lives on under a newer one, so fall back to the + * newest entry with matching text. + */ +function findActive( + entries: ReadonlyArray, + position: ComposerPromptHistoryPosition, +): number { + const byId = entries.findIndex((entry) => entry.id === position.entryId); + if (byId >= 0) return byId; + return entries.findLastIndex((entry) => entry.prompt === position.recalled); +} + +export interface ComposerPromptHistoryStep { + readonly position: ComposerPromptHistoryPosition | null; + readonly prompt: string; +} + +/** + * Drop only the review comments appended at send time, which sit at the + * end. Cuts the original string at the start of the trailing run of blocks + * so any review comment block the user typed earlier stays byte-for-byte. + */ +function stripTrailingReviewComments(prompt: string): string { + let cut = prompt.length; + for (const match of [...prompt.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)].toReversed()) { + const blockEnd = match.index + match[0].length; + if (prompt.slice(blockEnd, cut).trim().length > 0) break; + cut = match.index; + } + return cut === prompt.length ? prompt : prompt.slice(0, cut).trimEnd(); +} + +/** + * Inline terminal chips are sent as `@terminal-1:12-13` labels in the text + * with their content in the trailing block. Once the block is stripped the + * label points at nothing, so remove it too. Each block entry removes one + * label (the first match) and the single space beside it. Nothing else in + * the prompt is touched, so indented code and typed labels survive. Block + * headers look like `Terminal 1 lines 12-13`. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray): string { + let result = prompt; + for (const header of headers) { + const match = /^(.+?) lines? (\d+(?:-\d+)?)$/.exec(header); + if (!match) continue; + const label = `@${match[1]!.trim().toLowerCase().replace(/\s+/g, "-")}:${match[2]}`; + // Whole label only: `@terminal-1:4` must not match inside `@terminal-1:40` + // or `@terminal-1:4-12`. + const labelPattern = new RegExp(`${escapeRegExp(label)}(?![\\d-])`); + const index = result.search(labelPattern); + if (index < 0) continue; + let end = index + label.length; + let start = index; + if (result[end] === " ") end += 1; + else if (result[start - 1] === " ") start -= 1; + result = result.slice(0, start) + result.slice(end); + } + return result; +} + +/** + * Reduce a sent message to the text the user typed. Send-time appends + * (terminal and element context blocks, preview annotations, review + * comments, the Claude ultrathink prefix) are stripped so a recalled prompt + * never carries stale context from another turn. + */ +export function recallableComposerPrompt(messageText: string): string { + let prompt = messageText.trim(); + if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) { + prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length); + } + + while (prompt.length > 0) { + const withoutReviewComments = stripTrailingReviewComments(prompt); + if (withoutReviewComments !== prompt) { + prompt = withoutReviewComments; + continue; + } + const previewAnnotation = extractTrailingPreviewAnnotation(prompt); + if (previewAnnotation.annotation) { + prompt = previewAnnotation.promptText; + continue; + } + const elementContexts = extractTrailingElementContexts(prompt); + if (elementContexts.contextCount > 0) { + prompt = elementContexts.promptText; + continue; + } + const terminalContexts = extractTrailingTerminalContexts(prompt); + if (terminalContexts.contextCount > 0) { + prompt = stripInlineTerminalLabels( + terminalContexts.promptText, + terminalContexts.contexts.map((context) => context.header), + ); + continue; + } + break; + } + + // App-composed sends are not text the user typed, so they are not history. + const trimmed = prompt.trim(); + if ( + trimmed === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT || + trimmed.startsWith(PLAN_IMPLEMENTATION_PROMPT_PREFIX) + ) { + return ""; + } + return trimmed; +} + +/** + * Oldest first. Consecutive identical prompts collapse into the newest one, + * matching shell `HISTCONTROL=ignoredups`. Image-only sends have no text and + * are skipped. + */ +export function buildComposerPromptHistoryEntries( + messages: ReadonlyArray, +): ComposerPromptHistoryEntry[] { + const entries: ComposerPromptHistoryEntry[] = []; + for (const message of messages) { + if (message.role !== "user") continue; + const prompt = recallableComposerPrompt(message.text); + if (prompt.length === 0) continue; + const previous = entries[entries.length - 1]; + if (previous && previous.prompt === prompt) { + entries[entries.length - 1] = { id: message.id, prompt }; + continue; + } + entries.push({ id: message.id, prompt }); + } + return entries; +} + +/** + * Returns null when the key should fall through to normal caret movement. + * Backward starts only from an empty composer and stops at the oldest entry. + * Forward past the newest entry empties the composer and ends browsing. An + * edited or sent recall no longer matches `recalled`, so browsing restarts + * from scratch on the next backward step. + */ +export function stepComposerPromptHistory(input: { + readonly direction: "backward" | "forward"; + readonly entries: ReadonlyArray; + readonly position: ComposerPromptHistoryPosition | null; + readonly currentPrompt: string; +}): ComposerPromptHistoryStep | null { + const { entries, position, currentPrompt } = input; + const activeIndex = + position && position.recalled === currentPrompt ? findActive(entries, position) : -1; + + if (input.direction === "backward") { + if (activeIndex < 0 && currentPrompt.length > 0) return null; + const entry = entries[activeIndex < 0 ? entries.length - 1 : activeIndex - 1]; + if (!entry) return null; + return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; + } + + if (activeIndex < 0) return null; + const entry = entries[activeIndex + 1]; + if (!entry) return { position: null, prompt: "" }; + return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; +} diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index d0f37f97d800..f836f069006d 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -35,7 +35,7 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ * whole menu with the one item that cannot be honoured is what left a right-click on a link * showing the platform's cut-and-paste menu instead of a way to copy the link. */ -export function externalLinkContextMenuItems(options: { +function externalLinkContextMenuItems(options: { readonly canOpenInPreview: boolean; readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined; }): readonly ContextMenuItem[] { diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 1bf82c47a614..e5a67d05e409 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + getRowBottom, + timelineContentOverflowsViewport, +} from "./timelineScrollAnchoring"; function buildState({ positions, @@ -21,6 +25,37 @@ function buildState({ }; } +describe("timelineContentOverflowsViewport", () => { + const inset = { composerInset: 100, anchorOffset: 24 }; + + it("reports overflow from the last row, not the inset spacer", () => { + const fits = buildState({ positions: [0, 200], sizes: [200, 300], scrollLength: 700 }); + expect(timelineContentOverflowsViewport(fits, inset)).toBe(false); + + const overflows = buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 700 }); + expect(timelineContentOverflowsViewport(overflows, inset)).toBe(true); + }); + + it("treats an empty or unmeasured list as fitting", () => { + expect(timelineContentOverflowsViewport(undefined, inset)).toBe(false); + expect( + timelineContentOverflowsViewport( + buildState({ positions: [0, 200], sizes: [200, 400], scrollLength: 0 }), + inset, + ), + ).toBe(false); + expect(timelineContentOverflowsViewport(buildState({ positions: [], sizes: [] }), inset)).toBe( + false, + ); + expect( + timelineContentOverflowsViewport( + buildState({ positions: [0, 200], sizes: [200, Number.NaN] }), + inset, + ), + ).toBe(false); + }); +}); + describe("timeline scroll anchoring", () => { it("measures row bottoms from LegendList row position and size", () => { const state = buildState({ diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 4b011e235900..81d2c20fcb76 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -37,6 +37,31 @@ export function getRowBottom(state: TimelineListMeasurementState, index: number) return top + Math.max(1, height); } +/** + * Whether the timeline's real rows extend past the viewport left above the + * composer. The list's own content length includes the composer inset + * spacer, so this measures from the last row instead. Unknown row geometry + * or an unmeasured viewport counts as fitting. + */ +export function timelineContentOverflowsViewport( + state: TimelineListMeasurementState | undefined, + input: { readonly composerInset: number; readonly anchorOffset: number }, +): boolean { + if (!state || !state.data || state.data.length === 0) { + return false; + } + const scrollLength = state.scrollLength; + if (typeof scrollLength !== "number" || !Number.isFinite(scrollLength) || scrollLength <= 0) { + return false; + } + const lastBottom = getRowBottom(state, state.data.length - 1); + if (lastBottom === null) { + return false; + } + const visibleScrollLength = Math.max(0, scrollLength - input.composerInset - input.anchorOffset); + return lastBottom > visibleScrollLength; +} + export function getAnchoredTurnMetrics({ state, anchorIndex, diff --git a/apps/web/src/components/chat/useComposerFocusState.test.tsx b/apps/web/src/components/chat/useComposerFocusState.test.tsx index e4bf38430e85..5d2ee7a3163c 100644 --- a/apps/web/src/components/chat/useComposerFocusState.test.tsx +++ b/apps/web/src/components/chat/useComposerFocusState.test.tsx @@ -20,6 +20,7 @@ function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolea isScrollCollapsed: state.isComposerScrollCollapsed, hasExpandedChrome: false, collapseOnBlur: true, + timelineOverflows: true, }); }); return null; diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 00f20e53fbe1..2ddf8f56dd2a 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -1,4 +1,5 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; + import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; @@ -55,7 +56,7 @@ export function ClerkUserProfileRefreshButton({ disabled={disabled || isPending} onClick={onClick} > -
    @@ -55,11 +57,13 @@ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, savedEnvironments, showSavedEnvironments = false, + refreshWhileEmpty = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironments: ReadonlyArray; readonly showSavedEnvironments?: boolean; + readonly refreshWhileEmpty?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -69,6 +73,10 @@ export function CloudEnvironmentConnectRows({ const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { reportFailure: false, }); + const refreshDiscoveryWhenIdle = useEffectEvent(async () => { + if (environmentsState.refreshing || environmentsState.offline) return; + await refreshRelayEnvironments(); + }); const connectRelayEnvironment = useCallback( (environment: RelayClientEnvironmentRecord) => registerEnvironment( @@ -89,8 +97,10 @@ export function CloudEnvironmentConnectRows({ ); useEffect(() => { - void refreshRelayEnvironments(); - }, [refreshRelayEnvironments]); + if (!refreshWhileEmpty || document.visibilityState === "visible") { + void refreshRelayEnvironments(); + } + }, [refreshRelayEnvironments, refreshWhileEmpty]); const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); @@ -132,10 +142,54 @@ export function CloudEnvironmentConnectRows({ environment.environmentId !== primaryEnvironmentId && (showSavedEnvironments || !savedById.has(environment.environmentId)), ); + // Discovery clears its list on refresh, so poll only until a machine appears. + const shouldRefreshWhileEmpty = + refreshWhileEmpty && visibleEnvironments.length === 0 && !environmentsState.offline; + + useEffect(() => { + if (!shouldRefreshWhileEmpty) return; + let timer: ReturnType | undefined; + let disposed = false; + let pending = false; + const visible = () => document.visibilityState === "visible"; + const schedule = () => { + clearTimeout(timer); + if (!disposed && visible()) { + timer = setTimeout(() => void refresh(), EMPTY_DISCOVERY_REFRESH_INTERVAL_MS); + } + }; + const refresh = async () => { + if (disposed || pending || !visible()) return; + clearTimeout(timer); + pending = true; + try { + await refreshDiscoveryWhenIdle(); + } finally { + pending = false; + schedule(); + } + }; + const onFocus = () => void refresh(); + const onVisibilityChange = () => { + clearTimeout(timer); + if (visible()) void refresh(); + }; + + schedule(); + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + disposed = true; + clearTimeout(timer); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [shouldRefreshWhileEmpty]); const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( + !refreshWhileEmpty && standalone && visibleEnvironments.length === 0 && environmentsState.refreshing && diff --git a/apps/web/src/components/color-selector.tsx b/apps/web/src/components/color-selector.tsx deleted file mode 100644 index d6898b009eeb..000000000000 --- a/apps/web/src/components/color-selector.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { cn } from "~/lib/utils"; - -interface ColorSelectorProps { - colors: string[]; - size?: "default" | "sm" | "lg"; - defaultValue: string; - name?: string; - onColorSelect?: (color: string) => void; - className?: string; -} - -const colorMap = { - default: "var(--contrast-foreground)", - red: "var(--color-red-500)", - green: "var(--color-green-500)", - blue: "var(--color-blue-500)", - yellow: "var(--color-yellow-500)", - purple: "var(--color-purple-500)", - pink: "var(--color-pink-500)", - indigo: "var(--color-indigo-500)", - orange: "var(--color-orange-500)", - teal: "var(--color-teal-500)", - cyan: "var(--color-cyan-500)", - lime: "var(--color-lime-500)", - emerald: "var(--color-emerald-500)", - violet: "var(--color-violet-500)", - fuchsia: "var(--color-fuchsia-500)", - rose: "var(--color-rose-500)", - sky: "var(--color-sky-500)", - amber: "var(--color-amber-500)", -} as const; - -function getSizeClass(size: "default" | "sm" | "lg") { - switch (size) { - case "sm": - return "size-4"; - case "default": - return "size-5"; - case "lg": - return "size-6"; - default: - return "size-5"; - } -} - -function getColorValue(color: string): string { - return colorMap[color as keyof typeof colorMap] || color; -} - -export function ColorSelector({ - colors, - size = "default", - defaultValue, - name, - onColorSelect, - className, -}: ColorSelectorProps) { - const [selectedColor, setSelectedColor] = useState(defaultValue); - - const handleColorSelect = (color: string) => { - setSelectedColor(color); - onColorSelect?.(color); - }; - - const sizeClass = getSizeClass(size); - - return ( -
    - {name && } - {colors.map((color) => { - const colorValue = getColorValue(color); - return ( -
    handleColorSelect(color)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleColorSelect(color); - } - }} - tabIndex={0} - role="button" - aria-label={`Select ${color} color`} - aria-pressed={selectedColor === color} - /> - ); - })} -
    - ); -} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 82ef5850cc1f..7c85ead29b27 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -104,6 +104,7 @@ describe("shouldUseRestingComposerLayout", () => { isScrollCollapsed: false, hasExpandedChrome: false, collapseOnBlur: true, + timelineOverflows: true, }; it("uses the resting layout for an unfocused desktop composer", () => { @@ -131,6 +132,17 @@ describe("shouldUseRestingComposerLayout", () => { ).toBe(true); }); + it("keeps the composer expanded while the timeline fits above it", () => { + expect(shouldUseRestingComposerLayout({ ...resting, timelineOverflows: false })).toBe(false); + expect( + shouldUseRestingComposerLayout({ + ...resting, + isScrollCollapsed: true, + timelineOverflows: false, + }), + ).toBe(false); + }); + it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index b7b7d91a033c..5dd6000dbc75 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,6 +1,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; -export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; +const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; export function getRestingComposerImagePreviewCounts(imageCount: number): { visibleCount: number; @@ -30,6 +30,8 @@ export function shouldUseRestingComposerLayout(input: { isScrollCollapsed: boolean; hasExpandedChrome: boolean; collapseOnBlur: boolean; + /** Whether the timeline has more content than fits above the composer. */ + timelineOverflows: boolean; }): boolean { // Passive draft content is deliberately absent here. Resting only clamps // the prompt row and overlays its actions; non-image attachment and context @@ -44,8 +46,18 @@ export function shouldUseRestingComposerLayout(input: { // the user asked for it with the gesture, and it lifts on the next // composer interaction. With blur collapse off, losing focus alone never // rests the composer. + // + // Resting exists to give reading space back to the timeline. A thread that + // fits above the composer has nothing to reclaim, so it stays expanded and + // never shows the collapsed row that a fresh thread would otherwise open on. const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); - return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; + return ( + input.isExistingThread && + !input.isMobileViewport && + input.timelineOverflows && + collapsed && + !input.hasExpandedChrome + ); } /** diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a258..3e941e69dd53 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index dd05693d28b2..fcc97825b681 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -12,7 +12,6 @@ import { isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; @@ -42,7 +41,6 @@ describe("desktop update button state", () => { status: "available", availableVersion: "1.1.0", }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); }); @@ -55,7 +53,6 @@ describe("desktop update button state", () => { errorContext: "download", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -70,7 +67,6 @@ describe("desktop update button state", () => { errorContext: "install", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); @@ -85,7 +81,6 @@ describe("desktop update button state", () => { errorContext: null, canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to restart and install"); }); @@ -111,7 +106,7 @@ describe("desktop update button state", () => { expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); - it("hides the button for non-actionable check errors", () => { + it("has no action for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, status: "error", @@ -119,7 +114,6 @@ describe("desktop update button state", () => { errorContext: "check", canRetry: true, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(false); expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); }); @@ -130,7 +124,6 @@ describe("desktop update button state", () => { availableVersion: "1.1.0", downloadPercent: 42.5, }; - expect(shouldShowDesktopUpdateButton(state)).toBe(true); expect(isDesktopUpdateButtonDisabled(state)).toBe(true); expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 656ffbea8198..4a169cb3ef40 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -47,16 +47,6 @@ export function resolveDesktopUpdateButtonAction( return "none"; } -export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { - if (!state || !state.enabled) { - return false; - } - if (state.status === "downloading") { - return true; - } - return resolveDesktopUpdateButtonAction(state) !== "none"; -} - export function shouldShowArm64IntelBuildWarning(state: DesktopUpdateState | null): boolean { return state?.hostArch === "arm64" && state.appArch === "x64"; } diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx deleted file mode 100644 index 81cd5625e62b..000000000000 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx new file mode 100644 index 000000000000..3a97c60254ab --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -0,0 +1,193 @@ +import type { CodeViewScrollTarget } from "@pierre/diffs"; +import type { FileTree as FileTreeModel } from "@pierre/trees"; +import { FileTree } from "@pierre/trees/react"; +import { act, type MouseEvent, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; + +vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +// Tooltip positioning is unrelated to the tree's actual model and activation path. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); + +const entries: DiffFileTreeEntry[] = [ + { path: "01-tall.ts", status: "modified" }, + { path: "02-short.ts", status: "modified" }, + { path: "03-medium.ts", status: "modified" }, +]; + +class TreeRow { + constructor(readonly path: string) {} + + getAttribute(name: string) { + return name === "data-item-path" ? this.path : null; + } +} + +describe("diff tree file activation", () => { + let renderer: ReactTestRenderer | undefined; + const targets: CodeViewScrollTarget[] = []; + const viewer = { + getInstance: () => viewer, + scrollTo: (target: CodeViewScrollTarget) => targets.push(target), + }; + + function Panel({ + files = entries, + selectedPath = null, + }: { + files?: DiffFileTreeEntry[]; + selectedPath?: string | null; + }) { + const reveal = useCodeViewFileReveal(viewer, "working-tree"); + return ( + reveal(`${path}\0${path}`)} + /> + ); + } + + const model = (): FileTreeModel => renderer!.root.findByType(FileTree).props.model; + + async function mount(props: Parameters[0] = {}) { + await act(async () => { + renderer = create(); + }); + } + + // Exercise T3's capture handler before the real Pierre model's selection transition. + // Only DOM hit testing is represented here; native pointer/keyboard dispatch and diff + // geometry are verified separately in the integrated client. + async function activate(path: string, modifiers: Partial> = {}) { + const event = { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + nativeEvent: { composedPath: () => [{}, new TreeRow(path), {}] }, + ...modifiers, + } as MouseEvent; + await act(async () => { + renderer!.root + .find((node) => String(node.type) === "file-tree-container") + .props.onClickCapture?.(event); + const tree = model(); + const item = tree.getItem(path)!; + if (event.ctrlKey || event.metaKey) { + item.toggleSelect(); + } else { + for (const selected of tree.getSelectedPaths()) { + if (selected !== path) tree.getItem(selected)?.deselect(); + } + item.select(); + } + item.focus(); + if ("toggle" in item && !event.ctrlKey && !event.metaKey && !event.shiftKey) item.toggle(); + }); + } + + beforeEach(() => { + targets.length = 0; + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("HTMLElement", TreeRow); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reissues the reveal when the sole selected file is activated again", async () => { + await mount(); + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + await activate("02-short.ts"); + expect(targets).toEqual([ + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + ]); + }); + + it("reveals newly selected files once in either direction", async () => { + await mount(); + await activate("02-short.ts"); + await activate("01-tall.ts"); + await activate("03-medium.ts"); + expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual( + ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`), + ); + }); + + it("keeps focus-only navigation separate from button activation", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.focus()); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + await activate("01-tall.ts", { detail: 0 }); + await activate("01-tall.ts", { detail: 0 }); + expect(targets).toHaveLength(3); + }); + + it.each(["ctrlKey", "metaKey"] as const)( + "does not reveal a selected file that a %s click deselects", + async (modifier) => { + await mount(); + await activate("02-short.ts"); + await activate("02-short.ts", { [modifier]: true }); + expect(model().getSelectedPaths()).toEqual([]); + expect(targets).toHaveLength(1); + }, + ); + + it("lets a click narrow multiple selected files without a second reveal", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.select()); + expect(model().getSelectedPaths()).toHaveLength(2); + targets.length = 0; + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + }); + + it("leaves directory selection and expansion to the tree", async () => { + await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); + const directory = model().getItem("src/")!; + if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); + expect(directory.isExpanded()).toBe(true); + await activate("src/"); + expect(directory.isExpanded()).toBe(false); + await activate("src/"); + expect(directory.isExpanded()).toBe(true); + expect(targets).toEqual([]); + }); + + it("does not echo controlled selection, but lets the reader activate it", async () => { + await mount({ selectedPath: "02-short.ts" }); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toEqual([]); + await activate("02-short.ts"); + expect(targets).toHaveLength(1); + await act(async () => { + renderer!.update(); + }); + expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]); + expect(targets).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 3715b62ca15a..0d200853bcb4 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -177,6 +177,29 @@ export function DiffFileTree({ { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + // Pierre does not emit a selection change for its sole selected row. + // Read selection before the row handles the click so new selections reveal only once. + const selected = model.getSelectedPaths(); + const path = selected.length === 1 ? selected[0] : undefined; + if (!path || !filePathsRef.current.has(path)) return; + const clickedSelectedRow = event.nativeEvent + .composedPath() + .some( + (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, + ); + if (clickedSelectedRow) onSelectFileRef.current(path); + }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index 6a746b40f9af..ee249888a415 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -18,13 +18,10 @@ import { useState, type Ref, } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, workers: [] as NodeWorkerThreads.Worker[], terminations: [] as Promise[], requests: [] as WorkerRequest[], @@ -102,8 +99,6 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), CodeView: (props: CodeViewProps) => { - testState.codeViewClassName = props.className ?? null; - testState.codeViewOptions = props.options ? { ...props.options } : null; return props.items?.map((item) => item.type === "file" ? : null, ); @@ -158,49 +153,6 @@ function renderViews(count: number) { ); } -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); - describe("code-view worker lifecycle", () => { let renderer: ReactTestRenderer | undefined; diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index 7c7bfe8e9ff9..4896c9ce2df0 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -1,5 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ArrowLeftIcon, ChevronRightIcon, LoaderCircleIcon, RotateCwIcon } from "lucide-react"; +import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { PierreEntryIcon } from "~/components/chat/PierreEntryIcon"; @@ -124,12 +126,12 @@ function BreadcrumbMenuContent(props: { {entriesQuery.isPending && entriesQuery.data === null ? ( - + Loading folder… ) : entriesQuery.error && entriesQuery.data === null ? ( - + Retry loading folder ) : !directoryAvailable && !entriesTruncated ? ( @@ -177,7 +179,7 @@ function BreadcrumbMenuContent(props: { <> - + Refresh failed — retry diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index dc3cfcef0137..49894db3c8cf 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ContextMenuItem as TreeContextMenuItem, ContextMenuOpenContext as TreeContextMenuOpenContext, @@ -5,7 +6,7 @@ import type { import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; -import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -16,7 +17,6 @@ import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; -import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; @@ -57,7 +57,7 @@ function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void } /> } > - + {props.isPending ? "Refreshing…" : "Refresh files"} @@ -373,7 +373,7 @@ export default function FileBrowserPanel({ data-file-browser-panel={`${environmentId}:${cwd}`} >
    diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3f0c92742ca7..b739d120da63 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ChatFileAttachment, EditorId, @@ -18,7 +19,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; -import { Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import { Code2, Eye, FolderTree, Globe2 } from "lucide-react"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -198,7 +199,7 @@ function WorkspaceImagePreview(props: {
    ) : (
    - +
    ); } @@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
    - +
    ); } @@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
    - +
    ); } @@ -1262,11 +1263,15 @@ export default function FilePreviewPanel({
    ) : relativePath && file.data === null ? (
    - +
    ) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( + // Markdown reconciles in place across text updates, so a file + // switch needs a new key or the previous file's disclosure and + // wrap state carries into the next document. { +describe("file cache identity", () => { it("changes for same-length edits", () => { - expect(fileContentRevision("nodeVersion")).not.toBe(fileContentRevision("nodeVeasdrs")); - }); - - it("keeps identical contents stable", () => { - expect(projectFileCacheKey("/repo", "file.json", "contents")).toBe( - projectFileCacheKey("/repo", "file.json", "contents"), + expect(projectFileCacheKey("/repo", "file.json", "nodeVersion")).not.toBe( + projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index e51d464925bd..b4e1698a34dc 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -1,4 +1,4 @@ -export function fileContentRevision(contents: string): string { +function fileContentRevision(contents: string): string { let hash = 2_166_136_261; for (let index = 0; index < contents.length; index += 1) { hash ^= contents.charCodeAt(index); diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts new file mode 100644 index 000000000000..39624d953c09 --- /dev/null +++ b/apps/web/src/components/files/fileEditorHighlight.test.ts @@ -0,0 +1,299 @@ +import { + FileRenderer, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, + type RenderRange, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + readonly themeType: "light" | "dark"; + tokenize(change: DocumentChange, range: RenderRange): Map; + cleanUp(): void; +} + +// This dependency-internal tokenizer is the one used by Editor.#rerender. +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: (lines: Map, theme: "light" | "dark") => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const source = Array.from( + { length: 7_000 }, + (_, index) => + `export const section${index} =

    Long wrapped source line ${index} for the file editor.

    ;`, +).join("\n"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + overflow: "wrap", + disableFileHeader: true, +} as const; +const range: RenderRange = { + startingLine: 6_950, + totalLines: 150, + bufferBefore: 0, + bufferAfter: 0, +}; + +interface HeldResponse { + data: WorkerResponse; + deliver: () => void; +} +let responses: HeldResponse[]; +let responseWaiters: ((response: HeldResponse) => void)[]; +let terminationPromises: Promise[]; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let tokenizer: Tokenizer; +let file: FileContents; +let document: TextDocument; +const animationFrames = new Set>(); + +function nextResponse(): Promise { + const response = responses.shift(); + return response + ? Promise.resolve(response) + : new Promise((resolve) => responseWaiters.push(resolve)); +} + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") { + this.worker.on("error", listener); + return; + } + this.worker.on("message", (data: WorkerResponse) => { + const response = { data, deliver: () => listener({ data }) }; + if (data.type !== "success" || data.requestType !== "file") { + response.deliver(); + return; + } + const waiter = responseWaiters.shift(); + if (waiter) waiter(response); + else responses.push(response); + }); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function applyChange(change: DocumentChange) { + // Keep the installed editor's non-DOM order, including the existing contents patch. + renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType); + file.contents = document.getText(); + if (change.lineDelta !== 0) renderer.applyDocumentChange(document); +} + +function append(text: string) { + const position = document.positionAt(document.getText().length); + const change = document.applyEdits([ + { range: { start: position, end: position }, newText: text }, + ]); + expect(change).toBeDefined(); + applyChange(change!); +} + +function undo() { + const change = document.undo()?.[0]; + expect(change).toBeDefined(); + applyChange(change!); +} + +function renderContents() { + const result = renderer.renderFile(file, range); + expect(result?.totalLines).toBe(document.lineCount); + return renderer.renderFullHTML(result!); +} + +beforeEach(async () => { + responses = []; + responseWaiters = []; + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frame = setImmediate(() => { + animationFrames.delete(frame); + callback(0); + }); + animationFrames.add(frame); + return frame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: ReturnType) => { + animationFrames.delete(frame); + clearImmediate(frame); + }); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + pool = new WorkerPoolManager( + // Adapt browser transport only; Pierre's real worker produces each response. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(["tsx"]); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["tsx"], + preferredHighlighter: "shiki-wasm", + }); + file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" }; + document = new TextDocument(file.name, source, "tsx"); + renderer = new FileRenderer(options, () => {}, pool); + tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme), + }); + renderContents(); +}); + +async function cleanUpFixture() { + tokenizer?.cleanUp(); + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + // Pool termination can queue a final broadcast after its workers have exited. + for (const frame of animationFrames) clearImmediate(frame); + animationFrames.clear(); + vi.unstubAllGlobals(); +} + +afterEach(cleanUpFixture); + +describe("editable file highlighting", () => { + it("cleans up an already terminated worker pool", async () => { + (await nextResponse()).deliver(); + expect(pool.getStats().totalWorkers).toBe(1); + pool.terminate(); + await Promise.all(terminationPromises); + expect(pool.getStats().totalWorkers).toBe(0); + + const animationFrame = globalThis.requestAnimationFrame; + const cancelFrame = globalThis.cancelAnimationFrame; + const window = globalThis.window; + try { + await cleanUpFixture(); + // Deliver the real Immediate queue after cleanup has removed the browser globals. + await new Promise((resolve) => setImmediate(resolve)); + } finally { + vi.stubGlobal("requestAnimationFrame", animationFrame); + vi.stubGlobal("cancelAnimationFrame", cancelFrame); + vi.stubGlobal("window", window); + } + }); + + it("still accepts an asynchronous highlight when the file has not changed", async () => { + expect(renderContents()).not.toContain('style="color:'); + (await nextResponse()).deliver(); + expect(renderContents()).toContain('style="color:'); + expect(pool.getFileResultCache(file)).toBeDefined(); + }); + + it.each([1, 60])( + "ignores a dispatched highlight after %i Enter edits and highlights the new version", + async (count) => { + const oldResponse = await nextResponse(); + for (let index = 0; index < count; index += 1) append("\n"); + append("export const EDITED_MARKER = 1;"); + oldResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + const currentResponse = await nextResponse(); + currentResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); + expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); + expect(document.lineCount).toBe(7_000 + count); + }, + ); + + it("does not replace a same-line edit with stale tokens", async () => { + const oldResponse = await nextResponse(); + append(" EDITED_MARKER"); + oldResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + expect(document.lineCount).toBe(7_000); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + }); + + it("keeps undo edits after an older highlight arrives", async () => { + const oldResponse = await nextResponse(); + append("\nexport const RETAINED_MARKER = 1;"); + append("\nexport const UNDONE_MARKER = 2;"); + undo(); + oldResponse.deliver(); + const html = renderContents(); + expect(html).toContain("RETAINED_MARKER"); + expect(html).not.toContain("UNDONE_MARKER"); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("RETAINED_MARKER"); + undo(); + expect(document.getText()).toBe(source); + expect(renderContents()).not.toContain("RETAINED_MARKER"); + const redone = document.redo()?.[0]; + expect(redone).toBeDefined(); + applyChange(redone!); + expect(renderContents()).toContain("RETAINED_MARKER"); + }); + + it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => { + (await nextResponse()).deliver(); + expect(pool.getFileResultCache(file)).toBeDefined(); + append("\nexport const EDITED_MARKER = 1;"); + expect(pool.getFileResultCache(file)).toBeUndefined(); + expect(renderContents()).toContain("EDITED_MARKER"); + const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); + expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); + }); + + it("reopens the edited file with the same cache key while an old response is pending", async () => { + const oldResponse = await nextResponse(); + append("\nexport const REOPENED_MARKER = 1;"); + renderer.cleanUp(); + renderer = new FileRenderer(options, () => {}, pool); + file = { ...file }; + expect(renderContents()).toContain("REOPENED_MARKER"); + oldResponse.deliver(); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("REOPENED_MARKER"); + expect(renderContents()).toContain('style="color:'); + }); +}); diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts new file mode 100644 index 000000000000..520c0fa82d0e --- /dev/null +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -0,0 +1,186 @@ +import { + FileRenderer, + disposeHighlighter, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + tokenize(change: DocumentChange): Map; + cleanUp(): void; +} + +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: () => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, +} as const; +const source = "export const View = () =>
    Ready
    ;"; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let terminationPromises: Promise[]; + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") this.worker.on("error", listener); + else this.worker.on("message", (data: WorkerResponse) => listener({ data })); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { + const document = new TextDocument(file.name, file.contents, language); + const tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: () => {}, + }); + try { + const end = document.positionAt(file.contents.length); + const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); + expect(change).toBeDefined(); + // This is the synchronous first edit, before the tokenizer's debounced prebuild. + const dirtyLines = tokenizer.tokenize(change!); + expect([...dirtyLines.keys()]).toEqual([0, 1]); + expect(document.getText()).toBe(`${file.contents}\n`); + } finally { + tokenizer.cleanUp(); + } +} + +beforeEach(async () => { + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + await disposeHighlighter(); + pool = new WorkerPoolManager( + // Adapt transport only. The installed Pierre worker resolves and highlights the file. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(); + renderer = new FileRenderer(options, undefined, pool); +}); + +afterEach(async () => { + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + await disposeHighlighter(); + vi.unstubAllGlobals(); +}); + +describe("editable file language readiness", () => { + it.each(["hydrate", "renderFile"] as const)( + "%s prepares the inferred language before the first edit of a worker-highlighted file", + async (method) => { + const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; + await pool.primeFileHighlightCache(file); + expect(pool.getFileResultCache(file)).toBeDefined(); + const mainHighlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + }); + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + renderer[method](file); + // Read-only worker rendering must not load editor grammars on the main thread. + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "tsx"); + }, + ); + + it.each(["hydrate", "renderFile"] as const)( + "%s respects an explicit language when the filename suggests plain text", + async (method) => { + const file: FileContents = { + name: "source.txt", + lang: "tsx", + contents: source, + cacheKey: "explicit-tsx", + }; + await pool.primeFileHighlightCache(file); + renderer[method](file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }, + ); + + it("loads a newly opened language after reusing a worker-backed renderer", async () => { + const previousFile: FileContents = { + name: "previous.ts", + contents: "export const value = 1;", + cacheKey: "previous-ts", + }; + await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); + renderer.renderFile(previousFile); + firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); + const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; + renderer.renderFile(nextFile); + firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); + }); + + it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { + renderer.cleanUp(); + renderer = new FileRenderer(options); + const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; + renderer.hydrate(file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }); + + it("keeps plain text editable without loading an unrelated grammar", async () => { + const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; + renderer.renderFile(file); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "text"); + expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); + }); +}); diff --git a/apps/web/src/components/files/fileEditorVirtualization.test.ts b/apps/web/src/components/files/fileEditorVirtualization.test.ts new file mode 100644 index 000000000000..cf293dd47254 --- /dev/null +++ b/apps/web/src/components/files/fileEditorVirtualization.test.ts @@ -0,0 +1,723 @@ +import { + getSharedHighlighter, + VirtualizedFile, + Virtualizer, + type FileContents, +} from "@pierre/diffs"; +import { Editor, TextDocument } from "@pierre/diffs/editor"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const renderingManagerUrl = new URL( + "./managers/UniversalRenderingManager.js", + import.meta.resolve("@pierre/diffs"), +); +const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as { + clearRenderQueue(): void; +}; + +// Layout measurements are controlled here. The real reconciler, document and +// renderer calculate positions. This does not simulate native CSS wrapping. +class MeasuredElement { + static geometryReads = 0; + children: MeasuredElement[] = []; + dataset: Record = {}; + nextElementSibling: MeasuredElement | null = null; + width = 283; + + constructor(readonly height = 0) {} + + getBoundingClientRect() { + MeasuredElement.geometryReads += 1; + return { top: 0, height: this.height, width: this.width }; + } +} + +class MeasuredCodeElement extends MeasuredElement { + readonly tagName = "CODE"; + + get firstElementChild() { + return this.children[0] ?? null; + } +} + +const observers: RecordedResizeObserver[] = []; +const animationFrames = new Map(); +let nextFrameId = 0; + +class RecordedResizeObserver { + readonly targets = new Set(); + + constructor(readonly callback: ResizeObserverCallback) { + observers.push(this); + } + + observe(target: Element) { + this.targets.add(target); + } + + unobserve(target: Element) { + this.targets.delete(target); + } + + disconnect() { + this.targets.clear(); + } + + deliver(target: HTMLElement) { + const rect = target.getBoundingClientRect(); + const size = { inlineSize: rect.width, blockSize: rect.height }; + this.callback( + [ + { + target, + contentRect: rect, + contentBoxSize: [size], + borderBoxSize: [size], + devicePixelContentBoxSize: [size], + }, + ], + this as unknown as ResizeObserver, + ); + } +} + +function drainRenderFrames() { + const errors = vi.spyOn(console, "error"); + try { + for (let frame = 0; animationFrames.size > 0; frame += 1) { + if (frame === 10) throw new Error("The production render queue did not settle"); + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(frame); + } + expect(errors).not.toHaveBeenCalled(); + } finally { + errors.mockRestore(); + } +} + +function measuredElement(element: MeasuredElement): HTMLElement { + return element as unknown as HTMLElement; +} + +class LayoutVirtualizer extends Virtualizer { + override getOffsetInScrollContainer(_element: HTMLElement) { + return 0; + } +} + +class MeasuredFile extends VirtualizedFile { + override top = 0; + + override attachEditor(editor: Parameters[0]) { + this.editor = editor; + return () => { + this.editor = undefined; + }; + } + + async initialize(file: FileContents) { + this.prepareCodeViewItem(file, 0); + await this.fileRenderer.initializeHighlighter(); + expect( + this.fileRenderer.renderFile(file, { + startingLine: 5950, + totalLines: 51, + bufferBefore: 0, + bufferAfter: 0, + }), + ).toBeDefined(); + this.fileContainer = measuredElement(new MeasuredElement()); + } + + measure( + rows: ReadonlyArray, + contentWidth = 226.25, + ) { + const content = new MeasuredElement(); + content.width = contentWidth; + content.children = rows.map(([lineIndex, height]) => { + const row = new MeasuredElement(height); + row.dataset.lineIndex = String(lineIndex); + return row; + }); + const code = new MeasuredCodeElement(); + code.width = contentWidth + 56.75; + code.children = [new MeasuredElement(), content]; + this.code = measuredElement(code); + this.reconcileHeights(); + } + + resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) { + const code = this.code; + const content = code?.children[1]; + if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) { + throw new Error("Expected measured code and content"); + } + code.width = codeWidth; + content.width = contentWidth; + } + + observeLayout() { + const pre = new MeasuredElement(); + if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code"); + pre.children = [this.code]; + this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, { + disableAnnotations: true, + columnVariables: "measure", + }); + const code = this.code; + const observer = observers.find((candidate) => candidate.targets.has(code)); + if (observer === undefined) throw new Error("The real resize manager did not observe code"); + return () => observer.deliver(code); + } + + dispose() { + this.fileContainer = undefined; + this.code = undefined; + this.cleanUp(); + } +} + +const instances: MeasuredFile[] = []; +const editors: Editor[] = []; + +beforeAll(async () => { + await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); +}); + +beforeEach(() => { + observers.length = 0; + animationFrames.clear(); + MeasuredElement.geometryReads = 0; + vi.stubGlobal("HTMLElement", MeasuredElement); + vi.stubGlobal("Document", MeasuredElement); + vi.stubGlobal("ResizeObserver", RecordedResizeObserver); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const id = ++nextFrameId; + animationFrames.set(id, callback); + return id; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id)); +}); + +afterEach(() => { + for (const editor of editors.splice(0)) editor.cleanUp(); + for (const instance of instances.splice(0)) instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + vi.unstubAllGlobals(); +}); + +async function makeFixture( + overflow: "wrap" | "scroll" = "wrap", + lineCount = 6001, + contentWidth = 226.25, +) { + const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n"); + const file: FileContents = { + name: "wrapped.txt", + contents, + cacheKey: `wrapped:${overflow}`, + lang: "text", + }; + const document = new TextDocument(file.name, contents, "text"); + const instance = new MeasuredFile( + { + overflow, + disableFileHeader: true, + theme: "pierre-dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + controlledSelection: true, + }, + new LayoutVirtualizer(), + ); + instances.push(instance); + await instance.initialize(file); + instance.measure( + [ + [0, 80], + [120, 60], + [4999, 100], + [5000, 80], + [5999, 100], + [6000, 60], + ], + contentWidth, + ); + const apply = (change: { startLine: number } | undefined, passStartLine = true) => { + if (change === undefined) throw new Error("Expected a document change"); + file.contents = document.getText(); + instance.applyDocumentChange( + document, + undefined, + false, + passStartLine ? change.startLine : undefined, + ); + }; + const append = () => { + const position = document.positionAt(document.getText().length); + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + }; + return { instance, document, file, apply, append }; +} + +describe("wrapped editor document changes", () => { + it("preserves the position above an EOF insertion across layout checkpoints", async () => { + const { instance, document, append } = await makeFixture(); + const previousLastLine = document.lineCount; + const before = instance.getLinePosition(previousLastLine); + expect(before).toEqual({ top: 120328, height: 60 }); + const viewport = { top: before!.top - 100, bottom: before!.top + 80 }; + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2156 }); + + append(); + + expect(document.lineCount).toBe(previousLastLine + 1); + expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 }); + expect(instance.getVirtualizedHeight()).toBe(120376); + expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2136 }); + }); + + it("invalidates changed and shifted rows after an insertion in the middle", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5001); + const position = { line: 5000, character: 2 }; + apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }])); + + expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(4999)).toBe(100); + expect(instance.getLineHeight(5000)).toBe(20); + expect(instance.getLineHeight(5999)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 }); + }); + + it("keeps preceding measurements when a deletion crosses a checkpoint", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(5000); + apply( + document.applyEdits([ + { + range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } }, + newText: "", + }, + ]), + ); + + expect(document.lineCount).toBe(5999); + expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLineHeight(4999)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(20); + expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 }); + }); + + it("uses the earliest changed line for edits at multiple selections", async () => { + const { instance, document, apply } = await makeFixture(); + const before = instance.getLinePosition(121); + apply( + document.applyEdits( + [120, 5000].map((line) => ({ + range: { start: { line, character: 2 }, end: { line, character: 2 } }, + newText: "\n", + })), + ), + ); + + expect(document.lineCount).toBe(6003); + expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 }); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(4999)).toBe(20); + }); + + it("retains the unchanged prefix through repeated Enter, undo and redo", async () => { + const { instance, document, apply, append } = await makeFixture(); + const before = instance.getLinePosition(6001); + for (let count = 0; count < 60; count += 1) append(); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.undo()?.[0]); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + apply(document.redo()?.[0]); + expect(document.lineCount).toBe(6061); + expect(instance.getLinePosition(6001)?.top).toBe(before!.top); + }); + + it("keeps unwrapped positions unchanged", async () => { + const { instance, append } = await makeFixture("scroll"); + const before = instance.getLinePosition(6001); + append(); + expect(instance.getLinePosition(6001)).toEqual(before); + expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 }); + }); + + it("fully invalidates measurements when the first changed line is unknown", async () => { + const { instance, document, apply } = await makeFixture(); + const position = document.positionAt(document.getText().length); + apply( + document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]), + false, + ); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it("still discards all measured rows after a metric change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 }); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 }); + }); + + it("still discards all measured rows when annotations change", async () => { + const { instance, file, append } = await makeFixture(); + append(); + instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]); + instance.prepareCodeViewItem(file, 0); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }); +}); + +describe("wrapped measurement widths", () => { + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "drops offscreen measurements when content changes from %spx to %spx", + async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLineHeight(6000)).toBe(60); + expect(instance.getVirtualizedHeight()).toBe(120076); + }, + ); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])( + "does not retain old-width prefix heights after a %spx to %spx resize and edit", + async (before, after) => { + const { instance, append } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 60]], after); + append(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(120)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 }); + }, + ); + + it("repairs an edit before resize delivery when the real resize and render queues drain", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.25); + const readsBeforeEdit = MeasuredElement.geometryReads; + append(); + expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit); + // No synchronous geometry read: the resize entry owns invalidation. + expect(instance.getLineHeight(0)).toBe(80); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 }); + }); + + it("keeps measured prefixes through same-width reconciliation and editing", async () => { + const { instance, append } = await makeFixture(); + instance.measure([[6000, 60]]); + append(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(120)).toBe(60); + expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 }); + }); + + it("preserves measurements on first and repeated same-width resize deliveries", async () => { + const { instance } = await makeFixture(); + const before = instance.getVirtualizedHeight(); + const deliverResize = instance.observeLayout(); + deliverResize(); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getVirtualizedHeight()).toBe(before); + }); + + it.each([ + [226.25, 482.25], + [482.25, 226.25], + ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => { + const { instance } = await makeFixture("wrap", 6001, before); + instance.measure([[6000, 20]], before); + const deliverResize = instance.observeLayout(); + instance.resizeContent(after); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("keeps width validity when a new editor attaches to the same file", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + const first = new Editor(); + editors.push(first); + first.edit(instance); + first.cleanUp(); + const second = new Editor(); + editors.push(second); + second.edit(instance); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); + + it("ignores stale resize deliveries after cleanup", async () => { + const { instance } = await makeFixture(); + const deliverResize = instance.observeLayout(); + instance.dispose(); + clearRenderQueue(); + animationFrames.clear(); + deliverResize(); + expect(animationFrames.size).toBe(0); + }); + + it("does not discard a stable code width for gutter subpixel rounding", async () => { + const { instance } = await makeFixture("wrap", 6001, 482.25); + const deliverResize = instance.observeLayout(); + instance.resizeContent(482.234375, 539); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + }); + + it("waits for a visible width instead of caching measurements while hidden", async () => { + const { instance } = await makeFixture(); + instance.measure([[6000, 20]]); + const deliverResize = instance.observeLayout(); + instance.resizeContent(0, 0); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(80); + instance.resizeContent(482.25); + deliverResize(); + drainRenderFrames(); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getVirtualizedHeight()).toBe(120036); + }); +}); + +// Supply inert DOM transport so public Editor edits execute its real tokenizer +// and layout handoff. No native wrapping, observer delivery or scrolling is modeled. +class EditorElement extends MeasuredElement { + style: Record = {}; + parentElement: EditorElement | null = null; + + appendChild(child: EditorElement) { + child.parentElement = this; + this.children.push(child); + return child; + } + + prepend(child: EditorElement) { + child.parentElement = this; + this.children.unshift(child); + } + + replaceChildren(...children: (EditorElement | string)[]) { + this.children = []; + for (const child of children) if (typeof child !== "string") this.appendChild(child); + } + + setAttribute() {} + removeAttribute() {} + addEventListener() {} + removeEventListener() {} + after() {} + + remove() { + if (this.parentElement) { + this.parentElement.children = this.parentElement.children.filter((child) => child !== this); + } + } + + set innerHTML(value: string) { + expect(value.startsWith(" "code" in child.dataset); + } + + querySelector(selector: string) { + expect(selector).toBe("[data-deletions]"); + return null; + } + + getContext() { + return { measureText: (text: string) => ({ width: text.length * 8 }) }; + } +} + +async function makeEditorFixture(lineCount: number) { + const { instance, file } = await makeFixture("wrap", lineCount); + vi.stubGlobal("SVGSVGElement", EditorElement); + vi.stubGlobal("Document", EditorElement); + vi.stubGlobal( + "document", + Object.assign(new EditorElement(), { createElement: () => new EditorElement() }), + ); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("getComputedStyle", () => ({ + paddingTop: "0px", + fontSize: "13px", + fontFamily: "monospace", + tabSize: "2", + lineHeight: "20px", + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + instance.setOptions({ + ...instance.options, + useTokenTransformer: true, + controlledSelection: true, + themeType: "dark", + }); + const content = new EditorElement(); + content.dataset.content = ""; + const gutter = new EditorElement(); + gutter.dataset.gutter = ""; + const code = new EditorElement(); + code.dataset.code = ""; + code.appendChild(gutter); + code.appendChild(content); + const shadow = new EditorElement(); + shadow.appendChild(code); + const host = Object.assign(new EditorElement(), { shadowRoot: shadow }); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + preferredHighlighter: "shiki-wasm", + }); + const editor = new Editor(); + editors.push(editor); + editor.edit(instance); + editor.__syncRenderView(highlighter, measuredElement(host), file, undefined, { + startingLine: 0, + totalLines: 1, + bufferBefore: 0, + bufferAfter: 0, + }); + const append = (count: number) => { + const lines = editor.getText().split("\n"); + const end = { line: lines.length - 1, character: lines.at(-1)!.length }; + editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]); + }; + const remove = (count: number) => { + const lines = editor.getText().split("\n"); + const startLine = lines.length - count - 1; + editor.applyEdits([ + { + range: { + start: { line: startLine, character: lines[startLine]!.length }, + end: { line: lines.length - 1, character: lines.at(-1)!.length }, + }, + newText: "", + }, + ]); + }; + return { instance, editor, append, remove }; +} + +describe("editor gutter-width changes", () => { + it.each([ + [9999, 1], + [9998, 3], + ])( + "clears prefix measurements when %i lines grow by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + expect(instance.getLineHeight(0)).toBe(80); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it.each([ + [10000, 1], + [10002, 4], + ])( + "clears prefix measurements when %i lines shrink by %i across a digit boundary", + async (lines, count) => { + const { instance, editor, remove } = await makeEditorFixture(lines); + remove(count); + expect(editor.getText().split("\n")).toHaveLength(lines - count); + expect(instance.getLineHeight(0)).toBe(20); + expect(instance.getLineHeight(5000)).toBe(20); + }, + ); + + it("clears newly measured prefixes on undo and redo across a digit boundary", async () => { + const { instance, editor, append } = await makeEditorFixture(9999); + append(1); + instance.measure([[0, 100]]); + editor.undo(); + expect(editor.getText().split("\n")).toHaveLength(9999); + expect(instance.getLineHeight(0)).toBe(20); + instance.measure([[0, 80]]); + editor.redo(); + expect(editor.getText().split("\n")).toHaveLength(10000); + expect(instance.getLineHeight(0)).toBe(20); + }); + + it.each([ + [9998, 1], + [10000, 2], + ])( + "retains prefix measurements when %i lines grow by %i without changing digit width", + async (lines, count) => { + const { instance, editor, append } = await makeEditorFixture(lines); + append(count); + expect(editor.getText().split("\n")).toHaveLength(lines + count); + expect(instance.getLineHeight(0)).toBe(80); + expect(instance.getLineHeight(5000)).toBe(80); + editor.undo(); + expect(instance.getLineHeight(0)).toBe(80); + editor.redo(); + expect(instance.getLineHeight(0)).toBe(80); + }, + ); +}); diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 3018aa91a776..b501562fee00 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -67,8 +67,15 @@ describe("fileBreadcrumbChildren", () => { ]); }); - it("uses natural file-name ordering", () => { - expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([ + it("uses natural file-name ordering and preserves input order for equivalent names", () => { + const files = ["file10.ts", "File2.ts", "file02.ts", "file2.ts"].map((name) => ({ + path: `src/lib/${name}`, + kind: "file" as const, + })); + + expect(fileBreadcrumbChildren(files, "src/lib").map((entry) => entry.label)).toEqual([ + "File2.ts", + "file02.ts", "file2.ts", "file10.ts", ]); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index aea8266ec8b8..e819315310b8 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -36,6 +36,7 @@ export function fileBreadcrumbChildren( entries: readonly ProjectEntry[], directoryPath: string, ): FileBreadcrumbChild[] { + let collator: Intl.Collator | undefined; const prefix = directoryPath ? `${directoryPath}/` : ""; return entries .flatMap((entry) => { @@ -46,10 +47,11 @@ export function fileBreadcrumbChildren( }) .toSorted((left, right) => { if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; - return left.label.localeCompare(right.label, undefined, { + collator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "base", }); + return collator.compare(left.label, right.label); }); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d02ec99605ba..a12772920956 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,7 +33,7 @@ interface ProjectQueryState
    { readonly refresh: () => void; } -export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { +function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index cf79c81b9f60..d6c5cca70153 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -33,7 +33,7 @@ function mediaFileName(source: MediaActionSource): string { } /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ -export function useMediaActions(source: MediaActionSource) { +function useMediaActions(source: MediaActionSource) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000000..6debb0376bd9 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,244 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useAtomValue } from "@effect/atom-react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useLayoutEffect, useState } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettings, + useClientSettingsHydrationStatus, +} from "../../hooks/useSettings"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, + type FirstRunGateState, +} from "../../onboarding/firstRun.logic"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { environmentProjects } from "../../state/projects"; +import { primaryServerConfigAtom, primaryServerWelcomeAtom } from "../../state/server"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadShells } from "../../state/threads"; +import { Button } from "../ui/button"; + +/** + * Holds back authenticated and hosted app trees until the first-run decision + * is known, so a fresh install never flashes the main screen before the wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. + * Hosted mode instead checks its saved environment catalog. A timeout shows + * recovery for an unreachable primary server without mounting the app tree. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +const primaryShellLiveAtom = Atom.make((get) => { + const serverConfig = get(primaryServerConfigAtom); + return ( + serverConfig !== null && + get(environmentShell.stateValueAtom(serverConfig.environment.environmentId)).status === "live" + ); +}).pipe(Atom.withLabel("web-onboarding-primary-shell-live")); + +const workspaceEvidenceLiveAtom = Atom.make((get) => { + const environmentIds = new Set([ + ...get(environmentProjects.projectsAtom).map((project) => project.environmentId), + ...get(environmentThreadShells.threadShellsAtom).map((thread) => thread.environmentId), + ]); + + for (const environmentId of environmentIds) { + if (get(environmentShell.stateValueAtom(environmentId)).status !== "live") { + return false; + } + } + + return true; +}).pipe(Atom.withLabel("web-onboarding-workspace-evidence-live")); + +export function FirstRunGate({ + enabled, + hostedStatic, + children, +}: { + readonly enabled: boolean; + readonly hostedStatic: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hydrationStatus = useClientSettingsHydrationStatus(); + const hydrated = hydrationStatus === "ready"; + const completeOnboarding = useCompleteOnboarding(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const { environments, isReady: environmentCatalogReady } = useEnvironments(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); + const primaryShellLive = useAtomValue(primaryShellLiveAtom); + const workspaceEvidenceLive = useAtomValue(workspaceEvidenceLiveAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [gateState, setGateState] = useState(() => ({ + decision: + (!enabled && !hostedStatic) || (hydrated && onboardingCompletedAt !== null) + ? "app" + : "pending", + stalled: false, + })); + const { decision, stalled } = gateState; + const settingsReadFailed = hydrationStatus === "failed" || hydrationStatus === "retrying"; + const ownsOnboardingTheme = settingsReadFailed || stalled || decision === "wizard"; + + useLayoutEffect(() => { + if (!ownsOnboardingTheme) return; + return mountOnboardingTheme(); + }, [ownsOnboardingTheme]); + + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, more than one thread, or state in a non-primary + // environment is real user state — the aggregate hooks span every + // environment, and a saved remote's project must never read as "the + // bootstrap project" just because its root string matches the primary cwd. + const serverCwd = serverConfig?.cwd ?? null; + const primaryEnvironmentId = serverConfig?.environment.environmentId ?? null; + const workspaceFresh = isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd, + bootstrapProjectId: serverWelcome?.bootstrapProjectId, + bootstrapThreadId: serverWelcome?.bootstrapThreadId, + bootstrapProjectCreated: serverWelcome?.bootstrapProjectCreated, + bootstrapThreadCreated: serverWelcome?.bootstrapThreadCreated, + projects, + threads, + }); + + const { decision: nextDecision, persistCompletion } = hostedStatic + ? resolveHostedFirstRunDecision({ + hydrated, + completed: onboardingCompletedAt !== null, + catalogReady: environmentCatalogReady, + environmentCount: environments.length, + }) + : resolveFirstRunDecision({ + enabled, + hydrated, + completed: onboardingCompletedAt !== null, + bootstrapped, + authoritative: primaryShellLive, + workspaceAuthoritative: workspaceEvidenceLive, + workspaceProvenanceAuthoritative: isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: serverWelcome !== null, + bootstrapStatus: serverWelcome?.bootstrapStatus ?? null, + }), + catalogReady: environmentCatalogReady, + serverConfigAvailable: serverConfig !== null, + workspaceFresh, + projectCount: projects.length, + threadCount: threads.length, + }); + + useEffect(() => { + if (decision === "wizard" || !hydrated) return; + + if (persistCompletion && onboardingCompletedAt === null) { + void completeOnboarding().catch(() => undefined); + } + + setGateState((state) => + transitionFirstRunGateState(state, { type: "evidence", decision: nextDecision }), + ); + }, [ + completeOnboarding, + decision, + hydrated, + nextDecision, + onboardingCompletedAt, + persistCompletion, + ]); + + // A stalled server read gets a recovery screen, but never mounts the app. + // The timer starts after settings hydrate so slow local hydration does not + // show a false connection failure. + useEffect(() => { + if (!enabled || decision !== "pending" || !hydrated) return; + const timer = window.setTimeout( + () => setGateState((state) => transitionFirstRunGateState(state, { type: "timeout" })), + FIRST_RUN_DECISION_TIMEOUT_MS, + ); + return () => window.clearTimeout(timer); + }, [decision, enabled, hydrated]); + + useEffect(() => { + if (decision === "wizard" && pathname !== "/welcome") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate, pathname]); + + if (settingsReadFailed) { + return ; + } + if (decision !== "app") { + return stalled ? : null; + } + return children; +} + +function FirstRunRecovery({ + reason, + retrying = false, +}: { + readonly reason: "settings" | "connection"; + readonly retrying?: boolean; +}) { + const settingsReadFailed = reason === "settings"; + return ( +
    +
    +

    + {settingsReadFailed ? "Could not read settings" : "Still connecting"} +

    +

    + {settingsReadFailed + ? "Your saved settings could not be loaded." + : "T3 Code could not confirm this workspace."} +

    + +
    +
    + ); +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000000..62665aa86ebf --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1473 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ProjectId, + ScopedProjectRef, + ServerConfig, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { CommandId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { + ArrowRightIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CloudIcon, + CopyIcon, + LinkIcon, + MonitorIcon, + TerminalIcon, + type LucideIcon, +} from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { mountOnboardingTheme } from "../../hooks/useTheme"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "../../onboarding/projectImport.logic"; +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "../../onboarding/providerReadiness.logic"; +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "../../onboarding/targetEnvironment.logic"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { newProjectId, randomUUID } from "../../lib/utils"; +import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; +import { readProjects, useProjects } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { isElectron } from "../../env"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Input } from "../ui/input"; +import { toastManager } from "../ui/toast"; +import { cn } from "../../lib/utils"; + +/** + * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; + +type ConnectionMode = "local" | "connect" | "direct"; + +/** + * The machine the agent and import steps run against. Local mode targets the + * primary environment; the remote modes prefer the machine the user just + * connected (the most recently added connected non-primary environment), so + * probing and import happen where their code lives rather than on the local + * server that happens to serve the app. Deliberately not a persisted + * "primary machine" concept — just whichever machine fits the chosen path + * right now, labeled inline on each step. + */ +function useOnboardingTargetEnvironment( + mode: ConnectionMode, + pairedEnvironmentId: EnvironmentId | null, +) { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + return resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, + }); +} + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const ONBOARDING_STAGES = ["Connect", "Agents", "Projects"] as const; +const SCAN_LIMIT_MESSAGE = "Scan limit reached. Some projects or conversations may be missing."; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** + * Whether the "Local Only" card is offered. True whenever the app is served + * by an authenticated primary server — desktop, `npx t3`, or a dev server — + * since that server is "this machine" regardless of the hostname the app + * was opened from. Only hosted-static (app.t3.codes) has no local server. + */ + readonly localAvailable: boolean; + readonly onDone: (projectRef?: ScopedProjectRef) => void; +}) { + useLayoutEffect(() => mountOnboardingTheme(), []); + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const [mode, setMode] = useState("local"); + const [pairedEnvironmentId, setPairedEnvironmentId] = useState(null); + const finishingPromiseRef = useRef | null>(null); + const completionErrorToastIdRef = useRef | null>(null); + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0; + const finish = useCallback( + (projectRef?: ScopedProjectRef) => { + if (finishingPromiseRef.current !== null) return finishingPromiseRef.current; + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + + const completion = completeOnboarding() + .then(() => { + if (completionErrorToastIdRef.current !== null) { + toastManager.close(completionErrorToastIdRef.current); + completionErrorToastIdRef.current = null; + } + onDone(projectRef); + return true; + }) + .catch(() => { + const errorToast = { + type: "error", + title: "Could not finish setup", + description: "Your settings could not be saved. Try again.", + } as const; + if (completionErrorToastIdRef.current === null) { + completionErrorToastIdRef.current = toastManager.add(errorToast); + } else { + toastManager.update(completionErrorToastIdRef.current, errorToast); + } + return false; + }) + .finally(() => { + if (finishingPromiseRef.current === completion) { + finishingPromiseRef.current = null; + } + }); + finishingPromiseRef.current = completion; + return completion; + }, + [completeOnboarding, onDone], + ); + + return ( +
    + {isElectron ? ( +
    + ) : null} +
    +
    + + +
    + {step === "connection" ? ( + { + setMode("local"); + setPairedEnvironmentId(null); + setStep("agents"); + }} + onConnect={() => { + setMode("connect"); + setPairedEnvironmentId(null); + setStep("connect-machines"); + }} + onDirect={() => { + setMode("direct"); + setPairedEnvironmentId(null); + setStep("pair-direct"); + }} + /> + ) : step === "connect-machines" ? ( + setStep("connection")} + onContinue={() => setStep("agents")} + /> + ) : step === "pair-direct" ? ( + setStep("connection")} + onPaired={(environmentId) => { + setPairedEnvironmentId(environmentId); + setStep("agents"); + }} + /> + ) : step === "agents" ? ( + + setStep( + mode === "local" + ? "connection" + : mode === "connect" + ? "connect-machines" + : "pair-direct", + ) + } + onContinue={() => setStep("import")} + onSkip={() => setStep("import")} + /> + ) : ( + setStep("agents")} + onDone={finish} + /> + )} +
    +
    +
    +
    + ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + localAvailable, + localLabel, + onLocal, + onConnect, + onDirect, +}: { + readonly localAvailable: boolean; + readonly localLabel: string; + readonly onLocal: () => void; + readonly onConnect: () => void; + readonly onDirect: () => void; +}) { + const cloudEnabled = hasCloudPublicConfig(); + const [choice, setChoice] = useState<"local" | "connect" | "direct">( + localAvailable ? "local" : cloudEnabled ? "connect" : "direct", + ); + + const advance = () => { + if (choice === "local") onLocal(); + else if (choice === "connect") onConnect(); + else onDirect(); + }; + + return ( + <> +

    Where is your code?

    +

    Choose where your agents will run.

    +
    + {localAvailable ? ( + setChoice("local")} + /> + ) : null} + {cloudEnabled ? ( + setChoice("connect")} + /> + ) : null} + setChoice("direct")} + /> +
    +
    + +
    + + ); +} + +function ConnectionOption({ + icon: Icon, + title, + description, + truncateDescription = false, + detail, + selected, + onSelect, +}: { + readonly icon: LucideIcon; + readonly title: string; + readonly description: string; + readonly truncateDescription?: boolean; + readonly detail: string; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +// ── Step 2: T3 Connect (sign in, then connect machines) ────── + +const CONNECT_LOGIN_COMMAND = "npx t3 connect"; + +/** + * Sign-in and machine-connection combined: signed out shows the Clerk prompt, + * signed in forks on account state — zero connected machines blocks on the + * `npx t3 connect` command and auto-advance is left to the user pressing + * Continue once their machine appears; existing machines show a confirmation + * list with the command folded away. There is deliberately no "primary + * machine" selection. + */ +function ConnectMachinesStep({ + onBack, + onContinue, +}: { + readonly onBack: () => void; + readonly onContinue: () => void; +}) { + // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read + // as signed-out mid-transition. + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironments = environments.filter(isOnboardingRelayEnvironment); + // Only a live connection counts: a saved-but-offline machine must not show + // the "connected" confirmation (the agents step would find nothing to + // probe). Its row still renders in the list either way. + const hasRemoteMachines = savedEnvironments.some( + (environment) => environment.connection.phase === "connected", + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn) { + return ( + +
    + +
    +
    + ); + } + + return ( + + {hasRemoteMachines ? ( + <> +
    + +
    + + + + Add another machine + + + +

    + Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

    +
    +
    +
    + +
    + + ) : ( + <> + +

    + Keep T3 Code running on that computer. If it is not running, open T3 Code or run{" "} + npx t3 serve. +

    +
    + + Waiting for your computer to connect. +

    + } + /> +
    +
    + +
    + + Waiting for connection + + +
    +
    + + )} +
    + ); +} + +// ── Step 2′: Direct pairing ────────────────────────────────── + +/** + * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the + * server, paste the URL here. Registers the remote environment in this + * browser's catalog (same path the hosted /pair surface uses). + */ +function PairDirectStep({ + onBack, + onPaired, +}: { + readonly onBack: () => void; + readonly onPaired: (environmentId: EnvironmentId) => void; +}) { + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPairing, setIsPairing] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const submit = async () => { + setIsPairing(true); + setErrorMessage(""); + const result = await connectPairingEnvironment({ pairingUrl }); + if (!mountedRef.current) return; + setIsPairing(false); + if (result._tag === "Success") { + onPaired(result.value); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); + }; + + return ( + +
    +
    +

    + 01 Run this on your server +

    + +

    + Start the server with npx t3 serve first. Add{" "} + --tailscale to use your tailnet. +

    +
    +
    + + setPairingUrl(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); + }} + /> +
    + {errorMessage ? ( +
    + {errorMessage} +
    + ) : null} +
    +
    + +
    +
    + ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; +type OnboardingAgentDriver = (typeof PRIMARY_AGENT_DRIVERS)[number]; + +const AGENT_INSTALL_COMMANDS: Record = { + claudeAgent: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +/** Setup values stay fixed while provider probes refresh the surrounding cards. */ +interface AgentTerminalSession { + readonly environmentId: EnvironmentId; + readonly driver: OnboardingAgentDriver; + readonly providerInstanceId: ServerProvider["instanceId"]; + readonly cwd: string; + readonly command: string; + readonly keybindings: ServerConfig["keybindings"]; +} + +/** + * Claude Code and Codex use live probe status. Install opens the built-in terminal inline + * with the command pre-typed — the update RPC can't install a binary that + * isn't there yet (it infers the package manager from the installed binary's + * path), and the terminal also handles the interactive login that follows. + */ +function AgentsStep({ + mode, + pairedEnvironmentId, + onBack, + onContinue, + onSkip, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + if (targetEnvironment === null) { + return ( + +
    + +
    +
    + ); + } + return ( + + ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, + onBack, + onContinue, + onSkip, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; + readonly onBack: () => void; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const [terminalSession, setTerminalSession] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => selectOnboardingProvidersByDriver(providers), [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + const readyCount = primaryAgents.filter( + ({ provider }) => getOnboardingProviderState(provider) === "ready", + ).length; + return ( + +
    + {primaryAgents.map(({ driver, provider }) => ( + { + if (provider === undefined || serverConfig === null) return; + setTerminalSession({ + environmentId, + driver, + providerInstanceId: provider.instanceId, + cwd: serverConfig.cwd, + command: provider.installed + ? resolveOnboardingProviderLoginCommand( + provider, + serverConfig.settings, + serverConfig.environment.platform.os, + ) + : AGENT_INSTALL_COMMANDS[driver], + keybindings: serverConfig.keybindings, + }); + }} + /> + ))} +
    + {terminalSession !== null ? ( + { + setTerminalSession(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} +
    + +
    + + {readyCount} of {primaryAgents.length} ready + + +
    +
    +
    + ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + terminalAvailable, + onOpenTerminal, +}: { + readonly driver: OnboardingAgentDriver; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly terminalAvailable: boolean; + readonly onOpenTerminal: () => void; +}) { + const meta = getDriverOption(ProviderDriverKind.make(driver)); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + const providerState = getOnboardingProviderState(provider); + + return ( +
    + {Icon ? ( + + ) : null} +
    + {displayName} +

    + {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

    +
    +
    + {providerState === "ready" ? ( + + + Ready + + ) : providerState === "checking" ? ( + Checking... + ) : providerState === "disabled" ? ( + Disabled + ) : providerState === "attention" ? ( + {summary.headline} + ) : ( + + )} +
    +
    + ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +function AgentInstallTerminal({ + session, + onClose, +}: { + readonly session: AgentTerminalSession; + readonly onClose: () => void; +}) { + const { command, cwd, driver, environmentId, keybindings, providerInstanceId } = session; + // Same terminal typography preference the thread drawer honors. + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const setupQueueRef = useRef(Promise.resolve()); + const setupGenerationRef = useRef(0); + const activeSetupGenerationRef = useRef(null); + const [terminalId] = useState(() => `onboarding-${driver}-${randomUUID()}`); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + const [setupAttempt, setSetupAttempt] = useState(0); + const [setupState, setSetupState] = useState< + "preparing" | "ready" | "openFailed" | "writeFailed" + >("preparing"); + const terminalReady = setupState === "ready" || setupState === "writeFailed"; + + // Keep each setup generation distinct. In Strict Mode, a canceled open can + // finish after the replacement setup starts; it must not close or pre-type + // into the replacement session that shares this terminal id. + useEffect(() => { + const generation = setupGenerationRef.current + 1; + setupGenerationRef.current = generation; + activeSetupGenerationRef.current = generation; + setSetupState("preparing"); + + setupQueueRef.current = setupQueueRef.current.then(async () => { + if (activeSetupGenerationRef.current !== generation) return; + const opened = await openTerminal({ + environmentId, + input: { + threadId: AGENT_ONBOARDING_THREAD_ID, + terminalId, + cwd, + providerInstanceId, + }, + }); + if (opened._tag !== "Success") { + if (activeSetupGenerationRef.current === generation) setSetupState("openFailed"); + return; + } + + if (activeSetupGenerationRef.current !== generation) return; + + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + if (activeSetupGenerationRef.current !== generation) return; + setSetupState(wrote._tag === "Success" ? "ready" : "writeFailed"); + }); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so this cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + return () => { + if (activeSetupGenerationRef.current === generation) { + activeSetupGenerationRef.current = null; + } + setupQueueRef.current = setupQueueRef.current.then(async () => { + await closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, deleteHistory: true }, + }); + }); + }; + }, [ + closeTerminal, + command, + cwd, + environmentId, + openTerminal, + providerInstanceId, + setupAttempt, + terminalId, + writeTerminal, + ]); + + return ( +
    +
    + + {setupState === "writeFailed" ? ( + <> + Run {command} in this + terminal. + + ) : setupState === "ready" ? ( + "Review the command, then press Enter to run it." + ) : setupState === "openFailed" ? ( + "Could not open the setup terminal." + ) : ( + "Preparing command..." + )} + +
    + {setupState === "openFailed" ? ( + + ) : null} + +
    +
    +
    + {terminalReady ? ( + + ) : null} +
    +
    + ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +/** + * One-decision import (4B): a summary line with Import recent / Choose / + * Skip. The default imports only projects touched in the last 30 days; + * Choose expands a checklist including older ones. Imported projects also + * receive Codex and Claude threads active within the last 30 days. + */ +function ImportStep({ + mode, + pairedEnvironmentId, + onBack, + onDone, +}: { + readonly mode: ConnectionMode; + readonly pairedEnvironmentId: EnvironmentId | null; + readonly onBack: () => void; + readonly onDone: (projectRef?: ScopedProjectRef) => Promise; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); + const environmentId = targetEnvironment?.environmentId ?? null; + const machineLabel = targetEnvironment?.label ?? "this machine"; + const scan = useEnvironmentQuery( + environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), + ); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); + const projects = useProjects(); + const [choosing, setChoosing] = useState(false); + const [deselected, setDeselected] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [landingProject, setLandingProject] = useState(null); + // Keep project creation attempts separate from completed history imports so both can retry. + const importedProjectsRef = useRef(new Map()); + const projectsWithImportedHistoryRef = useRef(new Map()); + const lastImportSelectionRef = useRef>([]); + const projectAttemptsRef = useRef( + new Map(), + ); + const importGenerationRef = useRef(0); + + // Candidate paths are per-environment; a target switch would otherwise + // leave stale entries in the deselection set (and stale success records). + useEffect(() => { + importGenerationRef.current += 1; + setDeselected(new Set()); + setIsImporting(false); + setImportError(""); + setLandingProject(null); + importedProjectsRef.current = new Map(); + projectsWithImportedHistoryRef.current = new Map(); + lastImportSelectionRef.current = []; + projectAttemptsRef.current = new Map(); + return () => { + importGenerationRef.current += 1; + }; + }, [environmentId]); + + useEffect(() => { + if ( + landingProject !== null && + landingProject.environmentId === environmentId && + projects.some( + (project) => + project.id === landingProject.projectId && + project.environmentId === landingProject.environmentId, + ) + ) { + setLandingProject(null); + void onDone(landingProject).then((completed) => { + if (!completed) setIsImporting(false); + }); + } + }, [environmentId, landingProject, onDone, projects]); + + const { available: candidates, recent } = useMemo( + () => partitionOnboardingProjects(scan.data?.candidates ?? []), + [scan.data], + ); + const more = candidates.length - recent.length; + const scanTruncated = scan.data?.truncated === true; + const scanLimitNotice = scanTruncated ? ( +

    + {SCAN_LIMIT_MESSAGE} +

    + ) : null; + + const finishAfterImport = () => { + const projectRef = resolveOnboardingLandingProject( + lastImportSelectionRef.current, + projectsWithImportedHistoryRef.current, + importedProjectsRef.current, + ); + if (projectRef === undefined) { + void onDone(); + return; + } + setIsImporting(true); + setLandingProject(projectRef); + }; + + const runImport = async (selection: ReadonlyArray) => { + if (environmentId === null || selection.length === 0) { + void onDone(); + return; + } + setIsImporting(true); + setImportError(""); + lastImportSelectionRef.current = selection.map((candidate) => candidate.path); + const importGeneration = importGenerationRef.current; + const importedProjects = importedProjectsRef.current; + const projectAttempts = projectAttemptsRef.current; + // Interrupted imports are neither failures nor successes — the command was + // superseded or the environment dropped — but they still didn't land, so + // they must not read as "imported everything". Retries skip paths that + // already landed this session (re-creating them would only trip the + // duplicate-root invariant and read as a failure). + let importedProjectsCount = + importedProjects.size > 0 + ? selection.filter((candidate) => importedProjects.has(candidate.path)).length + : 0; + let importedThreadCount = 0; + let skippedThreadCount = 0; + let shouldRefreshScan = false; + for (const candidate of selection) { + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (importedProjects.has(candidate.path)) continue; + let projectId = resolveOnboardingProjectId(readProjects(), environmentId, candidate); + if (projectId === null) { + let attempt = projectAttempts.get(candidate.path); + if (attempt === undefined) { + const nextProjectId = newProjectId(); + attempt = { + projectId: nextProjectId, + commandId: CommandId.make(`onboarding:project:create:${nextProjectId}`), + }; + projectAttempts.set(candidate.path, attempt); + } + projectId = attempt.projectId; + const result = await createProject({ + environmentId, + input: { + projectId, + commandId: attempt.commandId, + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection: null, + }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (result._tag !== "Success") { + if (!isAtomCommandInterrupted(result)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + continue; + } + } + + const threadImportResult = await importThreads({ + environmentId, + input: { projectId, expectedWorkspaceRoot: candidate.path }, + }); + if ( + importGeneration !== importGenerationRef.current || + importedProjects !== importedProjectsRef.current + ) { + return; + } + if (threadImportResult._tag === "Success") { + importedThreadCount += threadImportResult.value.importedCount; + skippedThreadCount += threadImportResult.value.skippedCount; + if (threadImportResult.value.importedCount > 0) { + projectsWithImportedHistoryRef.current.set( + candidate.path, + scopeProjectRef(environmentId, projectId), + ); + } + if (threadImportResult.value.skippedCount === 0) { + importedProjectsCount += 1; + importedProjects.set(candidate.path, scopeProjectRef(environmentId, projectId)); + } + } else if (!isAtomCommandInterrupted(threadImportResult)) { + projectAttempts.delete(candidate.path); + shouldRefreshScan = true; + } + } + if (shouldRefreshScan) scan.refresh(); + setIsImporting(false); + if (importedProjectsCount < selection.length) { + if (importedThreadCount > 0 && skippedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`, + ); + } else if (skippedThreadCount > 0) { + setImportError( + `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`, + ); + } else if (importedThreadCount > 0) { + setImportError( + `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`, + ); + } else { + setImportError("Could not import thread history."); + } + return; + } + finishAfterImport(); + }; + + if (environmentId === null || (scan.isPending && scan.data === null)) { + return ( + +
    + +
    +
    + ); + } + + if (scan.error !== null || candidates.length === 0) { + return ( + + {scan.error !== null ? ( +

    You can add projects later.

    + ) : null} +
    + {scan.error !== null ? ( + + ) : null} + +
    +
    + ); + } + + if (choosing) { + const selected = candidates.filter((candidate) => !deselected.has(candidate.path)); + return ( + setChoosing(false)} + backDisabled={isImporting} + description={`${candidates.length} found on ${machineLabel}.`} + > + {scanLimitNotice} +
    + {candidates.map((candidate) => ( + + ))} +
    + {importError ?

    {importError}

    : null} +
    + + +
    +
    + ); + } + + return ( + 0 ? ` ${more} more available.` : ""}`} + onBack={onBack} + backDisabled={isImporting} + > + {scanLimitNotice} +
    + {recent.slice(0, 4).map((candidate) => ( +
    + + + {candidate.path} + + + {candidate.sources.map(formatSource).join(", ")} + +
    + ))} + {recent.length > 4 ? ( +

    + {recent.length - 4} more projects +

    + ) : null} +
    + {importError ?

    {importError}

    : null} +
    + +
    + + +
    +
    +
    + ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + onBack, + backDisabled = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly onBack?: () => void; + readonly backDisabled?: boolean; + readonly children?: React.ReactNode; +}) { + return ( + <> + {onBack ? ( + + ) : null} +

    {title}

    + {description ? ( +

    {description}

    + ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
    + + $ + {command} + + +
    + ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000000..75ed20cc4fe7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
    () { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b31640906e..fd87f7e80c79 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 8dbf9f0904f0..7ca0c496a04b 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ArrowLeft, ArrowRight, @@ -5,7 +6,6 @@ import { ExternalLink, MousePointerClick, PictureInPicture2, - RotateCw, } from "lucide-react"; import { type FormEvent, @@ -166,7 +166,7 @@ export function PreviewChromeRow({ /> } > - + {loading ? "Loading…" : "Refresh"} diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx index d950a99b59fc..5ab8552b36fe 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -1,51 +1,43 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ favicon: null as string | null })); - -vi.mock("~/browserFaviconStore", () => ({ - useFaviconForThreadUrl: () => mocks.favicon, -})); - -import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; - -const threadRef = { - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("thread-1"), -}; - -describe("preview favicon image", () => { - it("renders a captured source before later fallback sources", () => { - expect( - renderToStaticMarkup( - fallback} - />, - ), - ).toContain('src="data:image/png;base64,AAAA"'); - const captured = "data:image/png;base64,AAAA"; - const google = "https://public.example/icon"; - expect(selectFaviconSource([captured, google], new Set())).toBe(captured); - expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); - expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); - expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( - "data:image/png;base64,BBBB", +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/browserFaviconStore", () => ({ useFaviconForThreadUrl: () => null })); + +import { FaviconImage } from "./PreviewFaviconIcon"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("falls through failed favicon sources and retries when the source list changes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const captured = "data:image/png;base64,AAAA"; + const remote = "https://public.example/icon"; + await act(async () => { + renderer = create( + fallback} />, ); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); - it("uses a stored project icon or falls back to the browser mockup", () => { - mocks.favicon = null; - const html = renderToStaticMarkup( - , - ); - expect(html).not.toContain(", + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findByType("img").props.src).toBe(remote); + + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findByType("span").children).toEqual(["fallback"]); + + await act(async () => { + renderer!.update( + fallback} + />, ); - expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); }); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx index 111facfd82dd..b2e1fee3639e 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -6,13 +6,6 @@ import { cn } from "~/lib/utils"; import { BrowserMockup } from "./BrowserMockup"; -export function selectFaviconSource( - sources: ReadonlyArray, - failed: ReadonlySet, -): string | null { - return sources.find((candidate) => !failed.has(candidate)) ?? null; -} - export function FaviconImage(props: { sources: ReadonlyArray; fallback: ReactNode; @@ -35,7 +28,7 @@ function FaviconImageAttempt(props: { className?: string | undefined; }) { const [failed, setFailed] = useState>(() => new Set()); - const source = selectFaviconSource(props.sources, failed); + const source = props.sources.find((candidate) => !failed.has(candidate)); if (!source) return props.fallback; return ( ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); @@ -251,7 +252,7 @@ vi.mock("./AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); vi.mock("./usePreviewSession", () => ({ usePreviewSession: vi.fn() })); -import { PreviewView, previewProfileName } from "./PreviewView"; +import { PreviewView } from "./PreviewView"; import { toastManager } from "~/components/ui/toast"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -352,12 +353,6 @@ describe("PreviewView navigation", () => { mocks.recordVisitForThread.mockClear(); }); - it("labels a tab whose saved profile was removed", () => { - expect(previewProfileName(BUILT_IN_BROWSER_PROFILES, "profile-removed")).toBe( - "Removed profile", - ); - }); - it("does not rerender while loading time passes", async () => { vi.useFakeTimers(); mocks.loading = true; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 640690854e53..e6ad2758bc48 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -76,7 +80,7 @@ interface Props { ) => void; } -export function previewProfileName( +function previewProfileName( profiles: ReadonlyArray<{ readonly id: string; readonly name: string }>, profileId: string, ): string { @@ -186,6 +190,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index f26cb0fff9e1..d34de83a23b4 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2f1c..e0cd83501201 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86104..288db101e7a5 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,7 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e7fa..fe14211280c2 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465ebc28..07dab9a0b36d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 46dd33f7beb4..2ce81cb06af2 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,33 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index dcf35de53f2d..97a099ec72eb 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -216,7 +216,7 @@ export const PreviewAutomationHostError = Schema.Union([ ]); export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; -export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); +const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); export function serializePreviewAutomationHostError( error: PreviewAutomationHostError, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 3aa2e141af07..10723cedaa8a 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -4,7 +4,7 @@ export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; -export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; +const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export function clampPreviewMiniPlayerSize( size: PreviewMiniPlayerSize, diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..4ffd453955e9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -49,7 +49,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; -export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ +const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, { id: "test", label: "Test" }, { id: "lint", label: "Lint" }, diff --git a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx index d87dfa45b0ff..2aa1413438ee 100644 --- a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx @@ -1,4 +1,4 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { cn } from "~/lib/utils"; @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({

    Could not load pull request activity

    {error}

    diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index aa2d278ce249..b77a3711d90f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -187,7 +187,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */ -export function PullRequestCodeTab({ +function PullRequestCodeTab({ environmentId, reference, detail, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 76e50b60003b..f8514f83dcdf 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -7,7 +8,6 @@ import { type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, - type PullRequestState, resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -36,7 +36,6 @@ import { PanelRightIcon, PencilIcon, PlayIcon, - RefreshCwIcon, RotateCcwIcon, TriangleAlertIcon, } from "lucide-react"; @@ -454,7 +453,6 @@ export function PullRequestDetailPanel({ refreshToken: forcedRefreshToken = 0, onActed, onClose, - onStateChange, context = "page", composerDraftTarget, }: { @@ -482,8 +480,6 @@ export function PullRequestDetailPanel({ onActed?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; - /** Keeps surrounding inferred thread state in step with refreshed host state. */ - onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out @@ -627,6 +623,14 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, @@ -706,14 +710,6 @@ export function PullRequestDetailPanel({ } activityRevision.current = next; }, [activityQuery.refresh, coreDetail, tabScopeKey]); - useLayoutEffect(() => { - if (!resolvedCoreDetail) return; - onStateChange?.({ - repository: resolvedCoreDetail.repository, - number: resolvedCoreDetail.number, - state: resolvedCoreDetail.state, - }); - }, [onStateChange, resolvedCoreDetail]); // Reuse activity and diff until core detail reports a changed revision. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. @@ -728,10 +724,16 @@ export function PullRequestDetailPanel({ // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [isInvalidating, setIsInvalidating] = useState(false); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); + setIsInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + setIsInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -1670,8 +1672,14 @@ export function PullRequestDetailPanel({ - void refreshFromHost()}> - + void refreshFromHost()} + > + Refresh @@ -2315,6 +2323,7 @@ export function PullRequestDetailPanel({ {detailQuery.error && !detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx index 4dd92dbf2243..8c5f862700bc 100644 --- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; /** * What the list shows when it has no rows to show. * @@ -11,7 +12,7 @@ * with no project to read from — leave the button out, since pressing it could only repeat what * is already happening or ask nobody. */ -import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { openCommandPalette } from "../../commandPaletteBus"; import { Button } from "../ui/button"; @@ -149,7 +150,7 @@ export function PullRequestListEmptyState({ {/* The hosts answered this query once; a pull request opened since then would answer differently, and nothing on screen says which of the two the reader is looking at. */} @@ -175,7 +176,7 @@ export function PullRequestListEmptyState({ ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index e45a687e981d..1c703bce3e2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId, ProjectId, @@ -17,7 +18,6 @@ import { GitPullRequestDraftIcon, LayersIcon, ListFilterIcon, - LoaderIcon, SearchIcon, TagIcon, UserRoundIcon, @@ -37,6 +37,7 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuRadioItemIndicator, MenuSeparator, MenuSub, MenuSubPopup, @@ -119,7 +120,7 @@ export function PullRequestSearchInput({ return ( - {busy ? : } + {busy ? : } ({ {option.label} {option.unavailable ? · Unavailable : null} + ); @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; - readonly faviconPath?: string | null; - readonly projectIcon?: ProjectIconOverride | null; + readonly faviconPath?: string | null | undefined; + readonly projectIcon?: ProjectIconOverride | null | undefined; }>; projectId: ProjectId | undefined; /** diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 70f5c845d2ca..26c80326aec0 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -1,4 +1,5 @@ -import { ExternalLinkIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, GitPullRequestIcon } from "lucide-react"; import { Button } from "../ui/button"; import { @@ -14,11 +15,13 @@ export function PullRequestsUnavailableState({ title = "Could not load pull requests", error, onRetry, + refreshing = false, gitHubUrl, }: { title?: string; error: string; onRetry?: () => void; + refreshing?: boolean; gitHubUrl?: string; }) { return ( @@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({ {onRetry || gitHubUrl ? ( {onRetry ? ( - ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c51429ff6d83..61c630c815e4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -38,7 +38,6 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, - describePullRequestState, editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -199,15 +198,6 @@ describe("pull request primary control", () => { }); }); -describe("pull request state description", () => { - it("keeps draft and conflicts orthogonal to the terminal states", () => { - expect(describePullRequestState("open", true)).toBe("Draft"); - expect(describePullRequestState("open", false)).toBe("Ready for review"); - expect(describePullRequestState("merged", true)).toBe("Merged"); - expect(describePullRequestState("closed", false)).toBe("Closed"); - }); -}); - describe("pull request handoff labels", () => { it("names the open thread when actions write to its composer", () => { expect(pullRequestHandoffLabels(true)).toEqual({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cffe33f8d83d..d00215f02d41 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -189,13 +189,6 @@ export function isStackedPullRequestBase( return defaultBranch !== baseBranch; } -/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ -export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return isDraft ? "Draft" : "Ready for review"; -} - /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments( comments: ReadonlyArray, diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 720a5669178f..d3d5d958a49f 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic"; +import { orderDiffFiles } from "./pullRequestFileOrder.logic"; /** Only the path and the patch's own lines matter here; the viewer fills the rest in. */ function file(name: string, additionLines: ReadonlyArray = []): FileDiffMetadata { @@ -12,34 +12,31 @@ function order(files: ReadonlyArray): Array { return orderDiffFiles(files).map((entry) => entry.name); } -describe("diffFileTier", () => { - it("puts lockfiles, snapshots and build output last", () => { - expect(diffFileTier("pnpm-lock.yaml")).toBe("generated"); - expect(diffFileTier("apps/web/package-lock.json")).toBe("generated"); - expect(diffFileTier("src/__snapshots__/app.ts")).toBe("generated"); - expect(diffFileTier("src/app.test.ts.snap")).toBe("generated"); - expect(diffFileTier("src/api.generated.ts")).toBe("generated"); - expect(diffFileTier("public/app.min.js")).toBe("generated"); - expect(diffFileTier("dist/app.js")).toBe("generated"); - expect(diffFileTier("packages/core/vendor/lib.js")).toBe("generated"); - }); - - it("recognises a test by its name or by the directory holding it", () => { - expect(diffFileTier("src/app.test.ts")).toBe("test"); - expect(diffFileTier("src/app.spec.tsx")).toBe("test"); - expect(diffFileTier("src/__tests__/app.ts")).toBe("test"); - expect(diffFileTier("test/app.ts")).toBe("test"); - expect(diffFileTier("tests/helpers/app.ts")).toBe("test"); - }); - - it("treats everything else as source, including files merely named like a directory", () => { - expect(diffFileTier("src/app.ts")).toBe("source"); - expect(diffFileTier("src/testing.ts")).toBe("source"); - expect(diffFileTier("src/dist.ts")).toBe("source"); +describe("orderDiffFiles", () => { + it("places source before tests and generated files across path conventions", () => { + const source = ["src/app.ts", "src/dist.ts", "src/testing.ts"]; + const tests = [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ]; + const generated = [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + ]; + expect( + order([...generated, ...tests, ...source].toReversed().map((path) => file(path))), + ).toEqual([...source, ...tests, ...generated]); }); -}); -describe("orderDiffFiles", () => { it("answers an empty diff with an empty order", () => { expect(order([])).toEqual([]); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index b46ed88539c2..1a0df9d9beda 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -31,7 +31,7 @@ const GENERATED_DIRECTORIES = new Set([ const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; -export function diffFileTier(path: string): DiffFileTier { +function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); const name = segments.at(-1) ?? ""; if ( diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts index db105f97fe95..eb6f47b4c803 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts @@ -1,15 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { openOnHostLabel, pullRequestLinkContextMenuItems } from "./pullRequestLinkContextMenu"; +import { openOnHostLabel } from "./pullRequestLinkContextMenu"; describe("pull request link context menu", () => { - it("offers the copy first and the host's own page after it", () => { - expect(pullRequestLinkContextMenuItems("Open on GitHub")).toEqual([ - { id: "copy-link", label: "Copy link", icon: "copy" }, - { id: "open-external", label: "Open on GitHub" }, - ]); - }); - it("names every host it knows, and says nothing false about one it does not", () => { expect(openOnHostLabel("github")).toBe("Open on GitHub"); expect(openOnHostLabel("gitlab")).toBe("Open on GitLab"); diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 16b749445d4c..1ccdb64b73f4 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -8,7 +8,7 @@ import { toastManager } from "../ui/toast"; export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; /** Named for the host rather than "externally": the point is where you will land. */ -export const OPEN_ON_HOST_LABELS: Partial> = { +const OPEN_ON_HOST_LABELS: Partial> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", @@ -19,7 +19,7 @@ export const openOnHostLabel = (provider: string): string => OPEN_ON_HOST_LABELS[provider] ?? "Open on host"; /** Copy first: it is the reason to right-click a number rather than click it. */ -export function pullRequestLinkContextMenuItems( +function pullRequestLinkContextMenuItems( openLabel: string, ): readonly ContextMenuItem[] { return [ diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index cee672489389..c2fdde3d011f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -67,7 +67,7 @@ export type PullRequestViewers = PullRequestListResult["viewers"]; /** A row plus the environment that read it, where the caller has one to give. */ type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; -export const pullRequestViewerKey = (entry: ScopedEntry): string => +const pullRequestViewerKey = (entry: ScopedEntry): string => `${entry.environmentId ?? ""} ${entry.host}`; const GROUP_LABELS: Record = { diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index bc2bc6f272cd..95c4bf02e743 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -37,7 +37,7 @@ export type PullRequestListPreferencePatch = { [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined; }; -export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { +const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { involvement: "all", state: "open", } as const satisfies PullRequestListPreferences; diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 8611ddc28dde..3c41e0956fed 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { PullRequestActor, PullRequestCheck, @@ -15,7 +16,6 @@ import { GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, - LoaderIcon, TriangleAlertIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -119,7 +119,7 @@ export function PullRequestStateGlyph({ } const CHECK_STATUS_PRESENTATION = { - pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" }, + pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" }, "action-required": { label: "Awaiting action", Icon: CircleDotIcon, @@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = { neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, } as const satisfies Record< PullRequestCheckStatus, - { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } + { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } >; function isWorkflowApprovalCheck(check: Pick): boolean { diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts new file mode 100644 index 000000000000..5c660118ed28 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -0,0 +1,158 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findScopedProject } from "./pullRequestList.logic"; +import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; + +const cups = EnvironmentId.make("env-cups"); +const nucbox = EnvironmentId.make("env-nucbox"); +const labels = new Map([ + [cups, "cups"], + [nucbox, "nucbox-1"], +]); + +function project( + id: string, + environmentId = nucbox, + canonicalKey: string | null = "github.com/pingdotgg/t3code", +) { + return { + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: canonicalKey === null ? null : { canonicalKey }, + faviconPath: `${id}/favicon.png`, + }; +} + +describe("pull request project filter choices", () => { + it("collapses three checkouts on one server without dropping another server's copy", () => { + const projects = [ + project("main"), + project("worktree-1"), + project("worktree-2"), + project("main", cups), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([ + { id: "main", environmentId: cups, title: "t3code · cups" }, + { id: "main", environmentId: nucbox, title: "t3code · nucbox-1" }, + ]); + expect(choices[1]?.workspaceRoot).toBe("/work/main"); + expect(choices[1]?.faviconPath).toBe("main/favicon.png"); + }); + + it("keeps a saved worktree selection as the repository's only choice", () => { + const projects = [project("main"), project("worktree"), project("worktree", cups)]; + const selected = findScopedProject(projects, nucbox, "worktree"); + + const choices = pullRequestFilterProjects(projects, labels, selected); + + expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([ + { ...projects[1], title: "t3code · nucbox-1" }, + ]); + expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined(); + expect(findScopedProject(choices, nucbox, "main")).toBeUndefined(); + expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); + }); + + it("matches canonical repositories regardless of casing", () => { + const main = project("main"); + const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); + + expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); + }); + + it("does not add a server suffix after duplicate checkouts have collapsed", () => { + const main = project("main"); + + expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); + expect(main.title).toBe("t3code"); + }); + + it("distinguishes same-named repositories on one server by checkout path", () => { + const projects = [ + project("upstream"), + project("fork", nucbox, "github.com/juliusmarminge/t3code"), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/fork", + "t3code · nucbox-1 · /work/upstream", + ]); + }); + + it("keeps repositories on different hosts separate", () => { + const choices = pullRequestFilterProjects( + [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], + labels, + ); + + expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); + }); + + it("does not merge projects whose repository identity is unknown", () => { + const choices = pullRequestFilterProjects( + [project("first", nucbox, null), project("second", nucbox, null)], + labels, + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first", + "t3code · nucbox-1 · /work/second", + ]); + }); + + it("distinguishes servers with the same display name and checkout path", () => { + const first = project("main"); + const second = project("main", cups); + const repeatedLabels = new Map([ + [cups, "nucbox-1"], + [nucbox, "nucbox-1"], + ]); + + const choices = pullRequestFilterProjects([first, second], repeatedLabels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/main · env-cups", + "t3code · nucbox-1 · /work/main · env-nucbox", + ]); + }); + + it("uses the environment id when its label is unavailable", () => { + const choices = pullRequestFilterProjects( + [project("main"), project("remote", cups)], + new Map(), + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · env-cups", + "t3code · env-nucbox", + ]); + }); + + it("can distinguish unresolved project records that also share a checkout path", () => { + const first = project("first", nucbox, null); + const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; + + const choices = pullRequestFilterProjects([first, second], labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first · env-nucbox · first", + "t3code · nucbox-1 · /work/first · env-nucbox · second", + ]); + }); + + it("leaves unrelated names unchanged and orders them alphabetically", () => { + const app = { ...project("app"), title: "Zebra" }; + const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; + + expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); + expect(pullRequestFilterProjects([], labels)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts new file mode 100644 index 000000000000..5b214e3f37f6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts @@ -0,0 +1,53 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; + +interface FilterProject extends AssignableProject { + readonly title: string; + readonly workspaceRoot: string; +} + +function distinguishTitles( + projects: ReadonlyArray, + suffix: (project: Project) => string, +) { + const counts = new Map(); + for (const project of projects) { + counts.set(project.title, (counts.get(project.title) ?? 0) + 1); + } + return projects.map((project) => + (counts.get(project.title) ?? 0) > 1 + ? { ...project, title: `${project.title} · ${suffix(project)}` } + : project, + ); +} + +/** One choice per repository per server, retaining the selected checkout for saved scopes. */ +export function pullRequestFilterProjects( + projects: ReadonlyArray, + environmentLabels: ReadonlyMap, + selectedProject?: Pick, +) { + const byRepository = new Map(); + for (const project of projects) { + const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const key = JSON.stringify([ + project.environmentId, + repository ? ["repository", repository] : ["project", project.id], + ]); + const selected = + project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId; + if (!byRepository.has(key) || selected) byRepository.set(key, project); + } + + const byServer = distinguishTitles( + [...byRepository.values()], + (project) => environmentLabels.get(project.environmentId) ?? project.environmentId, + ); + const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); + // Separate environments can share both their display name and their checkout path. + const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); + return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + left.title.localeCompare(right.title), + ); +} diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..26015c5b3393 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,6 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; -import { LoaderCircle } from "lucide-react"; + import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: {
    {search.isPending ? ( - Searching… + Searching… ) : search.error ? ( {search.error} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5a6f3ebd70b0..ad7665651171 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -60,6 +60,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; +import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -394,7 +395,7 @@ function formatDesktopSshConnectionError(error: unknown): string { return withoutTaggedErrorPrefix.trim() || fallback; } -const ENDPOINT_ROW_CLASSNAME = "rounded-xl px-3 py-2.5 sm:px-4"; +const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; type AccessSectionPresentation = "current" | "endpoint-rail"; @@ -404,7 +405,10 @@ function accessRowClassName(_presentation: AccessSectionPresentation) { function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { if (presentation === "endpoint-rail") { - return cn("relative rounded-xl px-3 py-3 sm:px-4", !isAvailable && "bg-muted/15"); + return cn( + "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4", + !isAvailable && "bg-muted/15", + ); } return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); @@ -3222,6 +3226,7 @@ export function ConnectionsSettings() { > @@ -3591,6 +3596,7 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + ); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 0b23fb2d2072..ec3bf854d7b0 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { AlertTriangleIcon, ChevronDownIcon, @@ -5,7 +6,6 @@ import { CopyIcon, FolderOpenIcon, InfoIcon, - RefreshCwIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { @@ -766,7 +766,7 @@ function DiagnosticsRefreshButton({ onClick={onClick} aria-label={label} > - + } /> diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx index de18739e5090..fa5fb94aadd1 100644 --- a/apps/web/src/components/settings/ExpandableText.tsx +++ b/apps/web/src/components/settings/ExpandableText.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { cn } from "../../lib/utils"; @@ -17,12 +17,14 @@ export function ExpandableText({ collapsedClassName?: string; expandLabel?: string; }) { + const textId = useId(); const [expanded, setExpanded] = useState(false); const canExpand = text.length > 180 || text.includes("\n"); return (
    setExpanded((value) => !value)} > diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx new file mode 100644 index 000000000000..5d185fee5824 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -0,0 +1,77 @@ +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { listBrowserImportSources } = vi.hoisted(() => ({ + listBrowserImportSources: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../preview/previewBridge", () => ({ + previewBridge: { listBrowserImportSources }, +})); +vi.mock("../../env", () => ({ isElectron: true })); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), + usePrimaryEnvironment: () => null, +})); +vi.mock("../../hooks/useSettings", () => ({ + PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", + useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector(DEFAULT_CLIENT_SETTINGS), + useClientSettingsHydrated: () => true, + usePrimarySettingsAvailable: () => true, + usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, + useUpdatePrimarySettings: () => vi.fn(), +})); +vi.mock("./settingsLayout", async (importOriginal) => ({ + ...(await importOriginal()), + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, +})); + +import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + listBrowserImportSources.mockClear(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); + await act(() => { + renderer = create( + + + , + ); + }); + expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); +} + +describe("Integrations browser discovery", () => { + it("does not scan browser files when entering or revisiting settings", async () => { + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + + await act(() => renderer?.unmount()); + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3e96107b29c..514c241a3c57 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,8 +34,9 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; @@ -86,7 +86,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); @@ -804,11 +784,6 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { .catch(() => setSources((previous) => previous ?? [])); }, []); - // Loaded once so the first open is instant instead of flashing a spinner. - useEffect(() => { - loadSources(); - }, [loadSources]); - // Runs one import for the wizard. A new profile is registered only once the // import succeeds — the cookies land in its partition first — so a blocked // attempt never leaves an empty profile behind. diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index d987bc7a83dd..c366a87e7efd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -291,7 +291,7 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } -export function normalizeShortcutKeyToken(key: string): string | null { +function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( normalized === "meta" || diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b0beea24cc47..ad99328647af 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1324,7 +1324,7 @@ function KeybindingsList(props: KeybindingsListProps) { /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { return ( -
    +
    Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx new file mode 100644 index 000000000000..514b146c3bac --- /dev/null +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -0,0 +1,94 @@ +import { connectionStatusText } from "@t3tools/client-runtime/connection"; + +import { + useClientSettings, + useClientSettingsHydrated, + useUpdateClientSettings, +} from "~/hooks/useSettings"; +import type { EnvironmentPresentation } from "~/state/environments"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const preferences = [ + { value: 100, label: "Prefer" }, + { value: 50, label: "Normal" }, + { value: 25, label: "Less often" }, + { value: 0, label: "Manual only" }, +]; + +export function LoadBalancingSettings({ + environments, +}: { + environments: ReadonlyArray; +}) { + const settings = useClientSettings(); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdateClientSettings(); + + return ( + + updateSettings({ loadBalancingEnabled })} + /> + } + /> + {environments.map((environment) => { + const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; + // Keep saved slider weights until the user chooses a different preference. + const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; + + return ( + { + if (value !== null) { + updateSettings({ + loadBalancingWeights: { + ...settings.loadBalancingWeights, + [environment.environmentId]: value, + }, + }); + } + }} + > + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 000000000000..1794a5fdaa2e --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

    + No actions configured. +

    + ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} +
    + } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 000000000000..4385a5901b5e --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useEnvironments } from "../../state/environments"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { useProjectScriptSettings } from "./ProjectSettingsPanel"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const { saving, persist, submit } = useProjectScriptSettings( + targets.flatMap(({ environmentId, serverConfig }) => + serverConfig + ? [ + { + environmentId, + settings: serverConfig.settings, + keybindings: serverConfig.keybindings, + }, + ] + : [], + ), + ); + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 000000000000..938000e01002 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,474 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const navigate = useNavigate(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(new Set()); + const [saving, setSaving] = useState>(new Set()); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + config.providers, + ).get(instanceId); + if ( + !entry?.enabled || + !entry.isAvailable || + entry.driverKind !== sourceEntry?.driverKind || + !options?.some((option) => option.slug === model && !option.isUnavailable) + ) { + return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + const keys = Object.keys(patch); + if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + for (const key of keys) savingRef.current.add(key); + setSaving(new Set(savingRef.current)); + try { + const results = await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } + } finally { + for (const key of keys) savingRef.current.delete(key); + setSaving(new Set(savingRef.current)); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + +
    + } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

    + {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

    + ) : null} + setModel(null)} + /> + ) : null + } + control={ + selection && activeEntry ? ( +
    + { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} + onInstanceModelChange={(instanceId, model) => + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
    + ) : ( + No providers available + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> + + + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..f4ffe699466e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,52 +12,53 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + type EnvironmentId, + type ModelSelection, + type ProjectIconOverride, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ServerSettings, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { + projectScriptsInheritDefaults, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; -import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; -import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; -import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; -import { readLocalApi } from "../../localApi"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; -import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; +import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -98,16 +99,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
    - - - - -
    -
    - ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -298,17 +218,185 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
    ); } - return ; + if (members.length === 0) + return ( +

    + This project has no checkout on this machine. +

    + ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); +} + +function reportScriptFailure(result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to save project actions", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return mapAtomCommandResult(result, () => undefined); +} + +export function useProjectScriptSettings( + targets: readonly { + environmentId: EnvironmentId; + settings: ServerSettings; + keybindings: ResolvedKeybindingsConfig; + project?: { id: ProjectId; scripts: readonly ProjectScript[] }; + }[], +) { + const projects = useProjects(); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) { + const message = "No available machine, or another action change is saving."; + toastManager.add({ type: "error", title: "Actions not saved", description: message }); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + savingRef.current = true; + setSaving(true); + try { + for (const { environmentId, settings, keybindings, project } of targets) { + const current = project + ? resolveProjectScripts(settings, project) + : settings.defaultProjectScripts; + const nextScripts = transform(current); + const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; + const result = await updateSettings({ + environmentId, + input: { + patch: project + ? { projectScriptOverrides: { [project.id]: nextScripts } } + : { defaultProjectScripts: nextScripts ?? [] }, + }, + }); + if (result._tag === "Failure") return reportScriptFailure(result); + if (!isElectron) continue; + const changedIds = scriptId + ? [scriptId] + : current + .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) + .map((script) => script.id); + for (const id of changedIds) { + const command = commandForProjectScript(id); + const previousValue = keybindingValueForCommand(keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + const retainedElsewhere = + !nextScripts?.some((script) => script.id === id) && + ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || + Object.entries(settings.projectScriptOverrides).some( + ([projectId, scripts]) => + projectId !== project?.id && scripts?.some((script) => script.id === id), + ) || + projects.some( + (other) => + other.environmentId === environmentId && + other.id !== project?.id && + (project ? resolveProjectScripts(settings, other) : other.scripts).some( + (script) => script.id === id, + ), + )); + const bindingResult = next + ? await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }) + : previous && !retainedElsewhere + ? await removeKeybinding({ environmentId, input: previous }) + : null; + if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap(({ settings, project }) => + [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ...(project?.scripts ?? []), + ].map((script) => script.id), + ), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return persist( + (current) => { + const updated = current.map((script) => + script.id === id + ? next + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + return scriptId === null ? [...updated, next] : updated; + }, + id, + input.keybinding, + ); + } + + return { saving, persist, submit }; } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); - const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { - reportFailure: false, + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); + const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); + const savingBrowserAccessRef = useRef(false); + const browserOverrides = group.memberProjects.map( + (member) => + environmentById.get(member.environmentId)?.serverConfig?.settings + .projectAgentBrowserAccessOverrides[member.id], + ); + const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); }); + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); + for (const environmentId of environmentIds) { + const environment = environmentById.get(environmentId); + if (!environment?.serverConfig || environment.connection.phase !== "connected") { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: `Connect ${environment?.label ?? "this machine"} and try again.`, + }); + return; + } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); + const mixedModel = group.memberProjects.some((member) => { + const config = environmentById.get(member.environmentId)?.serverConfig; + return ( + !Equal.equals(member.defaultModelSelection, storedSelection) || + (config !== null && + config !== undefined && + environmentById.get(representative.environmentId)?.serverConfig != null && + JSON.stringify( + resolveDefaultProviderModelSelection( + config.providers, + member.defaultModelSelection ?? config.settings.defaultModelSelection, + ), + ) !== JSON.stringify(resolvedSelection)) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + const setDefaultModel = (selection: ModelSelection | null) => { + if (selection !== null) { + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const config = environment?.serverConfig; + const entry = config + ? applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === selection.instanceId) + : undefined; + const options = config + ? getCustomModelOptionsByInstance( + { ...projectSettings, ...config.settings }, + config.providers, + ).get(selection.instanceId) + : undefined; + if ( + !entry?.enabled || + !entry.isAvailable || + !options?.some((model) => model.slug === selection.model && !model.isUnavailable) + ) { + toastManager.add({ + type: "warning", + title: "Project model not saved", + description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, + }); + return; + } + } + } + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); + }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const mixedWorkspace = group.memberProjects.some( + (member) => member.defaultThreadEnvMode !== storedEnvMode, + ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( @@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [updateAllMembers], + const autoPull = resolveProjectAutoPull( + projectSettings, + representative.id, + representative.autoPull, ); + const autoPullOverridden = group.memberProjects.some( + (member) => + member.autoPull || + environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ + member.id + ] !== undefined, + ); + const mixedAutoPull = group.memberProjects.some((member) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; + }); + const setAutoPull = (enabled: boolean | undefined) => + setBooleanOverride("projectAutoPullOverrides", enabled); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); - // Script writes replace the whole array, so two overlapping writes computed - // from the same snapshot would drop each other's changes. One at a time. - const [isSavingScripts, setIsSavingScripts] = useState(false); - const savingScriptsRef = useRef(false); + const { + saving: isSavingScripts, + persist: persistScripts, + submit: submitScript, + } = useProjectScriptSettings([ + { + environmentId: selectedCheckout.environmentId, + settings: scriptSettings, + keybindings, + project: selectedCheckout, + }, + ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. - const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [scripts, t3File.scripts], ); - const persistScripts = useCallback( - async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, - ): Promise> => { - if (savingScriptsRef.current) { - return AsyncResult.failure( - Cause.fail(new Error("Another script change is still saving. Try again.")), - ); - } - savingScriptsRef.current = true; - setIsSavingScripts(true); - try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, - }), - () => undefined, - ); - if (updateResult._tag === "Failure") { - reportFailure("Failed to save scripts", updateResult); - return updateResult; - } - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; - } - } - } else if (previousTarget) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; - } - } - } - return updateResult; - } finally { - savingScriptsRef.current = false; - setIsSavingScripts(false); - } - }, - [ - keybindings, - removeKeybinding, - reportFailure, - selectedCheckout.environmentId, - selectedCheckout.id, - updateProject, - upsertKeybinding, - ], - ); - - const submitScript = useCallback( - async ( - scriptId: string | null, - input: NewProjectScriptInput, - ): Promise> => { - if (scriptId === null) { - const nextId = nextProjectScriptId( - input.name, - scripts.map((script) => script.id), - ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...scripts, nextScript]; - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); - } - - const updatedScript = buildProjectScript(scriptId, input); - const nextScripts = scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); - - const deleteScript = useCallback( - (scriptId: string) => { - const nextScripts = scripts.filter((script) => script.id !== scriptId); - void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); + const deleteScript = (scriptId: string) => + void persistScripts( + (current) => current.filter((script) => script.id !== scriptId), + scriptId, + null, + ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { @@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, + hasOtherMembers, navigate, reportFailure, threads, ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + const checkoutLabel = (member: SidebarProjectGroupMember) => { + const label = member.environmentLabel ?? "This machine"; + return group.memberProjects.some( + (other) => + other.physicalProjectKey !== member.physicalProjectKey && + (other.environmentLabel ?? "This machine") === label, + ) + ? `${label} · ${member.workspaceRoot}` + : label; + }; + const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } - > -
    -
    - - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
    - {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
    -
    -
    + + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + + {checkoutLabel(member)} + + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +export function ProjectsSettings({ + projectKey, + machineId, + onScopeChange, +}: { + projectKey: string | null; + machineId: string | null; + onScopeChange: (project: string | null, machine: string | null) => void; +}) { + const groups = useSettingsProjectGroups(); + const { environments } = useEnvironments(); + const machine = environments.find((environment) => environment.environmentId === machineId); + const machineOptions = environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + icon: ( + + ), + })); + return ( +
    +
    + +
    + {environments.length > 3 ? ( + onScopeChange(projectKey, value)} + /> + ) : ( + { + const value = next[0]; + if (value) onScopeChange(projectKey, value === "all" ? null : value); + }} + > + All machines + {machineOptions.map((option) => ( + + {option.icon} + {option.label} + + ))} + + )} +
    + ({ + value: group.projectKey, + label: group.displayName, + icon: ( + + ), + }))} + onChange={(value) => onScopeChange(value, machineId)} + /> +
    +
    +
    +
    + {machineId !== null && !machine ? ( +

    This machine is no longer available.

    + ) : projectKey === null ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 5c96de9d0545..327b48c2d44a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,10 +1,11 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { ArrowUpCircleIcon, CopyIcon, DownloadIcon, - LoaderIcon, LockIcon, LockOpenIcon, PlusIcon, @@ -598,15 +599,19 @@ export function ProviderInstanceCard({ selected ? "bg-muted/45" : "hover:bg-muted/25", )} > - + } + /> + Copy update command + + ) : ( + + + + ) ) : null} @@ -637,7 +664,7 @@ export function ProviderInstanceCard({ - +
    +
    {driverOption?.badgeLabel ? ( {driverOption.badgeLabel} @@ -669,7 +696,7 @@ export function ProviderInstanceCard({ render={ } /> @@ -713,7 +740,7 @@ export function ProviderInstanceCard({ disabled={isUpdating} onClick={onRunUpdate} > - {isUpdating ? : } + {isUpdating ? : } {isUpdating ? "Updating" : "Update now"} ) : null} @@ -758,14 +785,14 @@ export function ProviderInstanceCard({ {onDelete ? ( ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 7dc13fa4f25c..4bb8cf11bd4e 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -5,8 +5,6 @@ import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, - readProviderConfigBoolean, - readProviderConfigString, } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { @@ -90,10 +88,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("reads non-string config values as blank strings", () => { - expect(readProviderConfigString({ binaryPath: 123 }, "binaryPath")).toBe(""); - }); - it("omits false boolean fields when clearWhenEmpty is omit", () => { const next = nextProviderConfigWithFieldValue( { forkOwned: 1, experimental: true }, @@ -156,12 +150,4 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("reads non-boolean config values as false booleans", () => { - expect(readProviderConfigBoolean({ experimental: "true" }, "experimental")).toBe(false); - }); - - it("reads missing boolean config values from the supplied default", () => { - expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true); - }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 902fd408b54f..6d644aaf01c5 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -119,17 +119,13 @@ export function deriveProviderSettingsFields( }); } -export function readProviderConfigString(config: unknown, key: string): string { +function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; return typeof value === "string" ? value : ""; } -export function readProviderConfigBoolean( - config: unknown, - key: string, - defaultValue = false, -): boolean { +function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; return typeof value === "boolean" ? value : defaultValue; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fb4620b054c1..74676e3ff167 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -24,7 +25,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; @@ -993,7 +994,7 @@ export function EnvironmentProviderSettings({ aria-busy={isRefreshingProviders} onClick={() => void refreshProviders()} > - + Refresh provider status {isRefreshingProviders ? ( diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ca934952f8a9..003e46869a91 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ActivityIcon, AlertTriangleIcon, @@ -9,8 +10,6 @@ import { GaugeIcon, HardDriveIcon, MemoryStickIcon, - RefreshCwIcon, - RotateCcwIcon, } from "lucide-react"; import type { BackgroundBooleanState, @@ -982,9 +981,7 @@ export function ResourceTelemetryDiagnostics() { onClick={telemetry.refresh} aria-label="Refresh resource telemetry" > - + } /> @@ -1095,7 +1092,7 @@ export function ResourceTelemetryDiagnostics() { headerAction={ collectorNeedsRetry ? ( ) : null @@ -1233,7 +1230,7 @@ export function ResourceTelemetryDiagnostics() { onClick={history.refresh} aria-label="Refresh resource history" > - +
    } diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index b99c69ee331f..d93db8d970b1 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -14,7 +14,6 @@ import { formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, - isSamePreviewViewport, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -282,25 +281,3 @@ describe("getChangedBrowserSettingLabels", () => { ]); }); }); - -describe("isSamePreviewViewport", () => { - it("separates presets that share a size", () => { - // Two presets can agree on width and height and still be different - // entries in the picker, so the id has to take part in the comparison. - expect( - isSamePreviewViewport( - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - { _tag: "preset", width: 390, height: 844, presetId: "ipad-mini" }, - ), - ).toBe(false); - }); - - it("separates a freeform viewport from a preset of the same size", () => { - expect( - isSamePreviewViewport( - { _tag: "freeform", width: 390, height: 844 }, - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 3ac6bbaa0017..5cbcb190a97b 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -126,7 +126,7 @@ export type BrowserDefaultSettings = Pick< * reports every stored viewport as changed — including one that matches the * default. */ -export function isSamePreviewViewport( +function isSamePreviewViewport( left: PreviewViewportSetting, right: PreviewViewportSetting, ): boolean { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8482e37440a4..7d869a159ee7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,5 @@ -import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; +import { Spinner } from "~/components/ui/spinner"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -45,6 +46,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; +import { formatAboutVersion } from "@q1code/core/brand"; // fork: base import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, @@ -160,6 +162,7 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; +import { ForkSettingsSection } from "../../fork/ForkSettingsSection"; // fork: base const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -246,7 +249,10 @@ function AboutVersionTitle() { return ( Version - {APP_VERSION} + {/* fork: base */} + + {formatAboutVersion(APP_VERSION)} + ); } @@ -2203,7 +2209,6 @@ export function GeneralSettingsPanel() { ) : null} - - - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } + description="Choose the default model and workspace for all projects or a specific project." control={ - + Project settings + } /> - ) : null} - - {isElectron || HOSTED_APP_CHANNEL ? ( @@ -2898,8 +2868,8 @@ export function GeneralSettingsPanel() { } /> - + {/* fork: base */} ); } @@ -3029,7 +2999,7 @@ export function ArchivedThreadsPanel() { title={ {isLoadingArchive ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f8f0254cda61..182201bd81bc 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -15,11 +15,13 @@ import { BlocksIcon, BotIcon, GitBranchIcon, + PanelsTopLeftIcon, KeyboardIcon, Link2Icon, PaletteIcon, SearchIcon, Settings2Icon, + WaypointsIcon, // fork: prism XIcon, } from "lucide-react"; import { useLocation, useNavigate, useRouterState } from "@tanstack/react-router"; @@ -55,6 +57,7 @@ import { type SettingsSearchItem, } from "./settingsSearch"; import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; +import { useForkVisibleSettingsNavItems } from "../../fork/useForkSettingsNav"; // fork: prism const T3ConnectSidebarSignIn = lazy(() => import("../clerk/T3ConnectSidebarSignIn").then((module) => ({ @@ -72,15 +75,17 @@ const SETTINGS_SECTION_ICONS: Readonly< > = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, + "/settings/prism": WaypointsIcon, // fork: prism "/settings/integrations": BlocksIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, "/settings/archived": ArchiveIcon, }; -export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ +const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsPath; icon: ComponentType<{ className?: string }>; @@ -153,6 +158,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { null, ); const searchableItems = useAvailableSettingsSearchItems(); + const navItems = useForkVisibleSettingsNavItems(SETTINGS_NAV_ITEMS); // fork: prism const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; @@ -274,12 +280,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { + if ( + item.to !== "/settings/projects" && + pathname === item.to && + currentHash.replace(/^#/, "") === targetId + ) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, @@ -406,7 +418,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { ) : ( - {SETTINGS_NAV_ITEMS.map((item) => { + {navItems.map((item) => { + // fork: prism const Icon = item.icon; const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; const isActive = activeSettingsPath === item.to; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index ee1fa66a3db4..6d9d20105224 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,4 +1,5 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; @@ -281,7 +282,7 @@ function DiscoveryItemRow({ return (
    @@ -431,7 +432,7 @@ function SourceControlSectionSkeleton({ return ( {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( -
    +
    @@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({ @@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() { disabled={discovery.isPending} aria-label="Rescan server environment" > - + } /> diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b270bf7b8e4d..eb6620a136fb 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,10 +1,5 @@ -import { - ExternalLinkIcon, - PackagePlusIcon, - PaletteIcon, - RefreshCwIcon, - SearchIcon, -} from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { importOpenVsxThemeExtension, @@ -417,7 +412,7 @@ export function ThemeSearchSection({ {isInstalling ? ( ) : isInstalled ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/ThemeWireframe.tsx b/apps/web/src/components/settings/ThemeWireframe.tsx index ce4f13f208e5..895d8d1eecb7 100644 --- a/apps/web/src/components/settings/ThemeWireframe.tsx +++ b/apps/web/src/components/settings/ThemeWireframe.tsx @@ -4,7 +4,7 @@ import type { ThemeCardPreviewColors } from "./ThemePreviewCircles"; // A simple miniature of the app: sidebar, a short conversation, the // composer, and the orchestrator panel floating over the interface as an // island with horizontal agent rows. -export function ThemeWireframePane({ +function ThemeWireframePane({ colors, clip, }: { diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index e7af8337d961..cd82a7727ac5 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog"; import { searchableSetting } from "./settingsSearch"; import { SettingsRow, SettingsSection } from "./settingsLayout"; +import { PrismUsageProviderRow, UsageProvidersEmptyRow } from "~/fork/prism/PrismUsageProviderRow"; // fork: prism /** Hub management follows the selected device and access rules of provider settings. */ export function UsageProviderSettings({ @@ -47,8 +48,9 @@ export function UsageProviderSettings({ ) : null } > + {/* fork: prism */} {entries.length === 0 ? ( - + // fork: prism ) : ( entries.map(([id, source]) => { const label = source.label?.trim() || source.url; diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts index 0d48057206df..15de96e2f182 100644 --- a/apps/web/src/components/settings/customModelEditor.logic.ts +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -104,7 +104,7 @@ export const DESCRIPTOR_PRESETS_BY_KIND: Partial< }; let nextKey = 0; -export function newEditorKey(): string { +function newEditorKey(): string { nextKey += 1; return `k${nextKey}`; } @@ -140,7 +140,7 @@ export function emptyEditorChoice(): EditorChoice { * by built-in runtime profiles a custom entry does not have, so they are * dropped rather than stored as a plain option value. */ -export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { +function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { const promptInjected = new Set( descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], ); diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts index e207c9ff7a78..0bad52bcb033 100644 --- a/apps/web/src/components/settings/itemRows.ts +++ b/apps/web/src/components/settings/itemRows.ts @@ -1,5 +1,5 @@ -/** Direct row in a settings section. Whitespace, rather than rules, separates peers. */ -export const ITEM_ROW_CLASSNAME = "rounded-xl px-3 py-3 sm:px-4"; +/** Direct row in a grouped settings section. Round only outer corners; the parent owns borders and separators. */ +export const ITEM_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4"; export const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index a782632b10c8..4bf4da3919ba 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -43,7 +43,7 @@ export interface ProviderClientDefinition { readonly badgeLabel?: string; } -export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ { value: ProviderDriverKind.make("codex"), label: "Codex", @@ -84,7 +84,7 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = }, ]; -export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< +const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< Record > = Object.fromEntries( PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 000000000000..46dc7e262512 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,71 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderSummary } from "./providerStatus"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getProviderSummary", () => { + it("reports ready providers with unknown authentication as available", () => { + expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ + headline: "Available", + detail: null, + }); + }); + + it("does not hide a provider error behind a previous authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + message: "The provider process failed to start.", + }), + ).toEqual({ + headline: "Unavailable", + detail: "The provider process failed to start.", + }); + }); + + it("does not hide a provider warning behind an authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "warning", + message: "The provider version is unsupported.", + }), + ).toEqual({ + headline: "Needs attention", + detail: "The provider version is unsupported.", + }); + }); + + it("keeps authentication failures actionable when their provider status is error", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + message: "Run codex login.", + }), + ).toEqual({ + headline: "Not authenticated", + detail: "Run codex login.", + }); + }); + + it("treats a disabled provider status as disabled even before its enabled flag updates", () => { + expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 0f39f643f5ce..90c618f5daa7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; * settings page. Prefers `provider.message` for server-supplied detail and * falls back to generic phrasing when the server has not yet reported any * state — which happens before the first probe or when an instance names a - * driver this build does not ship. + * driver this build does not ship. A ready provider without account metadata + * remains available and does not imply an authentication failure. */ export function getProviderSummary(provider: ServerProvider | undefined) { if (!provider) { @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: "Waiting for the server to report installation and authentication details.", }; } - if (!provider.enabled) { + if (!provider.enabled || provider.status === "disabled") { return { headline: "Disabled", detail: @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.auth.status === "authenticated") { - const authLabel = provider.auth.label ?? provider.auth.type; - return { - headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", - detail: provider.message ?? null, - }; - } if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "The provider failed its startup checks.", }; } + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; + return { + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", + detail: provider.message ?? null, + }; + } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: provider.message ?? null, }; } diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..1bfa8c87146e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -284,7 +284,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
    @@ -320,10 +324,12 @@ export function SettingsRow({ export function SettingResetButton({ label, + tooltip = "Reset to default", disabled = false, onClick, }: { label: string; + tooltip?: string; disabled?: boolean; onClick: () => void; }) { @@ -345,7 +351,7 @@ export function SettingResetButton({ } /> - Reset to default + {tooltip} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7358ed8f9a17..9907d328de8f 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,7 +1,9 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; +import { FORK_PRISM_SETTINGS_SEARCH_ITEMS } from "../../fork/forkSettingsSearch"; // fork: prism export type SettingsPath = + | "/settings/projects" | "/settings/general" | "/settings/appearance" | "/settings/keybindings" @@ -9,6 +11,7 @@ export type SettingsPath = | "/settings/integrations" | "/settings/source-control" | "/settings/connections" + | "/settings/prism" // fork: prism | "/settings/archived"; export interface SettingsSearchItem { @@ -49,8 +52,10 @@ export interface SettingsSearchAvailability { export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", + "/settings/projects": "Projects", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", + "/settings/prism": "Prism", // fork: prism "/settings/integrations": "Integrations", "/settings/source-control": "Source Control", "/settings/connections": "Connections", @@ -63,6 +68,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "project-defaults", + title: "Project defaults and overrides", + to: "/settings/projects", + searchTerms: [ + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + ], + }, { id: "color-scheme", title: "Color scheme", @@ -235,14 +248,13 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "new-threads", title: "New threads", - to: "/settings/general", + to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, { id: "start-from-origin", title: "Start from origin", to: "/settings/general", - targetId: "new-threads", searchTerms: ["new worktrees latest matching remote branch local"], }, { @@ -312,6 +324,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["project thread tree old flat list"], }, + { + id: "q1code-feature-flags", + title: "q1code feature flags", + to: "/settings/general", + searchTerms: ["fork flags T3FORK fork.json update-check prism"], + }, // fork: base + ...FORK_PRISM_SETTINGS_SEARCH_ITEMS, // fork: prism { id: "keybindings", title: "Keybindings", @@ -345,7 +364,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "agent-browser-access", title: "Agent browser access", - to: "/settings/integrations", + to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, { @@ -505,6 +524,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 9306226b8c86..b790c307a3d9 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -14,7 +14,7 @@ const THEME_PAINT_KIND_ORDER: ReadonlyArray = [ "foreground", ]; -export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; +const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; const THEME_TOKEN_PROBE_COLOR = "#01fea7"; diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index a2f5ca62766f..33c44b5e4d5a 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -12,6 +12,7 @@ import { primaryServerConfigAtom } from "~/state/server"; import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; import { filterAvailableSettingsSearchItems } from "./settingsSearch"; +import { isForkSettingsSearchItemVisible } from "../../fork/forkSettingsSearch"; // fork: base export function useAvailableSettingsSearchItems() { const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -43,7 +44,7 @@ export function useAvailableSettingsSearchItems() { }), hasThreadAutoSettlement: primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, - }), + }).filter(isForkSettingsSearchItemVisible(primaryServerConfig?.environment.capabilities)), // fork: base [ canManageLocalBackend, desktopWsl.data, diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 60fa379ce30e..833559e1cc27 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,8 +1,7 @@ -import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; -import { cn } from "../../lib/utils"; - const DOWNLOAD_PROGRESS_RADIUS = 14; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; @@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({ if (status === "downloaded") return ; return ( - ); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 4f115a751422..f400ace3f7fa 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,3 +1,4 @@ +import { PrismNavigation } from "../../fork/prism/PrismNavigation"; // fork: prism import { ArrowLeftIcon, ChartNoAxesColumnIcon, @@ -216,6 +217,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { /> )} + {/* fork: prism */} ); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 84dd7f4b5634..066b7e583253 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,7 +1,8 @@ +import { Spinner } from "~/components/ui/spinner"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; -import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; import { primaryServerProvidersAtom } from "../../state/server"; @@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() { onClick={openProviderSettings} > {displayedView.tone === "loading" ? ( - + ) : displayedView.tone === "success" ? ( ) : displayedView.tone === "error" ? ( diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index e5f3db03c3f5..d82c85dd7711 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -19,10 +19,12 @@ function CollapsibleTrigger({ className, ...props }: CollapsiblePrimitive.Trigge } function CollapsiblePanel({ className, ...props }: CollapsiblePrimitive.Panel.Props) { + // Reuses the local shadcn/Base UI panel; skip height travel for reduced motion. + // https://ui.shadcn.com/docs/components/base/collapsible return ( - {children} - + {children} + ); } -function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) { +function ComboboxChipRemove({ + labelId, + ...props +}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) { + const removeLabelId = `${labelId}-remove`; + return ( - + + Remove + + ); } diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index d7892cb228ab..fbdcbc03480a 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -1,7 +1,7 @@ "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { ChevronRightIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -177,6 +177,23 @@ function MenuRadioItem({ ); } +function MenuRadioItemIndicator({ + className, + children, + ...props +}: MenuPrimitive.RadioItemIndicator.Props) { + return ( + + {children ?? } + + ); +} + function MenuGroupLabel({ className, inset, @@ -300,6 +317,7 @@ export { MenuRadioGroup as DropdownMenuRadioGroup, MenuRadioItem, MenuRadioItem as DropdownMenuRadioItem, + MenuRadioItemIndicator, MenuGroupLabel, MenuGroupLabel as DropdownMenuLabel, MenuSeparator, diff --git a/apps/web/src/components/ui/refresh-icon.tsx b/apps/web/src/components/ui/refresh-icon.tsx new file mode 100644 index 000000000000..6fbddeb365a6 --- /dev/null +++ b/apps/web/src/components/ui/refresh-icon.tsx @@ -0,0 +1,20 @@ +import { RefreshCwIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; + +/** Keep the refresh glyph in place while its owning action is running. */ +export function RefreshIcon({ + refreshing = false, + className, + ...props +}: React.ComponentPropsWithoutRef & { refreshing?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index e2d29d607e13..784c5e087963 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { - SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, @@ -89,17 +88,6 @@ describe("sidebar interactive cursors", () => { expect(html).not.toContain("cursor-pointer"); }); - it("uses a pointer cursor for menu actions", () => { - const html = renderToStaticMarkup( - - + - , - ); - - expect(html).toContain('data-slot="sidebar-menu-action"'); - expect(html).toContain("cursor-pointer"); - }); - it("uses a pointer cursor for submenu buttons", () => { const html = renderToStaticMarkup( }>Show more, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..22cb4808fadd 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -800,7 +800,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -875,38 +875,6 @@ function SidebarMenuButton({ ); } -function SidebarMenuAction({ - className, - showOnHover = false, - render, - ...props -}: useRender.ComponentProps<"button"> & { - showOnHover?: boolean; -}) { - const defaultProps = { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", - className, - ), - "data-sidebar": "menu-action", - "data-slot": "sidebar-menu-action", - }; - - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">(defaultProps, props), - render, - }); -} - function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
    ) { +function Spinner({ className, ...props }: React.ComponentPropsWithoutRef) { return ( - diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..0f6483c2ae67 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,5 +1,7 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { Toast } from "@base-ui/react/toast"; import { useEffect, @@ -20,7 +22,6 @@ import { CircleCheckIcon, CopyIcon, InfoIcon, - LoaderCircleIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; @@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, - loading: LoaderCircleIcon, + loading: Spinner, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; @@ -357,7 +358,7 @@ function ToastBodyContent({ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" data-slot="toast-icon" > - +
    ) : null}
    = { @@ -52,14 +45,14 @@ const PACE: Record @@ -95,14 +88,16 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + // The fill is quota left, so the even-spending mark is the time left. + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${Math.round(used)}% used${ - elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` + const summary = `${window.label}: ${remaining}% left${ + timeLeft === null ? "" : `, ${timeLeft}% of the window left` }${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -118,27 +113,26 @@ function WindowBar({ } >
    - {used > 0 ? ( + {remaining > 0 ? (
    ) : null} - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null}
    - {Math.round(used)}% used - {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} + {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - {elapsed !== null ? ( + {timeLeft !== null ? ( The line is where even spending would be. ) : null} {resetsAt ? ( @@ -153,24 +147,31 @@ function WindowBar({ ); } -/** One account's windows as rows: label and percent, bar, pace and countdown. */ -function LimitWindows({ +/** + * One account's windows as rows: label and percent, bar, pace and countdown. + * Compact rows fit the composer panel with narrower columns. + */ +export function LimitWindows({ driver, windows, now, + compact = false, }: { readonly driver: ServerProvider["driver"]; readonly windows: ReadonlyArray; readonly now: number; + readonly compact?: boolean; }) { const color = barColor(driver); return ( -
    - {windows.map((window, index) => { - // Windows that reset together show the countdown once. - const previous = windows[index - 1]; - const sharesReset = - previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; +
    + {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -178,13 +179,13 @@ function LimitWindows({ {window.label} - {Math.round(window.usedPercent)}% + {remainingPercent(window)}% left - + {pace ? : null} - {sharesReset ? "" : (resetsIn ?? "")} + {resetsIn ?? ""} ); @@ -193,94 +194,6 @@ function LimitWindows({ ); } -/** - * Heading shared by local providers and source accounts: icon, driver, instance, plan, - * and the signed-in email blurred until clicked, as provider settings do. - */ -function AccountHeading({ - driver, - label, - instanceLabel, - plan, - email, - accentColor, -}: { - readonly driver: ServerProvider["driver"]; - readonly label: string; - readonly instanceLabel: string; - readonly plan: string | undefined; - readonly email: string | undefined; - readonly accentColor?: string | undefined; -}) { - return ( -

    - - {label} - {instanceLabel !== label ? ( - - · {instanceLabel} - - ) : null} - {plan ? · {plan} : null} - {email ? ( - - ) : null} -

    - ); -} - -function ProviderLimits({ - provider, - environmentId, - now, -}: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; -}) { - const limits = provider.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); - return ( -
    - getDriverOption(driver)?.label)} - plan={provider.auth.label} - email={provider.auth.email} - accentColor={provider.accentColor} - /> - {notice ? ( - {notice} - ) : ( - - )} - {limits.resetCredits ? ( - - ) : null} -
    - ); -} - const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", @@ -288,36 +201,12 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -/** - * Banked reset credits with a confirmed redeem action. Redeeming spends a - * credit the provider granted the user, so it never fires on a bare click. - */ -function ResetCredits({ - environmentId, - instanceId, - credits, - now, -}: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - readonly credits: ServerProviderResetCredits; - readonly now: number; -}) { +/** Everything a redeem needs: where to send it and what to say afterwards. */ +export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; - - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; const redeem = async () => { setConfirming(false); @@ -336,95 +225,99 @@ function ResetCredits({ ); }; - return ( -
    - {summary} - {credits.availableCount > 0 ? ( - - ) : null} - {status ? {status} : null} - - - - Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - - - - }>Cancel - - - - -
    - ); + return { confirming, setConfirming, busy, status, redeem }; } -/** One account pooled by a usage-limit source, drawn like a provider row. */ -function SourceAccountLimits({ - account, - sourceKind, - now, +/** + * The confirm for a redeem. Redeeming spends a credit the provider granted the + * user, so it never fires on a bare click. Mount it outside any popover that + * holds the button: dialogs stack under popovers, and closing the popover + * would unmount a dialog rendered inside it. + */ +export function ResetCreditDialog({ + open, + onOpenChange, + onConfirm, }: { - readonly account: UsageLimitSourceAccount; - readonly sourceKind: string; - readonly now: number; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onConfirm: () => void; }) { - const notice = limitsNotice(account.usageLimits); return ( -
    - - {notice ? ( - {notice} - ) : ( - - )} -
    + + + + Use a reset credit? + + This redeems one credit on your account and clears the current rate-limit windows. It + cannot be undone. + + + + }>Cancel + + + + ); } -const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLI Proxy", -}; - -type LimitsSource = ReturnType[number]; +/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ +export function resetCreditsSummary( + credits: ServerProviderResetCredits, + now: number, + compact = false, +): string { + const expiresIn = credits.nextExpiresAt + ? formatDuration(Date.parse(credits.nextExpiresAt) - now) + : null; + if (credits.availableCount === 0) return "No reset credits banked"; + if (compact) + return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; + return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · next expires in ${expiresIn}` : "" + }`; +} -/** Read-only accounts pooled by a configured usage source. */ -function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const kind = SOURCE_KIND_LABEL[source.kind]; +/** Banked reset credits with the redeem button and its confirm, self-contained. */ +export function ResetCredits({ + environmentId, + instanceId, + credits, + now, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly credits: ServerProviderResetCredits; + readonly now: number; +}) { + const { confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + instanceId, + ); + if (credits.availableCount === 0 && status === null) return null; return ( -
    - {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account) => ( - - )) - )} +
    + {resetCreditsSummary(credits, now)} + {credits.availableCount > 0 ? ( + + ) : null} + {status ? {status} : null} + void redeem()} + />
    ); } /** - * Subscription quota windows from every connected environment's providers. - * Countdowns anchor to render time rather than ticking: a live clock would - * repaint the page every minute for no decision-changing gain. + * Subscription quota across every connected environment's providers and hubs, + * pooled per provider. Countdowns anchor to render time rather than ticking: a + * live clock would repaint the page every minute for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, @@ -432,42 +325,11 @@ export function UsageLimitsSection({ readonly selectedEnvironmentIds: ReadonlySet | null; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount on purpose: countdowns must not tick (see above). + const [now] = useState(() => Date.now()); const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const groups = collectLimitsGroups(selected); - const sources = collectLimitSources(selected); - // Anchored once per mount on purpose: countdowns must not tick (see below). - const [now] = useState(() => Date.now()); - - return ( -
    - {groups.length === 0 && sources.length === 0 ? ( -

    - No provider on the selected environments reports subscription limits. -

    - ) : null} - {sources.map((source) => ( - - ))} - {groups.map((group) => ( -
    - {group.environmentLabel ? ( -

    - {group.environmentLabel} -

    - ) : null} - {group.providers.map((provider) => ( - - ))} -
    - ))} -
    - ); + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..1d57cdb96340 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -0,0 +1,570 @@ +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + type LimitAccount, + type LimitPool, + type LimitPoolMember, + type LimitPoolWindow, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import { TicketIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PaceIcon, + ResetCreditDialog, + barColor, + resetCreditsSummary, + useResetCredit, +} from "./UsageLimits"; + +/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ +function accountInitials(email: string): string { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; +} + +/** A stable hue per email, so the same account gets the same chip on every visit. */ +function accountHue(email: string): number { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +/** The two-letter chip for an email, coloured by a stable hue per address. */ +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + {accountInitials(email)} + + ); +} + +/** + * The same mark the model picker uses for a native instance (provider glyph, + * initials badge, accent); hub accounts have no instance, so they get the chip. + */ +function AccountAvatar({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.redeem) { + return ( + + ); + } + return account.email ? : null; +} + +/** + * Who an account is, without printing the email: the instance name when there + * is one, else a two-letter chip. The address itself is revealed on demand in + * the segment's popover. + */ +function AccountName({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.displayName) return {account.displayName}; + if (account.email) { + return ( + + + + ); + } + return ( + + {getDriverOption(account.driver)?.label ?? String(account.driver)} + + ); +} + +function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
    + {label} + {children} +
    + ); +} + +/** + * Everything about one account in one window: plan, where it is signed in, + * the email on request, reset time and share of the pool it restores, and the + * reset-credit action. Opens on hover for a glance, on click to act. + */ +function SegmentPopover({ + account, + window, + reset, + now, + redeem, + onRedeem, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + /** Redeem state owned by the segment, since the confirm lives outside this popover. */ + readonly redeem: ReturnType | null; + readonly onRedeem: () => void; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const where = + account.environments.length > 0 + ? account.environments.map((environment) => environment.label).join(", ") + : account.sourceLabel; + const credits = + redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + return ( +
    +
    + + + + {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + + + {account.email ? ( + + ) : null} +
    +
    + {account.plan ? {account.plan} : null} + {where ? ( + 0 ? "Signed in" : "Via"}>{where} + ) : null} +
    +
    + {remaining}% + {window.resetsAt ? ( + + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + +{reset.restoresPercent}% of pool + ) : null} +
    + {credits && redeem ? ( +
    + + {resetCreditsSummary(credits, now, true)} + + +
    + ) : null} +
    + ); +} + +/** + * One account's share of one pooled window: the segment, its popover, and the + * reset confirm. The confirm is a sibling of the popover, not a child: dialogs + * stack under popovers, and the popover closes as the confirm opens. + */ +function PoolSegment({ + account, + window, + reset, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly color: string; + readonly now: number; + /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ + readonly index: number; +}) { + const [open, setOpen] = useState(false); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */} +
    + {/* The spent share is hatched, not blank: it is what the countdown restores. */} + {remaining < 100 && reset ? ( +
    + ) : null} + + {index} + +
    + + {remaining}% + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
    + + + {account.redeem ? ( + setOpen(false)} + /> + ) : ( + + {}} + /> + + )} + + ); +} + +/** + * Below the strip at narrow widths: one row per account in bar order, carrying + * the text the segment has no room for. Tapping a row opens the same popover + * as its segment, so the two are one control with two handles. + */ +function LegendRow({ + account, + window, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly color: string; + readonly now: number; + readonly index: number; +}) { + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + + Segment + {index} + + + {remaining}% + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + {credits} reset {credits === 1 ? "credit" : "credits"} banked + + + ) : null} + + + ); +} + +/** Split out so the redeem hook only runs for accounts that can redeem. */ +function RedeemableSegmentPopup({ + account, + window, + reset, + now, + redeemAt, + closePopover, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + readonly redeemAt: NonNullable; + readonly closePopover: () => void; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); + return ( + <> + + { + closePopover(); + redeem.setConfirming(true); + }} + /> + + void redeem.redeem()} + /> + {/* The popover closed before the confirm, so the outcome needs a home outside it. */} + {redeem.status ? ( + + {redeem.status} + + ) : null} + + ); +} + +/** + * One pooled window as equal-width segments, one per account, each filled by + * the share of that account's quota still open. Equal widths are honest: every + * account contributes the same share of the pool, whatever its plan. + * + * Wide, each segment carries its own label. Narrow, the bar is a bare strip + * and a legend below lists the accounts in the same order; both open the + * same popover. + */ +function PoolBar({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); + return ( +
    +
    + {pool.members.map(({ account, window }, position) => ( + + ))} +
    +
    + ); +} + +/** + * Big pooled number and the segment bar. The bar is sorted by reset, so who + * refills next is its left edge; the exact time and share restored live in + * each segment's popover rather than a list restating the bar. + */ +function PoolWindowCard({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + // The soonest reset that hands anything back; an untouched account resets to no effect. + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + return ( +
    +
    + {pool.label} + + + {pool.remainingPercent}% + + left + {pool.pace ? : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} +
    + +
    + ); +} + +function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { + const color = barColor(pool.driver); + const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + return ( +
    +

    + + {label} +

    + {pool.windows.map((window) => ( + + ))} +
    + ); +} + +/** + * Accounts pooled per provider: what is open across all of them, who resets + * next, and how much of the pool that hands back. Answers "can I keep going" + * before "on which account". + */ +export function UsageLimitsPooled({ + presentations, + now, +}: { + readonly presentations: Parameters[0]; + readonly now: number; +}) { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const notices = collectLimitNotices(presentations); + return ( +
    + {pools.length === 0 ? ( +

    + No provider on the selected environments reports subscription limits. +

    + ) : null} + {pools.map((pool) => ( + + ))} + +
    + ); +} + +/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ +function LimitNotices({ notices }: { readonly notices: readonly string[] }) { + if (notices.length === 0) return null; + return ( +
      + {notices.map((notice) => ( +
    • {notice}
    • + ))} +
    + ); +} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..e41843e6d9cc 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens", + metric: "cost" as "cost" | "tokens" | "limits", breakdown: "time" as "model" | "time", })); @@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: vi.fn((initial: unknown) => [ - typeof initial === "function" - ? { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, + initial === readUsagePagePreferences + ? { metric: testState.metric, windowDays: 30 } + : typeof initial === "function" + ? { + days: 1, + window: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + resolution: "hour", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + }, + } + : initial === "cost" + ? testState.metric + : initial === "model" + ? testState.breakdown + : initial, vi.fn(), ]), }; @@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => { }); import { UsagePage } from "./UsagePage"; +import { readUsagePagePreferences } from "./usagePagePreferences"; const providerTotals = (codex: number, claude: number) => new Map([ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index e957002115ab..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { USAGE_CONTRACT_VERSION, @@ -8,10 +9,9 @@ import { CircleAlertIcon, ChevronDownIcon, CircleDashedIcon, - RefreshCwIcon, SlidersHorizontalIcon, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; +import { + readUsagePagePreferences, + saveUsagePagePreferences, + type UsagePagePreferences, +} from "./usagePagePreferences"; type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ @@ -81,13 +86,24 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { + return WINDOW_OPTIONS.some((option) => option.days === value); +} + export function UsagePage() { + const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), + days: preferences.windowDays, + window: makeWindow( + preferences.windowDays, + undefined, + preferences.windowDays === 1 ? "hour" : "day", + ), })); - const [metric, setMetric] = useState("cost"); + const metric = preferences.metric; const showingLimits = metric === "limits"; + const [isRefreshing, setIsRefreshing] = useState(false); + const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); @@ -132,32 +148,54 @@ export function UsagePage() { const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { + if (!isUsageWindowDays(days)) return; + const nextPreferences = { metric, windowDays: days }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectMetric = (nextMetric: UsageMetric) => { + const nextPreferences = { metric: nextMetric, windowDays }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + }; const refreshWindow = () => { + if (refreshingRef.current) return; + if (showingLimits) { - for (const [environmentId, presentation] of presentations) { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - void refreshProviders({ environmentId, input: {} }); - } - } + refreshingRef.current = true; + setIsRefreshing(true); + void Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshProviders({ environmentId, input: {} }); + } + }), + ).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); return; } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setIsRefreshing(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -195,7 +233,7 @@ export function UsagePage() { value={[metric]} onValueChange={(next) => { const value = next[0]; - if (isUsageMetric(value)) setMetric(value); + if (isUsageMetric(value)) selectMetric(value); }} > {METRIC_OPTIONS.map((option) => ( @@ -225,17 +263,19 @@ export function UsagePage() {
    setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + discardRef.current = true; + setDraft(null); + event.currentTarget.blur(); + } + }} + /> + ); +} diff --git a/apps/web/src/fork/prism/PrismAddAccount.tsx b/apps/web/src/fork/prism/PrismAddAccount.tsx new file mode 100644 index 000000000000..27b5c36b6a0f --- /dev/null +++ b/apps/web/src/fork/prism/PrismAddAccount.tsx @@ -0,0 +1,286 @@ +import type { PrismLoginProvider } from "@q1code/core/prismApi"; +import { useEffect, useReducer, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { SettingsRow, SettingsSection } from "~/components/settings/settingsLayout"; +import { searchableSetting } from "~/components/settings/settingsSearch"; +import { cn } from "~/lib/utils"; + +import { + PRISM_LOGIN_PROVIDER_LABELS, + PRISM_LOGIN_PROVIDERS, + IDLE_LOGIN_FLOW, + pendingPrismLoginSession, + reducePrismLoginFlow, +} from "./prismAccountsState"; +import { CopyValueButton, openExternalUrl, reportPrismError } from "./prismUi"; +import { type PrismApi, describePrismCallError } from "./usePrismApi"; + +const LOGIN_POLL_MS = 2_000; + +/** + * The OAuth sign-in flow. The reducer owns the transitions; effects only poll + * and hand a finished sign-in to the parent through `onCompleted`. + */ +export function PrismAddAccountSection({ + api, + writable, + onCompleted, +}: { + readonly api: PrismApi | null; + /** The accounts list loaded, so a new sign-in has somewhere to land. */ + readonly writable: boolean; + /** Called once per completed sign-in with the new account id when the sidecar reports it. Keep it stable. */ + readonly onCompleted: (accountId: string | null) => void; +}) { + const [flow, dispatch] = useReducer(reducePrismLoginFlow, IDLE_LOGIN_FLOW); + const [provider, setProvider] = useState("codex"); + const [redirectDraft, setRedirectDraft] = useState(""); + const pendingSession = pendingPrismLoginSession(flow); + + useEffect(() => { + if (pendingSession === null || api === null) return; + let cancelled = false; + const poll = async () => { + const result = await api.loginStatus(pendingSession); + if (cancelled) return; + if (result._tag === "ok") { + dispatch({ type: "status", status: result.value }); + } else if (result.error._tag === "PrismNotFoundError") { + dispatch({ + type: "status", + status: { + sessionId: pendingSession, + status: "failed", + error: "The sign-in session expired before it finished.", + }, + }); + } + }; + const interval = window.setInterval(() => void poll(), LOGIN_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [pendingSession, api]); + + useEffect(() => { + if (flow._tag !== "completed") return; + setRedirectDraft(""); + onCompleted(flow.accountId); + }, [flow, onCompleted]); + + const startLogin = async () => { + if (api === null) return; + dispatch({ type: "start", provider }); + const result = await api.startLogin(provider); + if (result._tag === "error") { + dispatch({ type: "startFailed", error: describePrismCallError(result.error) }); + return; + } + dispatch({ type: "started", started: result.value }); + }; + + const cancelLogin = async () => { + if (api === null || flow._tag !== "pending") return; + const sessionId = flow.sessionId; + dispatch({ type: "cancel" }); + setRedirectDraft(""); + const result = await api.cancelLogin(sessionId); + if (result._tag === "error" && result.error._tag !== "PrismNotFoundError") { + reportPrismError("Could not cancel the sign-in", result.error); + } + }; + + const submitRedirect = async () => { + if (api === null || flow._tag !== "pending" || flow.submittingRedirect) return; + const redirectUrl = redirectDraft.trim(); + if (redirectUrl.length === 0) return; + const sessionId = flow.sessionId; + dispatch({ type: "pasteRedirect" }); + const result = await api.completeLogin(sessionId, redirectUrl); + if (result._tag === "error") { + dispatch({ + type: "redirectFailed", + sessionId, + error: describePrismCallError(result.error), + }); + return; + } + dispatch({ type: "status", status: result.value }); + }; + + const loginBusy = flow._tag === "starting" || flow._tag === "pending"; + + return ( + + + + + + } + > + {flow._tag === "pending" ? ( +
    +
    +

    + Finish signing in to {PRISM_LOGIN_PROVIDER_LABELS[flow.provider]} +

    + + {flow.userCode ? ( +
    + Enter code + + {flow.userCode} + + +
    + ) : null} +

    + Waiting for the provider to call back. This checks every few seconds. +

    +
    + {flow.flow === "redirect" ? ( +
    + +
    + setRedirectDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void submitRedirect(); + }} + /> + +
    + {flow.redirectError ? ( +

    {flow.redirectError}

    + ) : null} +
    + ) : null} +
    + +
    +
    + ) : flow._tag === "completed" ? ( + dispatch({ type: "reset" })} + /> + ) : flow._tag === "failed" ? ( + dispatch({ type: "reset" })} + /> + ) : flow._tag === "cancelled" ? ( + dispatch({ type: "reset" })} + /> + ) : null} +
    +
    + ); +} + +function FlowNotice({ + tone, + text, + onDismiss, +}: { + readonly tone: "success" | "error" | "muted"; + readonly text: string; + readonly onDismiss: () => void; +}) { + return ( +
    + + {text} + + +
    + ); +} diff --git a/apps/web/src/fork/prism/PrismNavigation.tsx b/apps/web/src/fork/prism/PrismNavigation.tsx new file mode 100644 index 000000000000..212f3150d371 --- /dev/null +++ b/apps/web/src/fork/prism/PrismNavigation.tsx @@ -0,0 +1,25 @@ +import { useNavigate } from "@tanstack/react-router"; +import { WaypointsIcon } from "lucide-react"; +import { SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/components/ui/sidebar"; +import { useForkFlag } from "../useForkFlag"; + +export function PrismNavigation() { + const enabled = useForkFlag("prism"); + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + if (!enabled) return null; + return ( + + { + if (isMobile) setOpenMobile(false); + void navigate({ to: "/prism" }); + }} + > + + Prism + + + ); +} diff --git a/apps/web/src/fork/prism/PrismPage.tsx b/apps/web/src/fork/prism/PrismPage.tsx new file mode 100644 index 000000000000..bdf8f6480d57 --- /dev/null +++ b/apps/web/src/fork/prism/PrismPage.tsx @@ -0,0 +1,18 @@ +import { WorkspacePageHeader } from "~/components/WorkspacePageHeader"; +import { SidebarInset } from "~/components/ui/sidebar"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { isElectron } from "~/env"; +import { PrismSettingsPanel } from "./PrismSettingsPanel"; + +export function PrismPage() { + return ( + + +

    Prism

    +
    + + + +
    + ); +} diff --git a/apps/web/src/fork/prism/PrismSettingsPanel.tsx b/apps/web/src/fork/prism/PrismSettingsPanel.tsx new file mode 100644 index 000000000000..5a24f347158c --- /dev/null +++ b/apps/web/src/fork/prism/PrismSettingsPanel.tsx @@ -0,0 +1,564 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + PrismAccount, + PrismState, + PrismStatus, + PrismSyncStatus, + PrismUnavailableReason, +} from "@q1code/core/prismApi"; +import { PrismRoutingStrategy, FORK_CONFIG_FILENAME } from "@q1code/core/config"; +import { readForkFlag } from "@t3tools/client-runtime/fork"; +import { AlertCircleIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; + +import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "~/components/settings/settingsLayout"; +import { searchableSetting } from "~/components/settings/settingsSearch"; +import { ensureLocalApi } from "~/localApi"; +import { usePrimaryEnvironmentId } from "~/state/environments"; +import { primaryServerConfigAtom } from "~/state/server"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { PrismAddAccountSection } from "./PrismAddAccount"; +import { + PrismAccountsEmpty, + PrismAccountsSkeleton, + PrismAccountsTable, +} from "./PrismAccountsTable"; +import { PrismStatusSection } from "./PrismStatusSection"; +import { + describePrismRestart, + describePrismUnavailable, + formatPrismSyncInterval, + INITIAL_PRISM_ACCOUNTS, + reducePrismAccounts, + resolvePrismMode, + resolvePrismUsageSource, +} from "./prismAccountsState"; +import { describePrismAccount, MonoValue, reportPrismError, useDocumentVisible } from "./prismUi"; +import { + type PrismApi, + describePrismCallError, + isPrismPermissionError, + usePrismApi, +} from "./usePrismApi"; + +const STATUS_POLL_MS = 10_000; +/** While a restart is in flight the status poll tightens until the proxy settles. */ +const RESTART_POLL_MS = 1_000; +/** A restart that has not settled by then stops looking pending; the badge keeps telling the truth. */ +const RESTART_PENDING_MAX_MS = 90_000; +const NEW_ACCOUNT_HIGHLIGHT_MS = 6_000; + +const ROUTING_LABELS: Readonly> = { + "round-robin": "Round robin", + "weighted-round-robin": "Weighted round robin", + "fill-first": "Fill first", +}; + +type PanelData = + | { readonly _tag: "idle" } + | { readonly _tag: "ready"; readonly routing: PrismRoutingStrategy | null } + | { + readonly _tag: "unavailable"; + readonly reason: PrismUnavailableReason; + readonly state: PrismState; + } + | { readonly _tag: "forbidden"; readonly message: string }; + +type SyncView = + | { readonly _tag: "idle" } + | { readonly _tag: "ready"; readonly value: PrismSyncStatus } + | { + readonly _tag: "unavailable"; + readonly reason: PrismUnavailableReason; + readonly state: PrismState; + } + | { readonly _tag: "error"; readonly message: string }; + +/** The Prism tab. Off flag, missing primary, and the live panel each get a calm page of their own. */ +export function PrismSettingsPanel() { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const config = useAtomValue(primaryServerConfigAtom); + if (primaryEnvironmentId === null) { + return ( + + ); + } + if (!config) { + return ( + + ); + } + if (!readForkFlag(config.environment.capabilities, "prism")) { + return ( + + ); + } + return ; +} + +function PrismNotice({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( + + + + + + ); +} + +function PrismSettingsPanelBody() { + const api = usePrismApi(); + const visible = useDocumentVisible(); + + const [statusView, setStatusView] = useState<{ + readonly status: PrismStatus; + readonly receivedAt: number; + } | null>(null); + const [statusError, setStatusError] = useState(null); + const [sync, setSync] = useState({ _tag: "idle" }); + const [restart, setRestart] = useState<{ readonly startedAt: number } | null>(null); + + const [data, setData] = useState({ _tag: "idle" }); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [accounts, dispatchAccounts] = useReducer(reducePrismAccounts, INITIAL_PRISM_ACCOUNTS); + // Set once the list loaded (or the server said no); cleared to force a reload. + const dataLoadedRef = useRef(false); + const loadGenerationRef = useRef(0); + + const loadData = useCallback(async () => { + if (api === null) return; + const generation = ++loadGenerationRef.current; + setLoading(true); + const [accountsResult, routing] = await Promise.all([api.listAccounts(), api.getRouting()]); + if (generation !== loadGenerationRef.current) return; + setLoading(false); + if (accountsResult._tag === "error") { + const error = accountsResult.error; + if (error._tag === "PrismUnavailableError") { + setData({ _tag: "unavailable", reason: error.reason, state: error.state }); + } else if (isPrismPermissionError(error)) { + dataLoadedRef.current = true; + setData({ _tag: "forbidden", message: describePrismCallError(error) }); + } else { + setLoadError(describePrismCallError(error)); + } + return; + } + dataLoadedRef.current = true; + setLoadError(null); + dispatchAccounts({ type: "loaded", accounts: accountsResult.value }); + setData({ _tag: "ready", routing: routing._tag === "ok" ? routing.value.strategy : null }); + }, [api]); + + // What every status answer means for the rest of the panel: the list loads + // once the proxy is ready, goes away when it is not, and a pending restart + // settles on ready or failed. + const applyStatus = useCallback( + (status: PrismStatus) => { + if (status.state !== "ready") { + dataLoadedRef.current = false; + setData({ _tag: "unavailable", reason: "sidecar-not-ready", state: status.state }); + } else if (!dataLoadedRef.current) { + void loadData(); + } + setRestart((current) => + current !== null && + (status.state === "ready" || + status.state === "failed" || + Date.now() - current.startedAt > RESTART_PENDING_MAX_MS) + ? null + : current, + ); + }, + [loadData], + ); + + const restarting = restart !== null; + + // Status and sync poll only while the panel is mounted and the document is visible. + useEffect(() => { + if (!visible || api === null) return; + let cancelled = false; + const tick = async () => { + const [statusResult, syncResult] = await Promise.all([api.status(), api.syncStatus()]); + if (cancelled) return; + if (syncResult._tag === "ok") { + setSync({ _tag: "ready", value: syncResult.value }); + } else if (syncResult.error._tag === "PrismUnavailableError") { + setSync({ + _tag: "unavailable", + reason: syncResult.error.reason, + state: syncResult.error.state, + }); + } else { + setSync({ _tag: "error", message: describePrismCallError(syncResult.error) }); + } + if (statusResult._tag === "error") { + setStatusError(describePrismCallError(statusResult.error)); + return; + } + setStatusError(null); + setStatusView({ status: statusResult.value, receivedAt: Date.now() }); + applyStatus(statusResult.value); + }; + void tick(); + const interval = window.setInterval( + () => void tick(), + restarting ? RESTART_POLL_MS : STATUS_POLL_MS, + ); + return () => { + cancelled = true; + window.clearInterval(interval); + loadGenerationRef.current += 1; + }; + }, [visible, api, applyStatus, restarting]); + + const restartProxy = async () => { + if (api === null || restarting || statusView === null) return; + const confirmed = await ensureLocalApi().dialogs.confirm( + describePrismRestart(resolvePrismMode(statusView.status)), + ); + if (!confirmed) return; + setRestart({ startedAt: Date.now() }); + const result = await api.restart(); + if (result._tag === "error") { + setRestart(null); + reportPrismError("Could not restart the proxy", result.error); + return; + } + setStatusView({ status: result.value, receivedAt: Date.now() }); + applyStatus(result.value); + }; + + const refreshAccounts = useCallback(async () => { + if (api === null) return; + const result = await api.listAccounts(); + if (result._tag === "error") { + reportPrismError("Could not refresh accounts", result.error); + return; + } + dispatchAccounts({ type: "loaded", accounts: result.value }); + }, [api]); + + const [highlightedAccountId, setHighlightedAccountId] = useState(null); + useEffect(() => { + if (highlightedAccountId === null) return; + const timeout = window.setTimeout( + () => setHighlightedAccountId(null), + NEW_ACCOUNT_HIGHLIGHT_MS, + ); + return () => window.clearTimeout(timeout); + }, [highlightedAccountId]); + + const handleLoginCompleted = useCallback( + (accountId: string | null) => { + setHighlightedAccountId(accountId); + void refreshAccounts(); + }, + [refreshAccounts], + ); + + const patchAccount = async ( + account: PrismAccount, + patch: Parameters[1], + failureTitle: string, + ) => { + if (api === null) return; + dispatchAccounts({ type: "patchStarted", id: account.id, patch }); + const result = await api.patchAccount(account.id, patch); + if (result._tag === "error") { + dispatchAccounts({ type: "patchFailed", id: account.id }); + reportPrismError(failureTitle, result.error); + return; + } + dispatchAccounts({ type: "patchSucceeded", id: account.id, account: result.value }); + }; + + const deleteAccount = async (account: PrismAccount) => { + if (api === null) return; + const confirmed = await ensureLocalApi().dialogs.confirm( + `Remove ${describePrismAccount(account)} from Prism? Its auth file is deleted on the server; sign in again to add it back.`, + { variant: "destructive" }, + ); + if (!confirmed) return; + dispatchAccounts({ type: "deleteStarted", id: account.id }); + const result = await api.deleteAccount(account.id); + if (result._tag === "error") { + dispatchAccounts({ type: "deleteFailed", id: account.id }); + reportPrismError("Could not remove account", result.error); + return; + } + dispatchAccounts({ type: "deleteSucceeded", id: account.id }); + }; + + // The switch flips at once and holds the requested value until the server + // answers; a failure drops it, so the switch falls back to the last status. + const [usageSourceRequest, setUsageSourceRequest] = useState(null); + const changeUsageSource = async (enabled: boolean) => { + if (api === null || usageSourceRequest !== null) return; + setUsageSourceRequest(enabled); + const result = await api.setUsageSource(enabled); + setUsageSourceRequest(null); + if (result._tag === "error") { + reportPrismError( + enabled ? "Could not show accounts on Usage" : "Could not hide accounts from Usage", + result.error, + ); + return; + } + setStatusView({ status: result.value, receivedAt: Date.now() }); + }; + + const [routingBusy, setRoutingBusy] = useState(false); + const changeRouting = async (strategy: PrismRoutingStrategy) => { + if (api === null) return; + setRoutingBusy(true); + const result = await api.setRouting(strategy); + setRoutingBusy(false); + if (result._tag === "error") { + reportPrismError("Could not change routing strategy", result.error); + return; + } + setData((previous) => + previous._tag === "ready" ? { ...previous, routing: result.value.strategy } : previous, + ); + }; + + const writable = data._tag === "ready"; + const accountWritable = writable && statusView?.status.role !== "replica"; + const showList = data._tag === "idle" || data._tag === "ready"; + + return ( + + void restartProxy()} + usageSource={ + usageSourceRequest ?? + (statusView === null ? null : resolvePrismUsageSource(statusView.status)) + } + usageSourcePending={usageSourceRequest !== null} + onUsageSourceChange={(enabled) => void changeUsageSource(enabled)} + /> + + { + dataLoadedRef.current = false; + void loadData(); + }} + > + + Refresh + + } + > + {data._tag === "unavailable" ? ( + + ) : data._tag === "forbidden" ? ( + + ) : ( + <> + {loadError ? ( + + + Could not load accounts + {loadError} + + + + + ) : null} + {accounts.accounts === null ? ( + loadError ? null : ( + + ) + ) : accounts.accounts.length === 0 ? ( + + ) : ( + + + void patchAccount( + account, + { disabled: !enabled }, + enabled ? "Could not enable account" : "Could not disable account", + ) + } + onWeight={(account, weight) => + void patchAccount(account, { weight }, "Could not change weight") + } + onDelete={(account) => void deleteAccount(account)} + /> + + )} + + )} + + + + + + { + if (value === null) return; + void changeRouting(value as PrismRoutingStrategy); + }} + > + + + {writable && data.routing ? ROUTING_LABELS[data.routing] : "Unknown"} + + + + {PrismRoutingStrategy.literals.map((strategy) => ( + + {ROUTING_LABELS[strategy]} + + ))} + + + } + /> + + + + + ); +} + +/** Read-only view of cross-machine sync; configured in fork.json, never from the UI. */ +function PrismSyncSection({ sync }: { readonly sync: SyncView }) { + return ( + + {sync._tag === "idle" ? ( + + ) : sync._tag === "unavailable" ? ( + + ) : sync._tag === "error" ? ( + {sync.message}} + /> + ) : ( + <> + {sync.value.role}} + /> + {sync.value.primaryUrl ? ( + {sync.value.primaryUrl}} + /> + ) : null} + {sync.value.intervalSeconds !== undefined ? ( + {formatPrismSyncInterval(sync.value.intervalSeconds)}} + /> + ) : null} + + {sync.value.lastSyncAt + ? formatRelativeTimeLabel(sync.value.lastSyncAt) || sync.value.lastSyncAt + : "Never"} + + } + /> + {sync.value.lastSyncError ? ( + + {sync.value.lastSyncError} + + } + /> + ) : null} + + )} + + ); +} diff --git a/apps/web/src/fork/prism/PrismStatusSection.tsx b/apps/web/src/fork/prism/PrismStatusSection.tsx new file mode 100644 index 000000000000..bd4762bb19f4 --- /dev/null +++ b/apps/web/src/fork/prism/PrismStatusSection.tsx @@ -0,0 +1,138 @@ +import type { PrismStatus } from "@q1code/core/prismApi"; +import { FORK_CONFIG_FILENAME } from "@q1code/core/config"; +import { Link } from "@tanstack/react-router"; + +import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; +import { SettingsRow, SettingsSection } from "~/components/settings/settingsLayout"; +import { searchableSetting } from "~/components/settings/settingsSearch"; + +import { + PRISM_MODE_LABELS, + describePrismEngine, + describePrismMode, + resolvePrismMode, + summarizePrismStatus, +} from "./prismAccountsState"; +import { PrismStateBadge, CopyValueButton, MonoValue } from "./prismUi"; + +export function PrismStatusSection({ + status, + receivedAt, + statusError, + canRestart, + restarting, + onRestart, + usageSource, + usageSourcePending, + onUsageSourceChange, +}: { + readonly status: PrismStatus | null; + /** When `status` arrived; the "for 3m" label counts from here, so it moves with each poll. */ + readonly receivedAt: number; + readonly statusError: string | null; + readonly canRestart: boolean; + readonly restarting: boolean; + readonly onRestart: () => void; + /** The Limits-view publication toggle as it should show right now (optimistic while a change is in flight); `null` until known. */ + readonly usageSource: boolean | null; + readonly usageSourcePending: boolean; + readonly onUsageSourceChange: (enabled: boolean) => void; +}) { + const mode = status === null ? null : resolvePrismMode(status); + return ( + + {statusError} + ) : status ? ( + summarizePrismStatus(status, receivedAt) + ) : ( + "Checking the proxy…" + ) + } + control={status ? : null} + /> + {mode === null ? "—" : PRISM_MODE_LABELS[mode]}} + /> + + {status.baseUrl} + + + ) : ( + Not published until ready + ) + } + /> + {status ? describePrismEngine(status) : "—"}} + /> + {status?.lastError ? ( + + {status.lastError} + + } + /> + ) : null} + + The accounts in the pool appear on the Limits view labelled Prism, beside the accounts + your providers report. Saved as prism.usageSource in {FORK_CONFIG_FILENAME}.{" "} + + Open Usage + + + } + control={ + onUsageSourceChange(Boolean(checked))} + aria-label="Show pooled accounts on Usage → Limits" + /> + } + /> + + {restarting ? "Restarting…" : "Restart"} + + } + /> + + ); +} diff --git a/apps/web/src/fork/prism/PrismUsageProviderRow.tsx b/apps/web/src/fork/prism/PrismUsageProviderRow.tsx new file mode 100644 index 000000000000..9a2aa6497f17 --- /dev/null +++ b/apps/web/src/fork/prism/PrismUsageProviderRow.tsx @@ -0,0 +1,92 @@ +/** + * Prism's row in Settings → Providers → Usage providers (the + * `UsageProviderSettings.tsx` seam). With the environment's `prism` flag on + * it is the first row of the list: the managed source, its origin, and + * whether the pooled accounts reach Usage → Limits, linking to the Prism tab + * where the toggle lives. It is not removable here. The empty-state row + * yields to it and renders upstream's text otherwise, so with the flag off + * the section reads exactly as upstream. + */ +import type { PrismStatus } from "@q1code/core/prismApi"; +import { readForkFlag } from "@t3tools/client-runtime/fork"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { SettingsRow } from "~/components/settings/settingsLayout"; +import { Button } from "~/components/ui/button"; +import { useServerConfigs } from "~/state/entities"; + +import { describePrismUsageProvider } from "./prismAccountsState"; +import { useDocumentVisible } from "./prismUi"; +import { describePrismCallError, usePrismApi } from "./usePrismApi"; + +const STATUS_POLL_MS = 10_000; + +/** The `prism` flag as that environment's server reports it; off for servers that do not know the flag. */ +function useEnvironmentPrismFlag(environmentId: EnvironmentId): boolean { + const configs = useServerConfigs(); + return readForkFlag(configs.get(environmentId)?.environment.capabilities, "prism"); +} + +export function PrismUsageProviderRow({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const enabled = useEnvironmentPrismFlag(environmentId); + return enabled ? : null; +} + +function PrismUsageProviderRowBody({ environmentId }: { readonly environmentId: EnvironmentId }) { + const api = usePrismApi(environmentId); + const visible = useDocumentVisible(); + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + // Same cadence as the Prism tab, and only while the page is visible. + useEffect(() => { + if (!visible || api === null) return; + let cancelled = false; + const tick = async () => { + const result = await api.status(); + if (cancelled) return; + if (result._tag === "error") { + setError(describePrismCallError(result.error)); + return; + } + setError(null); + setStatus(result.value); + }; + void tick(); + const interval = window.setInterval(() => void tick(), STATUS_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [visible, api]); + + const view = describePrismUsageProvider(status); + return ( + {view.description}} + status={error ? {error} : view.status} + control={ + + } + /> + ); +} + +/** Upstream's "No usage providers configured." row, unless Prism fills the list. */ +export function UsageProvidersEmptyRow({ + environmentId, +}: { + readonly environmentId: EnvironmentId; +}) { + const enabled = useEnvironmentPrismFlag(environmentId); + return enabled ? null : ; +} diff --git a/apps/web/src/fork/prism/prismAccountsState.test.ts b/apps/web/src/fork/prism/prismAccountsState.test.ts new file mode 100644 index 000000000000..eeb101866bcd --- /dev/null +++ b/apps/web/src/fork/prism/prismAccountsState.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { PrismAccount } from "@q1code/core/prismApi"; +import { UsageLimitSourceId } from "@t3tools/contracts"; + +import { + IDLE_LOGIN_FLOW, + INITIAL_PRISM_ACCOUNTS, + type PrismLoginFlowState, + describePrismEngine, + describePrismMode, + describePrismRestart, + describePrismUnavailable, + describePrismUsageProvider, + formatPrismSince, + formatPrismSyncInterval, + isPrismAccountPending, + parsePrismWeight, + pendingPrismLoginSession, + prismUsageSourceKindLabel, + reducePrismAccounts, + reducePrismLoginFlow, + resolvePrismMode, + resolvePrismUsageSource, + summarizePrismStatus, +} from "./prismAccountsState.ts"; + +const started = { + sessionId: "session-1", + authUrl: "https://auth.example.test/start", + flow: "redirect" as const, +}; + +function pending(): PrismLoginFlowState { + return reducePrismLoginFlow( + reducePrismLoginFlow(IDLE_LOGIN_FLOW, { type: "start", provider: "codex" }), + { type: "started", started }, + ); +} + +describe("prism login flow", () => { + it("walks idle -> starting -> pending -> completed with the new account id", () => { + const starting = reducePrismLoginFlow(IDLE_LOGIN_FLOW, { + type: "start", + provider: "anthropic", + }); + expect(starting).toEqual({ _tag: "starting", provider: "anthropic" }); + + const waiting = reducePrismLoginFlow(starting, { + type: "started", + started: { ...started, flow: "device", userCode: "ABCD-EFGH" }, + }); + expect(waiting).toMatchObject({ + _tag: "pending", + provider: "anthropic", + sessionId: "session-1", + flow: "device", + userCode: "ABCD-EFGH", + submittingRedirect: false, + }); + expect(pendingPrismLoginSession(waiting)).toBe("session-1"); + + const done = reducePrismLoginFlow(waiting, { + type: "status", + status: { sessionId: "session-1", status: "completed", accountId: "claude-1.json" }, + }); + expect(done).toEqual({ _tag: "completed", provider: "anthropic", accountId: "claude-1.json" }); + expect(pendingPrismLoginSession(done)).toBeNull(); + }); + + it("ignores answers for another session", () => { + const waiting = pending(); + const stale = reducePrismLoginFlow(waiting, { + type: "status", + status: { sessionId: "session-0", status: "completed", accountId: "old.json" }, + }); + expect(stale).toBe(waiting); + expect( + reducePrismLoginFlow(waiting, { + type: "redirectFailed", + sessionId: "session-0", + error: "nope", + }), + ).toBe(waiting); + }); + + it("keeps polling through a pasted redirect and surfaces a rejected one", () => { + const submitting = reducePrismLoginFlow(pending(), { type: "pasteRedirect" }); + expect(submitting).toMatchObject({ _tag: "pending", submittingRedirect: true }); + expect(pendingPrismLoginSession(submitting)).toBe("session-1"); + + const stillPending = reducePrismLoginFlow(submitting, { + type: "status", + status: { sessionId: "session-1", status: "pending" }, + }); + expect(stillPending).toMatchObject({ _tag: "pending", submittingRedirect: false }); + + const rejected = reducePrismLoginFlow( + reducePrismLoginFlow(stillPending, { type: "pasteRedirect" }), + { type: "redirectFailed", sessionId: "session-1", error: "state mismatch" }, + ); + expect(rejected).toMatchObject({ + _tag: "pending", + submittingRedirect: false, + redirectError: "state mismatch", + }); + // The next paste clears the old rejection. + expect(reducePrismLoginFlow(rejected, { type: "pasteRedirect" })).toMatchObject({ + redirectError: null, + submittingRedirect: true, + }); + }); + + it("maps failed and cancelled answers, defaulting the failure text", () => { + expect( + reducePrismLoginFlow(pending(), { + type: "status", + status: { sessionId: "session-1", status: "failed" }, + }), + ).toEqual({ _tag: "failed", provider: "codex", error: "The sign-in did not complete." }); + expect( + reducePrismLoginFlow(pending(), { + type: "status", + status: { sessionId: "session-1", status: "cancelled" }, + }), + ).toEqual({ _tag: "cancelled", provider: "codex" }); + expect( + reducePrismLoginFlow( + { _tag: "starting", provider: "xai" }, + { type: "startFailed", error: "sidecar down" }, + ), + ).toEqual({ _tag: "failed", provider: "xai", error: "sidecar down" }); + }); + + it("cancels optimistically from starting or pending and resets to idle", () => { + const cancelled = reducePrismLoginFlow(pending(), { type: "cancel" }); + expect(cancelled).toEqual({ _tag: "cancelled", provider: "codex" }); + expect(pendingPrismLoginSession(cancelled)).toBeNull(); + expect( + reducePrismLoginFlow({ _tag: "starting", provider: "kimi" }, { type: "cancel" }), + ).toEqual({ + _tag: "cancelled", + provider: "kimi", + }); + expect(reducePrismLoginFlow(cancelled, { type: "cancel" })).toBe(cancelled); + expect(reducePrismLoginFlow(cancelled, { type: "reset" })).toBe(IDLE_LOGIN_FLOW); + }); + + it("refuses a second start while one flow is waiting", () => { + const waiting = pending(); + expect(reducePrismLoginFlow(waiting, { type: "start", provider: "xai" })).toBe(waiting); + // Terminal states can start over. + expect( + reducePrismLoginFlow( + { _tag: "failed", provider: "codex", error: "x" }, + { type: "start", provider: "xai" }, + ), + ).toEqual({ _tag: "starting", provider: "xai" }); + }); + + it("drops events that do not apply to the current state", () => { + expect(reducePrismLoginFlow(IDLE_LOGIN_FLOW, { type: "started", started })).toBe( + IDLE_LOGIN_FLOW, + ); + expect(reducePrismLoginFlow(IDLE_LOGIN_FLOW, { type: "pasteRedirect" })).toBe(IDLE_LOGIN_FLOW); + }); +}); + +describe("prism label helpers", () => { + it("explains each unavailable reason with the env/config hint", () => { + expect(describePrismUnavailable("flag-off", "off")).toContain("T3FORK_PRISM=1"); + expect(describePrismUnavailable("flag-off", "off")).toContain("fork.json"); + expect(describePrismUnavailable("sidecar-not-ready", "starting")).toContain("starting"); + expect(describePrismUnavailable("sidecar-not-ready", "failed")).toContain("failed to start"); + expect(describePrismUnavailable("sync-not-configured", "ready")).toContain("prism.sync"); + }); + + it("names the engine with its release and where it runs", () => { + expect(describePrismEngine({ mode: "sidecar", version: "7.2.147" })).toBe( + "CLIProxyAPI v7.2.147 (bundled)", + ); + expect(describePrismEngine({ mode: "external", version: "7.1.0" })).toBe( + "CLIProxyAPI v7.1.0 (external)", + ); + expect(describePrismEngine({})).toBe("CLIProxyAPI (bundled)"); + }); + + it("accepts only changed non-negative integers as weights", () => { + expect(parsePrismWeight("3", 1)).toBe(3); + expect(parsePrismWeight(" 0 ", undefined)).toBe(0); + expect(parsePrismWeight("3", 3)).toBeNull(); + expect(parsePrismWeight("-1", 1)).toBeNull(); + expect(parsePrismWeight("1.5", 1)).toBeNull(); + expect(parsePrismWeight("", 1)).toBeNull(); + expect(parsePrismWeight("abc", 1)).toBeNull(); + }); + + it("labels only the managed usage source as Prism", () => { + expect(prismUsageSourceKindLabel({ id: UsageLimitSourceId.make("prism") })).toBe("Prism"); + expect(prismUsageSourceKindLabel({ id: UsageLimitSourceId.make("my-hub") })).toBeUndefined(); + }); +}); + +describe("prism usage provider row", () => { + it("treats a status without the toggle as publishing", () => { + expect(resolvePrismUsageSource({})).toBe(true); + expect(resolvePrismUsageSource({ usageSource: false })).toBe(false); + }); + + it("describes the managed source, its origin, and whether it publishes", () => { + expect(describePrismUsageProvider(null)).toEqual({ + description: "Managed by q1code", + status: "Checking the proxy…", + }); + expect( + describePrismUsageProvider({ state: "ready", baseUrl: "http://127.0.0.1:8317" }), + ).toEqual({ + description: "Managed by q1code · http://127.0.0.1:8317", + status: "Pooled accounts are shown on Usage → Limits.", + }); + expect( + describePrismUsageProvider({ + state: "ready", + baseUrl: "http://127.0.0.1:8317", + usageSource: false, + }).status, + ).toContain("Not shown on Usage → Limits"); + const starting = describePrismUsageProvider({ state: "starting" }); + expect(starting.description).toBe("Managed by q1code"); + expect(starting.status).toContain("Proxy starting"); + expect(describePrismUsageProvider({ state: "failed" }).status).toContain("Proxy failed"); + }); +}); + +describe("prism status helpers", () => { + const nowMs = Date.parse("2026-09-02T12:00:00.000Z"); + + it("treats a status without a mode as the sidecar", () => { + expect(resolvePrismMode({})).toBe("sidecar"); + expect(resolvePrismMode({ mode: "external" })).toBe("external"); + expect(describePrismMode("external")).toContain("restart re-checks the connection"); + expect(describePrismMode("sidecar")).toContain("relaunches"); + expect(describePrismRestart("sidecar")).toContain("Restart"); + expect(describePrismRestart("external")).toContain("Re-check"); + }); + + it("formats since as an elapsed duration and skips it when absent", () => { + expect(formatPrismSince(undefined, nowMs)).toBeNull(); + expect(formatPrismSince("not a date", nowMs)).toBeNull(); + expect(formatPrismSince("2026-09-02T11:59:59.000Z", nowMs)).toBe("just now"); + expect(formatPrismSince("2026-09-02T11:57:00.000Z", nowMs)).toBe("for 3m"); + expect(formatPrismSince("2026-09-01T12:00:00.000Z", nowMs)).toBe("for 1d"); + }); + + it("summarizes state, uptime, and restarts on one line", () => { + expect(summarizePrismStatus({ state: "ready" }, nowMs)).toBe("Ready"); + expect( + summarizePrismStatus( + { state: "ready", since: "2026-09-02T11:57:00.000Z", restarts: 0 }, + nowMs, + ), + ).toBe("Ready for 3m"); + expect( + summarizePrismStatus( + { state: "starting", since: "2026-09-02T11:59:50.000Z", restarts: 1 }, + nowMs, + ), + ).toBe("Starting for 10s · 1 restart"); + expect(summarizePrismStatus({ state: "failed", restarts: 4 }, nowMs)).toBe( + "Failed · 4 restarts", + ); + }); + + it("formats the sync interval in minutes when even", () => { + expect(formatPrismSyncInterval(300)).toBe("every 5 minutes"); + expect(formatPrismSyncInterval(60)).toBe("every 1 minute"); + expect(formatPrismSyncInterval(90)).toBe("every 90 seconds"); + expect(formatPrismSyncInterval(1)).toBe("every 1 second"); + }); +}); + +describe("prism accounts reducer", () => { + const claude: PrismAccount = { + id: "claude-1.json", + provider: "claude", + label: "claude-1", + email: "a@example.test", + disabled: false, + weight: 1, + updatedAt: "2026-09-02T00:00:00.000Z", + }; + const codex: PrismAccount = { + id: "codex-1.json", + provider: "codex", + label: "codex-1", + disabled: true, + updatedAt: "2026-09-02T00:00:00.000Z", + }; + const loaded = reducePrismAccounts(INITIAL_PRISM_ACCOUNTS, { + type: "loaded", + accounts: [claude, codex], + }); + + it("starts without a list and takes the first one as is", () => { + expect(INITIAL_PRISM_ACCOUNTS.accounts).toBeNull(); + expect(loaded.accounts).toEqual([claude, codex]); + expect(loaded.pending.size).toBe(0); + }); + + it("applies a toggle at once, then keeps the server's row", () => { + const started = reducePrismAccounts(loaded, { + type: "patchStarted", + id: claude.id, + patch: { disabled: true }, + }); + expect(started.accounts?.[0]).toEqual({ ...claude, disabled: true }); + expect(isPrismAccountPending(started, claude.id)).toBe(true); + expect(isPrismAccountPending(started, codex.id)).toBe(false); + + const fromServer = { ...claude, disabled: true, updatedAt: "2026-09-02T00:00:01.000Z" }; + const done = reducePrismAccounts(started, { + type: "patchSucceeded", + id: claude.id, + account: fromServer, + }); + expect(done.accounts?.[0]).toBe(fromServer); + expect(isPrismAccountPending(done, claude.id)).toBe(false); + }); + + it("rolls a failed patch back to the row it replaced", () => { + const started = reducePrismAccounts(loaded, { + type: "patchStarted", + id: claude.id, + patch: { weight: 5 }, + }); + expect(started.accounts?.[0]?.weight).toBe(5); + const rolledBack = reducePrismAccounts(started, { type: "patchFailed", id: claude.id }); + expect(rolledBack.accounts?.[0]).toBe(claude); + expect(rolledBack.pending.size).toBe(0); + }); + + it("allows one mutation per row at a time and ignores unknown rows", () => { + const started = reducePrismAccounts(loaded, { + type: "patchStarted", + id: claude.id, + patch: { disabled: true }, + }); + expect( + reducePrismAccounts(started, { + type: "patchStarted", + id: claude.id, + patch: { disabled: false }, + }), + ).toBe(started); + expect(reducePrismAccounts(started, { type: "deleteStarted", id: claude.id })).toBe(started); + expect( + reducePrismAccounts(loaded, { + type: "patchStarted", + id: "missing.json", + patch: { disabled: true }, + }), + ).toBe(loaded); + expect(reducePrismAccounts(loaded, { type: "patchFailed", id: "missing.json" })).toEqual( + loaded, + ); + }); + + it("keeps a row inert through a delete and drops it only once the server confirms", () => { + const started = reducePrismAccounts(loaded, { type: "deleteStarted", id: codex.id }); + expect(started.accounts).toEqual([claude, codex]); + expect(isPrismAccountPending(started, codex.id)).toBe(true); + + const failed = reducePrismAccounts(started, { type: "deleteFailed", id: codex.id }); + expect(failed.accounts).toEqual([claude, codex]); + expect(failed.pending.size).toBe(0); + + const removed = reducePrismAccounts(started, { type: "deleteSucceeded", id: codex.id }); + expect(removed.accounts).toEqual([claude]); + expect(removed.pending.size).toBe(0); + }); + + it("does not invent a list when a mutation answers before the first load", () => { + const succeeded = reducePrismAccounts(INITIAL_PRISM_ACCOUNTS, { + type: "patchSucceeded", + id: claude.id, + account: claude, + }); + expect(succeeded.accounts).toBeNull(); + expect( + reducePrismAccounts(INITIAL_PRISM_ACCOUNTS, { type: "deleteSucceeded", id: claude.id }) + .accounts, + ).toBeNull(); + }); +}); diff --git a/apps/web/src/fork/prism/prismAccountsState.ts b/apps/web/src/fork/prism/prismAccountsState.ts new file mode 100644 index 000000000000..5ddd2bcf5b86 --- /dev/null +++ b/apps/web/src/fork/prism/prismAccountsState.ts @@ -0,0 +1,408 @@ +/** + * Pure state for the Prism settings tab: the "Add account" login flow and the + * accounts list (optimistic edits with rollback) as reducers, plus the label + * helpers the tab and the mobile card render from. No React, no network; the + * tab wires these to `@t3tools/client-runtime/fork`. + */ +import { + type PrismAccount, + type PrismAccountPatch, + type PrismLoginProvider, + type PrismLoginStarted, + type PrismLoginStatus, + type PrismState, + type PrismStatus, + type PrismUnavailableReason, + PRISM_USAGE_SOURCE_ID, + PRISM_USAGE_SOURCE_LABEL, +} from "@q1code/core/prismApi"; +import { type PrismMode, FORK_CONFIG_FILENAME } from "@q1code/core/config"; +import { envVarForFlag } from "@q1code/core/flags"; +import type { UsageLimitSourceSnapshot } from "@t3tools/contracts"; + +import { formatElapsedDurationLabel } from "~/timestampFormat"; + +export const PRISM_LOGIN_PROVIDERS: ReadonlyArray = [ + "codex", + "anthropic", + "antigravity", + "xai", + "kimi", +]; + +export const PRISM_LOGIN_PROVIDER_LABELS: Readonly> = { + codex: "Codex (OpenAI)", + anthropic: "Anthropic (Claude)", + antigravity: "Antigravity (Google)", + xai: "xAI (Grok)", + kimi: "Kimi (Moonshot)", +}; + +/** Sidecar provider keys are lowercase words; show the ones we know by name. */ +const ACCOUNT_PROVIDER_LABELS: Readonly> = { + claude: "Claude", + anthropic: "Claude", + codex: "Codex", + openai: "Codex", + gemini: "Gemini", + antigravity: "Antigravity", + xai: "Grok", + grok: "Grok", + kimi: "Kimi", +}; + +export function labelPrismProvider(provider: string): string { + return ACCOUNT_PROVIDER_LABELS[provider.toLowerCase()] ?? provider; +} + +export type PrismLoginFlowState = + | { readonly _tag: "idle" } + | { readonly _tag: "starting"; readonly provider: PrismLoginProvider } + | { + readonly _tag: "pending"; + readonly provider: PrismLoginProvider; + readonly sessionId: string; + readonly authUrl: string; + readonly flow: PrismLoginStarted["flow"]; + readonly userCode: string | null; + /** A pasted redirect URL is in flight; polling keeps going meanwhile. */ + readonly submittingRedirect: boolean; + /** The last pasted redirect the sidecar rejected; cleared on the next paste. */ + readonly redirectError: string | null; + } + | { + readonly _tag: "completed"; + readonly provider: PrismLoginProvider; + readonly accountId: string | null; + } + | { readonly _tag: "failed"; readonly provider: PrismLoginProvider; readonly error: string } + | { readonly _tag: "cancelled"; readonly provider: PrismLoginProvider }; + +export type PrismLoginFlowEvent = + | { readonly type: "start"; readonly provider: PrismLoginProvider } + | { readonly type: "started"; readonly started: PrismLoginStarted } + | { readonly type: "startFailed"; readonly error: string } + /** A poll, callback, or cancel answer. Answers for another session are ignored. */ + | { readonly type: "status"; readonly status: PrismLoginStatus } + | { readonly type: "pasteRedirect" } + | { readonly type: "redirectFailed"; readonly sessionId: string; readonly error: string } + | { readonly type: "cancel" } + | { readonly type: "reset" }; + +export const IDLE_LOGIN_FLOW: PrismLoginFlowState = { _tag: "idle" }; + +const GENERIC_LOGIN_FAILURE = "The sign-in did not complete."; + +export function reducePrismLoginFlow( + state: PrismLoginFlowState, + event: PrismLoginFlowEvent, +): PrismLoginFlowState { + switch (event.type) { + case "start": + // A flow already waiting on the browser keeps its session; cancel first. + if (state._tag === "starting" || state._tag === "pending") return state; + return { _tag: "starting", provider: event.provider }; + case "started": + if (state._tag !== "starting") return state; + return { + _tag: "pending", + provider: state.provider, + sessionId: event.started.sessionId, + authUrl: event.started.authUrl, + flow: event.started.flow, + userCode: event.started.userCode ?? null, + submittingRedirect: false, + redirectError: null, + }; + case "startFailed": + if (state._tag !== "starting") return state; + return { _tag: "failed", provider: state.provider, error: event.error }; + case "status": { + if (state._tag !== "pending" || state.sessionId !== event.status.sessionId) return state; + switch (event.status.status) { + case "pending": + return state.submittingRedirect ? { ...state, submittingRedirect: false } : state; + case "completed": + return { + _tag: "completed", + provider: state.provider, + accountId: event.status.accountId ?? null, + }; + case "failed": + return { + _tag: "failed", + provider: state.provider, + error: event.status.error ?? GENERIC_LOGIN_FAILURE, + }; + case "cancelled": + return { _tag: "cancelled", provider: state.provider }; + } + return state; + } + case "pasteRedirect": + if (state._tag !== "pending" || state.submittingRedirect) return state; + return { ...state, submittingRedirect: true, redirectError: null }; + case "redirectFailed": + if (state._tag !== "pending" || state.sessionId !== event.sessionId) return state; + return { ...state, submittingRedirect: false, redirectError: event.error }; + case "cancel": + // Optimistic: the section stops polling at once, and a late "cancelled" + // answer for the old session is dropped by the session check above. + if (state._tag !== "pending" && state._tag !== "starting") return state; + return { _tag: "cancelled", provider: state.provider }; + case "reset": + return IDLE_LOGIN_FLOW; + } +} + +/** The session the section should keep polling, if any. */ +export function pendingPrismLoginSession(state: PrismLoginFlowState): string | null { + return state._tag === "pending" ? state.sessionId : null; +} + +export const PRISM_STATE_LABELS: Readonly> = { + off: "Off", + starting: "Starting", + ready: "Ready", + failed: "Failed", +}; + +export function describePrismUnavailable( + reason: PrismUnavailableReason, + state: PrismState, +): string { + switch (reason) { + case "flag-off": + return `Prism is off. Set ${envVarForFlag("prism")}=1 or flags.prism in ${FORK_CONFIG_FILENAME}, then restart the server.`; + case "sidecar-not-ready": + return state === "failed" + ? `Prism failed to start. Check the server log and the prism section of ${FORK_CONFIG_FILENAME} (binaryPath, port).` + : `Prism is ${PRISM_STATE_LABELS[state].toLowerCase()}. Accounts appear once the sidecar is ready.`; + case "replica-read-only": + return "Manage pooled accounts on the primary environment. This gateway receives serving credentials only."; + case "sync-not-configured": + return `Cross-machine sync is not configured for this role. Set prism.sync in ${FORK_CONFIG_FILENAME}.`; + } +} + +/** + * Text for a permission failure, phrased like the other settings sections: + * name the scope, never the token. + */ +export function describePrismPermissionError(input: { + readonly _tag: "EnvironmentScopeRequiredError" | "EnvironmentAuthInvalidError"; + readonly requiredScope?: string; +}): string { + return input._tag === "EnvironmentScopeRequiredError" + ? `Managing accounts requires the ${input.requiredScope ?? "access:write"} scope for this backend.` + : "This environment session is no longer valid. Refresh the page or pair again."; +} + +/** + * Weight input: integer at or above zero; `null` when unchanged or invalid so + * the caller can drop the edit instead of sending it. + */ +export function parsePrismWeight(raw: string, current: number | undefined): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + if (!Number.isSafeInteger(value) || value === current) return null; + return value; +} + +/** Servers older than the toggle publish their accounts: the fork.json default is true. */ +export function resolvePrismUsageSource(status: Pick): boolean { + return status.usageSource ?? true; +} + +export interface PrismUsageProviderView { + /** "Managed by q1code · http://127.0.0.1:8317"; the origin only once the proxy reported one. */ + readonly description: string; + /** Whether the pooled accounts reach Usage → Limits right now, in words. */ + readonly status: string; +} + +/** The Usage providers row for Prism: what it is, where it points, and whether it publishes. */ +export function describePrismUsageProvider( + status: Pick | null, +): PrismUsageProviderView { + const description = ["Managed by q1code", status?.baseUrl].filter(Boolean).join(" · "); + if (status === null) return { description, status: "Checking the proxy…" }; + if (status.state !== "ready") { + return { + description, + status: `Proxy ${PRISM_STATE_LABELS[status.state].toLowerCase()}. Accounts appear on Usage → Limits once it is ready.`, + }; + } + return { + description, + status: resolvePrismUsageSource(status) + ? "Pooled accounts are shown on Usage → Limits." + : "Not shown on Usage → Limits. Turn it on in the Prism tab.", + }; +} + +/** The `UsageLimits.tsx` seam: the managed source reads "Prism"; a hub the user added keeps upstream's label. */ +export function prismUsageSourceKindLabel( + source: Pick, +): string | undefined { + return source.id === PRISM_USAGE_SOURCE_ID ? PRISM_USAGE_SOURCE_LABEL : undefined; +} + +export const PRISM_MODE_LABELS: Readonly> = { + sidecar: "Sidecar", + external: "External", +}; + +/** Servers older than the `mode` field run the bundled sidecar. */ +export function resolvePrismMode(status: Pick): PrismMode { + return status.mode ?? "sidecar"; +} + +export function describePrismMode(mode: PrismMode): string { + switch (mode) { + case "sidecar": + return "q1code starts the bundled CLIProxyAPI engine on the primary environment and supervises it; restart stops and relaunches it."; + case "external": + return "q1code manages a proxy something else runs; restart re-checks the connection."; + } +} + +/** "CLIProxyAPI v7.2.147 (bundled)": the engine behind Prism, named only here. */ +export function describePrismEngine(status: Pick): string { + const release = status.version ? ` v${status.version}` : ""; + return `CLIProxyAPI${release} (${resolvePrismMode(status) === "external" ? "external" : "bundled"})`; +} + +/** Confirm-dialog text for the Restart button. */ +export function describePrismRestart(mode: PrismMode): string { + return mode === "external" + ? "Re-check the connection to the external proxy? Accounts and routing reload once it answers." + : "Restart the Prism sidecar? Claude and Codex instances routed through it fail requests until it is ready again."; +} + +/** "for 3m" after `since`, "just now" right after a state change, `null` without a usable timestamp. */ +export function formatPrismSince(since: string | undefined, nowMs: number): string | null { + if (since === undefined) return null; + const elapsed = formatElapsedDurationLabel(since, nowMs); + if (elapsed === "") return null; + return elapsed === "just now" ? elapsed : `for ${elapsed}`; +} + +/** One line under the state badge: "Ready for 3m · 2 restarts". */ +export function summarizePrismStatus( + status: Pick, + nowMs: number, +): string { + const label = PRISM_STATE_LABELS[status.state]; + const since = formatPrismSince(status.since, nowMs); + const parts = [since === null ? label : `${label} ${since}`]; + if (status.restarts !== undefined && status.restarts > 0) { + parts.push(`${status.restarts} restart${status.restarts === 1 ? "" : "s"}`); + } + return parts.join(" · "); +} + +/** Sync interval as people configure it: whole minutes when even, seconds otherwise. */ +export function formatPrismSyncInterval(seconds: number): string { + if (seconds >= 60 && seconds % 60 === 0) { + const minutes = seconds / 60; + return `every ${minutes} minute${minutes === 1 ? "" : "s"}`; + } + return `every ${seconds} second${seconds === 1 ? "" : "s"}`; +} + +export interface PrismAccountsState { + /** `null` until the first list arrives, so the table can tell "loading" from "empty". */ + readonly accounts: ReadonlyArray | null; + /** Rows with a mutation in flight, keyed by id, holding the row as it was before the optimistic change. */ + readonly pending: ReadonlyMap; +} + +export type PrismAccountsEvent = + | { readonly type: "loaded"; readonly accounts: ReadonlyArray } + /** Applies the patch to the row at once; `patchFailed` puts the old row back. */ + | { readonly type: "patchStarted"; readonly id: string; readonly patch: PrismAccountPatch } + | { readonly type: "patchSucceeded"; readonly id: string; readonly account: PrismAccount } + | { readonly type: "patchFailed"; readonly id: string } + /** The row stays visible but inert until the server confirms; nothing to roll back. */ + | { readonly type: "deleteStarted"; readonly id: string } + | { readonly type: "deleteSucceeded"; readonly id: string } + | { readonly type: "deleteFailed"; readonly id: string }; + +export const INITIAL_PRISM_ACCOUNTS: PrismAccountsState = { + accounts: null, + pending: new Map(), +}; + +function withoutPending( + pending: PrismAccountsState["pending"], + id: string, +): PrismAccountsState["pending"] { + if (!pending.has(id)) return pending; + const next = new Map(pending); + next.delete(id); + return next; +} + +function replaceAccount( + accounts: ReadonlyArray, + account: PrismAccount, +): ReadonlyArray { + return accounts.map((entry) => (entry.id === account.id ? account : entry)); +} + +export function reducePrismAccounts( + state: PrismAccountsState, + event: PrismAccountsEvent, +): PrismAccountsState { + switch (event.type) { + case "loaded": + return { ...state, accounts: event.accounts }; + case "patchStarted": { + const current = state.accounts?.find((entry) => entry.id === event.id); + // One mutation per row at a time; the row's controls are disabled meanwhile. + if (current === undefined || state.pending.has(event.id)) return state; + const optimistic: PrismAccount = { + ...current, + ...(event.patch.disabled === undefined ? {} : { disabled: event.patch.disabled }), + ...(event.patch.weight === undefined ? {} : { weight: event.patch.weight }), + }; + return { + accounts: replaceAccount(state.accounts ?? [], optimistic), + pending: new Map(state.pending).set(event.id, current), + }; + } + case "patchSucceeded": + return { + accounts: state.accounts === null ? null : replaceAccount(state.accounts, event.account), + pending: withoutPending(state.pending, event.id), + }; + case "patchFailed": { + const snapshot = state.pending.get(event.id); + return { + accounts: + state.accounts === null || snapshot === undefined + ? state.accounts + : replaceAccount(state.accounts, snapshot), + pending: withoutPending(state.pending, event.id), + }; + } + case "deleteStarted": { + const current = state.accounts?.find((entry) => entry.id === event.id); + if (current === undefined || state.pending.has(event.id)) return state; + return { ...state, pending: new Map(state.pending).set(event.id, current) }; + } + case "deleteSucceeded": + return { + accounts: state.accounts?.filter((entry) => entry.id !== event.id) ?? null, + pending: withoutPending(state.pending, event.id), + }; + case "deleteFailed": + return { ...state, pending: withoutPending(state.pending, event.id) }; + } +} + +export function isPrismAccountPending(state: PrismAccountsState, id: string): boolean { + return state.pending.has(id); +} diff --git a/apps/web/src/fork/prism/prismUi.tsx b/apps/web/src/fork/prism/prismUi.tsx new file mode 100644 index 000000000000..e139658df61d --- /dev/null +++ b/apps/web/src/fork/prism/prismUi.tsx @@ -0,0 +1,120 @@ +/** Small pieces the Prism sections share: state badge, copy button, error toast, visibility hook. */ +import type { PrismAccount, PrismState } from "@q1code/core/prismApi"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { type ReactNode, useRef, useSyncExternalStore } from "react"; + +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "~/components/ui/anchoredCopyToast"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; +import { ensureLocalApi } from "~/localApi"; + +import { PRISM_STATE_LABELS, labelPrismProvider } from "./prismAccountsState"; +import { type PrismCallError, describePrismCallError } from "./usePrismApi"; + +const STATE_BADGE_VARIANT: Readonly< + Record +> = { + off: "outline", + starting: "warning", + ready: "success", + failed: "error", +}; + +function subscribeVisibility(onChange: () => void) { + document.addEventListener("visibilitychange", onChange); + return () => document.removeEventListener("visibilitychange", onChange); +} + +/** Polling gates on this so a background tab costs nothing. */ +export function useDocumentVisible(): boolean { + return useSyncExternalStore( + subscribeVisibility, + () => document.visibilityState === "visible", + () => true, + ); +} + +export function reportPrismError(title: string, error: PrismCallError) { + toastManager.add( + stackedThreadToast({ type: "error", title, description: describePrismCallError(error) }), + ); +} + +export function openExternalUrl(url: string) { + void ensureLocalApi() + .shell.openExternal(url) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the sign-in page", + description: error instanceof Error ? error.message : "Copy the link instead.", + }), + ); + }); +} + +export function describePrismAccount(account: PrismAccount): string { + const name = account.email ?? account.label; + return `${labelPrismProvider(account.provider)} account ${name}`; +} + +export function PrismStateBadge({ state }: { readonly state: PrismState }) { + return {PRISM_STATE_LABELS[state]}; +} + +/** Read-only value in a settings row's control slot. */ +export function MonoValue({ + children, + muted = false, + className, +}: { + readonly children: ReactNode; + readonly muted?: boolean; + readonly className?: string; +}) { + return ( + + {children} + + ); +} + +export function CopyValueButton({ + value, + label, +}: { + readonly value: string; + readonly label: string; +}) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + }); + return ( + + ); +} diff --git a/apps/web/src/fork/prism/usePrismApi.ts b/apps/web/src/fork/prism/usePrismApi.ts new file mode 100644 index 000000000000..e8208b4f4bb8 --- /dev/null +++ b/apps/web/src/fork/prism/usePrismApi.ts @@ -0,0 +1,138 @@ +/** + * The Prism client bound to one environment's prepared connection (the + * primary environment unless the caller names another). Every call resolves + * to a plain result so the panel never `try`s: typed failures land in + * `error`, and only a defect rejects the promise. + */ +import { + cancelPrismLogin, + type PrismAccountId, + type PrismAccountPatch, + type PrismClientError, + type PrismClientInput, + type PrismLoginProvider, + completePrismLogin, + deletePrismAccount, + getPrismLoginStatus, + getPrismRouting, + getPrismStatus, + getPrismSyncStatus, + listPrismAccounts, + patchPrismAccount, + restartPrism, + setPrismRouting, + setPrismUsageSource, + startPrismLogin, +} from "@t3tools/client-runtime/fork"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import type { HttpClient } from "effect/unstable/http"; +import { useMemo } from "react"; + +import { runtime } from "~/lib/runtime"; +import { usePrimaryEnvironmentId } from "~/state/environments"; +import { usePreparedConnection } from "~/state/session"; + +import { describePrismPermissionError } from "./prismAccountsState"; + +export type PrismCallError = PrismClientError | { readonly _tag: "UnknownError" }; + +export type PrismResult = + | { readonly _tag: "ok"; readonly value: A } + | { readonly _tag: "error"; readonly error: PrismCallError }; + +type Call = ( + input: PrismClientInput, +) => Effect.Effect; + +const bindCalls = (prepared: PrismClientInput["prepared"]) => { + const run = (call: Call): Promise> => + runtime + .runPromise( + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + return yield* call({ prepared, signer }); + }).pipe( + Effect.match({ + onFailure: (error): PrismResult => ({ _tag: "error", error }), + onSuccess: (value): PrismResult => ({ _tag: "ok", value }), + }), + ), + ) + .catch((): PrismResult => ({ _tag: "error", error: { _tag: "UnknownError" } })); + + return { + status: () => run(getPrismStatus), + /** Answers with the status right after; poll `status` until `ready` or `failed`. */ + restart: () => run(restartPrism), + /** Answers with the status; `usageSource` carries the new value. */ + setUsageSource: (enabled: boolean) => + run((input) => setPrismUsageSource({ ...input, enabled })), + syncStatus: () => run(getPrismSyncStatus), + listAccounts: () => run(listPrismAccounts), + startLogin: (provider: PrismLoginProvider) => + run((input) => startPrismLogin({ ...input, provider })), + loginStatus: (sessionId: string) => + run((input) => getPrismLoginStatus({ ...input, sessionId })), + completeLogin: (sessionId: string, redirectUrl: string) => + run((input) => completePrismLogin({ ...input, sessionId, redirectUrl })), + cancelLogin: (sessionId: string) => run((input) => cancelPrismLogin({ ...input, sessionId })), + patchAccount: (id: PrismAccountId, patch: PrismAccountPatch) => + run((input) => patchPrismAccount({ ...input, id, patch })), + deleteAccount: (id: PrismAccountId) => run((input) => deletePrismAccount({ ...input, id })), + getRouting: () => run(getPrismRouting), + setRouting: (strategy: PrismRoutingStrategy) => + run((input) => setPrismRouting({ ...input, strategy })), + }; +}; + +export type PrismApi = ReturnType; + +/** `null` until the environment (the primary one by default) has a prepared connection. */ +export function usePrismApi(environmentId?: EnvironmentId | null): PrismApi | null { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const prepared = usePreparedConnection( + environmentId === undefined ? primaryEnvironmentId : environmentId, + ); + const preparedValue = Option.getOrNull(prepared); + return useMemo(() => (preparedValue ? bindCalls(preparedValue) : null), [preparedValue]); +} + +export function isPrismPermissionError( + error: PrismCallError, +): error is Extract< + PrismCallError, + { _tag: "EnvironmentScopeRequiredError" | "EnvironmentAuthInvalidError" } +> { + return ( + error._tag === "EnvironmentScopeRequiredError" || error._tag === "EnvironmentAuthInvalidError" + ); +} + +/** Toast/inline text for a failed call. Never includes a token or a management secret. */ +export function describePrismCallError(error: PrismCallError): string { + switch (error._tag) { + case "PrismUnavailableError": + return error.message; + case "PrismUpstreamError": + return `Prism answered ${error.status}: ${error.message}`; + case "PrismNotFoundError": + return error.message; + case "PrismConfigError": + return error.message; + case "PrismSyncFailedError": + return error.message; + case "EnvironmentScopeRequiredError": + case "EnvironmentAuthInvalidError": + return describePrismPermissionError(error); + case "UnknownError": + return "The request failed unexpectedly."; + default: + return "message" in error && typeof error.message === "string" + ? error.message + : "The request failed."; + } +} diff --git a/apps/web/src/fork/useForkFlag.ts b/apps/web/src/fork/useForkFlag.ts new file mode 100644 index 000000000000..fbf7dddf0abf --- /dev/null +++ b/apps/web/src/fork/useForkFlag.ts @@ -0,0 +1,9 @@ +import { useAtomValue } from "@effect/atom-react"; +import { readForkFlag, type ForkFlagKey } from "@t3tools/client-runtime/fork"; +import { primaryServerConfigAtom } from "~/state/server"; + +/** Value of a fork flag on the primary environment; registry default until its config arrives. */ +export function useForkFlag(key: ForkFlagKey): boolean { + const config = useAtomValue(primaryServerConfigAtom); + return readForkFlag(config?.environment.capabilities, key); +} diff --git a/apps/web/src/fork/useForkSettingsNav.ts b/apps/web/src/fork/useForkSettingsNav.ts new file mode 100644 index 000000000000..ee92e258b384 --- /dev/null +++ b/apps/web/src/fork/useForkSettingsNav.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; + +import type { SettingsPath } from "~/components/settings/settingsSearch"; +import { primaryServerConfigAtom } from "~/state/server"; + +import { isForkSettingsPathVisible } from "./forkSettingsNav"; + +/** The settings nav items whose fork flag (if any) is on for the primary environment. */ +export function useForkVisibleSettingsNavItems( + items: ReadonlyArray, +): ReadonlyArray { + const config = useAtomValue(primaryServerConfigAtom); + const capabilities = config?.environment.capabilities; + return useMemo(() => { + const visible = isForkSettingsPathVisible(capabilities); + return items.filter((item) => visible(item.to)); + }, [capabilities, items]); +} diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 91b757f51e0d..afd503e63c25 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,14 +49,31 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), + useAtomValue: (atom: unknown) => + atom === "primary-settings" + ? { newWorktreesStartFromOrigin: false } + : new Map([ + [ + "environment-ssh", + { + settings: { + defaultThreadEnvMode: "local", + newWorktreesStartFromOrigin: false, + defaultModelSelection: null, + }, + }, + ], + ]), })); vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/contracts", () => ({ + DEFAULT_RUNTIME_MODE: "default", + DEFAULT_SERVER_SETTINGS: {}, +})); vi.mock("@t3tools/shared/threadEnvMode", () => ({ resolveDefaultThreadEnvMode: (input: { readonly projectFile: "local" | "worktree" | null; @@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../state/server", () => ({ + environmentServerConfigsAtom: {}, + primaryServerSettingsAtom: "primary-settings", +})); vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c26b25d1316b..78dfc1b13fe6 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,12 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -30,7 +35,7 @@ import { resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; -import { primaryServerSettingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target - // environment's own settings here would silently reset remote projects to - // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. + const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); @@ -83,6 +84,8 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const targetServerSettings = + environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -138,7 +141,8 @@ export function useNewThreadHandler() { ); const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: project?.defaultModelSelection ?? null, + projectDefaultSelection: + project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, @@ -157,7 +161,7 @@ export function useNewThreadHandler() { project.workspaceRoot, ) : null, - globalDefault: primaryServerSettings.defaultThreadEnvMode, + globalDefault: targetServerSettings.defaultThreadEnvMode, }); }; const logicalProjectKey = project @@ -429,7 +433,13 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], + [ + environmentServerConfigs, + getCurrentRouteTarget, + primaryServerSettings.newWorktreesStartFromOrigin, + projectGroupingSettings, + router, + ], ); } diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts new file mode 100644 index 000000000000..5d3ddfb8b1ef --- /dev/null +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -0,0 +1,50 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../state/server"; + +/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ +export function useLoadBalancedEnvironment( + environmentIds: readonly EnvironmentId[], + weights: Readonly>, +) { + const registry = useContext(RegistryContext); + const refresh = useCallback( + (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) { + registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); + } + }, + [registry], + ); + const resourcesAtom = useMemo( + () => + Atom.make((get) => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + }; + }), + ), + [environmentIds], + ); + const resources = useAtomValue(resourcesAtom); + return { + refresh, + pending: resources.some((resource) => resource.pending), + environmentId: chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null, + }; +} diff --git a/apps/web/src/hooks/useLocalStorage.test.ts b/apps/web/src/hooks/useLocalStorage.test.ts index 27627a36e4b0..495365dfdcce 100644 --- a/apps/web/src/hooks/useLocalStorage.test.ts +++ b/apps/web/src/hooks/useLocalStorage.test.ts @@ -55,9 +55,28 @@ describe("local storage errors", () => { } }); - it("preserves decode failure context", async () => { + it("retries when access to browser storage becomes available", async () => { + const storage = createStorage(); + storage.setItem("read-key", JSON.stringify("saved value")); + let blocked = true; + vi.stubGlobal("window", { + get localStorage() { + if (blocked) throw new Error("storage unavailable"); + return storage; + }, + }); + const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); + + expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( + LocalStorageOperationError, + ); + blocked = false; + expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); + }); + + it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( - createStorage({ getItem: () => "not-json" }), + createStorage({ getItem: () => value }), ); try { diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 3099e73ff43f..112715599484 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass(); - return { - clear: () => store.clear(), - getItem: (_) => store.get(_) ?? null, - key: (_) => Record.keys(store).at(_) ?? null, - get length() { - return store.size; - }, - removeItem: (_) => store.delete(_), - setItem: (_, value) => store.set(_, value), - }; - })(); +const fallbackStorage: Storage = (() => { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (_) => store.get(_) ?? null, + key: (_) => Record.keys(store).at(_) ?? null, + get length() { + return store.size; + }, + removeItem: (_) => store.delete(_), + setItem: (_, value) => store.set(_, value), + }; +})(); + +const getStorage = (): Storage => + typeof window !== "undefined" ? window.localStorage : fallbackStorage; const read = (key: string) => { try { - return isomorphicLocalStorage.getItem(key); + return getStorage().getItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); } @@ -58,13 +58,13 @@ const encode = (key: string, schema: Schema.Codec, value: T) => { export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { const item = read(key); - return item ? decode(key, schema, item) : null; + return item === null ? null : decode(key, schema, item); }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { const valueToSet = encode(key, schema, value); try { - isomorphicLocalStorage.setItem(key, valueToSet); + getStorage().setItem(key, valueToSet); } catch (cause) { throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); } @@ -72,7 +72,7 @@ export const setLocalStorageItem = (key: string, value: T, schema: Schema. export const removeLocalStorageItem = (key: string) => { try { - isomorphicLocalStorage.removeItem(key); + getStorage().removeItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 200e14241e13..d55424766883 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -3,12 +3,22 @@ import { ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const persistenceMocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: persistenceMocks }), +})); import { __resetClientSettingsPersistenceForTests, __setClientSettingsForTests, + ensureClientSettingsHydrated, getClientSettings, mergeEnvironmentSettings, persistClientSettingsPatch, @@ -17,9 +27,138 @@ import { } from "./useSettings"; beforeEach(() => { + persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); + persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined); __resetClientSettingsPersistenceForTests(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client settings hydration", () => { + const savedSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }], + }; + const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; + const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings + .mockRejectedValueOnce(failure) + .mockResolvedValue(savedSettings); + + await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + + const completedSettings = { ...savedSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2); + }); + + it("uses defaults only after storage confirms no saved settings exist", async () => { + const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt }; + + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + }); + + it("holds patches until a pending read supplies the saved preferences", async () => { + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + finishRead(savedSettings); + await hydration; + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + }); + + it("handles failed patch reads without writing and retries with the saved preferences", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValue(failure); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + await expect(hydration).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + persistenceMocks.getClientSettings.mockResolvedValue(savedSettings); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + persistClientSettingsPatch({ wordWrap: false }); + + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + }); + + it("preserves patch order across hydration and a blocked completion write", async () => { + let finishRead!: (settings: ClientSettings) => void; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + persistenceMocks.getClientSettings.mockReturnValue(read); + let finishCompletionWrite!: () => void; + const blockedWrite = new Promise((resolve) => { + finishCompletionWrite = resolve; + }); + let signalCompletionWrite!: () => void; + const completionWriteStarted = new Promise((resolve) => { + signalCompletionWrite = resolve; + }); + let durableSettings: ClientSettings = savedSettings; + const persist = vi + .fn<(settings: ClientSettings) => Promise>() + .mockImplementationOnce(async (settings) => { + signalCompletionWrite(); + await blockedWrite; + durableSettings = settings; + }) + .mockImplementation(async (settings) => { + durableSettings = settings; + }); + + const completion = persistClientSettingsUpdate(complete, persist); + persistClientSettingsPatch({ wordWrap: false }, persist); + finishRead(savedSettings); + await completionWriteStarted; + persistClientSettingsPatch({ wordWrap: true }, persist); + const finalWrite = persistClientSettingsUpdate((current) => current, persist); + + finishCompletionWrite(); + await completion; + await finalWrite; + + const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true }; + expect(getClientSettings()).toEqual(expected); + expect(durableSettings).toEqual(expected); + }); +}); + describe("persistClientSettingsUpdate", () => { it("publishes the update only after persistence succeeds", async () => { let finishPersistence!: () => void; @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { expect(settings.sidebarAutoSettleOnMerge).toBe(false); }); }); + +describe("onboarding completion persistence", () => { + it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => { + const failure = new Error("disk full"); + const persist = vi + .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined); + const existingSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [ + { + provider: ProviderInstanceId.make("codex_work"), + model: "gpt-5.6", + }, + ], + }; + __setClientSettingsForTests(existingSettings); + const onboardingCompletedAt = "2026-09-01T12:00:00.000Z"; + const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({ + ...current, + onboardingCompletedAt, + }); + + await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure); + expect(getClientSettings()).toBe(existingSettings); + expect(getClientSettings().onboardingCompletedAt).toBeNull(); + + const completedSettings = { ...existingSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual( + completedSettings, + ); + expect(getClientSettings()).toEqual(completedSettings); + expect(persist).toHaveBeenLastCalledWith(completedSettings); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bed80616e4a8..9b428b08ea50 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,6 +26,7 @@ import { } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, @@ -53,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); +type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; -let clientSettingsHydrated = false; +let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending"; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); +let deferredClientSettingsPatchCount = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -80,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { emitClientSettingsChange(); } -function setClientSettingsHydrated(nextHydrated: boolean): void { - if (clientSettingsHydrated === nextHydrated) { +function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void { + if (clientSettingsHydrationStatus === nextStatus) { return; } - clientSettingsHydrated = nextHydrated; + clientSettingsHydrationStatus = nextStatus; emitClientSettingsHydrationChange(); } function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsListeners.delete(listener); }; } function getClientSettingsHydratedSnapshot(): boolean { - return clientSettingsHydrated; + return clientSettingsHydrationStatus === "ready"; +} + +function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus { + return clientSettingsHydrationStatus; } function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsHydrationListeners.delete(listener); }; } async function hydrateClientSettings(): Promise { - if (clientSettingsHydrated) { + if (clientSettingsHydrationStatus === "ready") { return; } if (clientSettingsHydrationPromise) { @@ -117,6 +124,11 @@ async function hydrateClientSettings(): Promise { } const hydrationGeneration = clientSettingsHydrationGeneration; + setClientSettingsHydrationStatus( + clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying" + ? "retrying" + : "pending", + ); const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); @@ -126,15 +138,16 @@ async function hydrateClientSettings(): Promise { if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } + setClientSettingsHydrationStatus("ready"); } catch (error) { + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrationStatus("failed"); + } console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { operation: "hydrate", ...safeErrorLogAttributes(error), }); - } finally { - if (hydrationGeneration === clientSettingsHydrationGeneration) { - setClientSettingsHydrated(true); - } + throw error; } })(); @@ -164,15 +177,32 @@ export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): void { - replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); - void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( - (error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { - operation: "persist", - ...safeErrorLogAttributes(error), - }); - }, - ); + // Patches queued before hydration must publish before newer optimistic patches. + const deferPatch = + clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; + if (deferPatch) { + deferredClientSettingsPatchCount += 1; + } else { + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } + void enqueueClientSettingsPersistence(async () => { + if (deferPatch) { + try { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } finally { + deferredClientSettingsPatchCount -= 1; + } + } + await persist(getClientSettingsSnapshot()); + }).catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); + }); } /** @@ -186,6 +216,9 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } for (;;) { const current = getClientSettingsSnapshot(); const next = update(current); @@ -233,7 +266,9 @@ export function getClientSettings(): ClientSettings { } /** - * Resolves once client settings have been read from disk. + * Resolves after settings load or storage confirms no saved settings exist. + * Failed reads reject and remain retryable. They must not allow defaults to + * overwrite saved preferences. * * The pre-hydration snapshot is just the schema defaults, so imperative paths * that open a preview must await this or they bake the built-in viewport, zoom @@ -251,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { ); } +export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus { + return useSyncExternalStore( + subscribeClientSettingsHydration, + getClientSettingsHydrationStatusSnapshot, + () => "pending", + ); +} + function useClientSettingsValue(): ClientSettings { return useSyncExternalStore( subscribeClientSettings, @@ -368,18 +411,6 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } -/** Environments that can receive a shared settings write right now. */ -function useSharedSettingsSyncTargetIds(): ReadonlyArray { - const { environments } = useEnvironments(); - return useMemo( - () => - environments - .filter(supportsSharedSettingsSync) - .map((environment) => environment.environmentId), - [environments], - ); -} - /** * Returns an updater that routes each key to the correct backing store. * @@ -394,7 +425,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); - const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(); + const { environments } = useEnvironments(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -402,11 +433,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. - const warnUnsaved = () => + const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => toastManager.add({ type: "warning", title: "Setting not saved", - description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, + description, }); if (Object.keys(localPatch).length > 0) { if (environmentId) { @@ -419,26 +450,38 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set(sharedSettingsSyncTargetIds); + const targets = new Set( + environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), + ); if (environmentId) { targets.add(environmentId); } - if (targets.size === 0) { - warnUnsaved(); - } + let wroteToTarget = false; for (const targetId of targets) { + const target = environments.find((candidate) => candidate.environmentId === targetId); + const targetPatch = filterSharedServerPatch( + sharedPatch, + target?.serverConfig?.environment.capabilities, + ); + if (Object.keys(targetPatch).length === 0) continue; + wroteToTarget = true; void persistServerSettings({ environmentId: targetId, - input: { patch: sharedPatch }, + input: { patch: targetPatch }, }); } + if (!wroteToTarget) { + warnUnsaved( + targets.size > 0 ? "Update older servers to save this setting." : undefined, + ); + } } } if (Object.keys(clientPatch).length > 0) { persistClientSettingsPatch(clientPatch); } }, - [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], + [environmentId, environments, persistServerSettings], ); return updateSettings; @@ -453,6 +496,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { export function useSharedSettingsSync() { const primaryEnvironment = usePrimaryEnvironment(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities; // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls // back to defaults while the primary is disconnected, and "apply to all" // must never push defaults over real values. Same for a primary too old to @@ -472,28 +516,35 @@ export function useSharedSettingsSync() { findSharedSettingsMismatches({ primaryEnvironmentId, primarySettings, + primaryCapabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, + capabilities: environment.serverConfig?.environment.capabilities, })), }), - [environments, primaryEnvironmentId, primarySettings], + [environments, primaryEnvironmentId, primarySettings, primaryCapabilities], ); const applyToAll = useCallback(() => { if (primarySettings === null) { return; } - const patch = pickSharedServerSettings(primarySettings); + const patch = pickSharedServerSettings(primarySettings, primaryCapabilities); for (const mismatch of mismatches) { + const target = environments.find( + (candidate) => candidate.environmentId === mismatch.environmentId, + ); void persistServerSettings({ environmentId: mismatch.environmentId, - input: { patch }, + input: { + patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities), + }, }); } - }, [mismatches, persistServerSettings, primarySettings]); + }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]); return { mismatches, applyToAll }; } @@ -515,9 +566,10 @@ export function useUpdateClientSettings() { export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; + clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; clientSettingsPersistenceQueue = Promise.resolve(); + deferredClientSettingsPatchCount = 0; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } @@ -525,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; - clientSettingsHydrated = true; + clientSettingsHydrationStatus = "ready"; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff298..9a1748dc4d24 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -204,3 +204,200 @@ describe("theme failure handling", () => { } }); }); + +describe("onboarding theme", () => { + it("clears custom palettes and restores the latest selected theme", async () => { + const storage = createStorage(); + const classes = new Set(); + const styleValues = new Map(); + const root = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style: { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }, + }; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + body: { style: { backgroundColor: "" } }, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: "rgb(0, 0, 0)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { EMBER_THEME, installCustomTheme } = await import("../themePalette"); + const firstTheme = installCustomTheme({ + ...EMBER_THEME, + id: "first-custom", + label: "First Custom", + }); + const secondTheme = installCustomTheme({ + ...EMBER_THEME, + id: "second-custom", + label: "Second Custom", + colors: { ...EMBER_THEME.colors, error: "#123456" }, + }); + storage.setItem("t3code:theme", firstTheme.id); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(root.dataset.themeId).toBe(firstTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error); + + const cleanup = mountOnboardingTheme(); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + expect(useTheme().setTheme(secondTheme.id)).toBe(true); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + cleanup(); + expect(root.dataset.themeId).toBe(secondTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); + }); + + it("stays dark during storage changes and restores the latest saved theme", async () => { + const storage = createStorage(); + storage.setItem("t3code:theme", "light"); + const classes = new Set(); + const styleValues = new Map(); + const style = { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }; + const root = { + classList: { + add: (name: string) => classes.add(name), + contains: (name: string) => classes.has(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style, + }; + const body = { style: { backgroundColor: "" } }; + let storageHandler: ((event: StorageEvent) => void) | undefined; + const setDesktopTheme = vi.fn().mockResolvedValue(undefined); + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + desktopBridge: { setTheme: setDesktopTheme }, + }); + vi.stubGlobal("document", { + body, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: + root.dataset.onboardingSurface !== undefined + ? "rgb(0, 0, 0)" + : classes.has("dark") + ? "rgb(10, 10, 10)" + : "rgb(255, 255, 255)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(useTheme().resolvedTheme).toBe("light"); + const cleanup = mountOnboardingTheme(); + + expect(root.dataset.onboardingSurface).toBe(""); + expect(classes.has("dark")).toBe(true); + expect(root.style.backgroundColor).toBe("#000"); + expect(body.style.backgroundColor).toBe("#000"); + expect(useTheme().resolvedTheme).toBe("dark"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); + + storage.setItem("t3code:theme", "dark"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + storage.setItem("t3code:theme", "light"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + expect(classes.has("dark")).toBe(true); + expect(useTheme().resolvedTheme).toBe("dark"); + + cleanup(); + expect(root.dataset.onboardingSurface).toBeUndefined(); + expect(classes.has("dark")).toBe(false); + expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(storage.getItem("t3code:theme")).toBe("light"); + expect(useTheme().resolvedTheme).toBe("light"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 726a03dac7b8..01928552acb0 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { function themeHalvesSignature(halves: ThemeHalves | null): string { return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; } + +function isOnboardingThemeActive(): boolean { + return ( + typeof document !== "undefined" && + document.documentElement.dataset?.onboardingSurface !== undefined + ); +} + const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { export function syncBrowserChromeTheme() { if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); const rootStyles = getComputedStyle(document.documentElement); - const themeChromeColor = document.documentElement.dataset.themeId - ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) - : null; + const themeChromeColor = + !onboardingActive && document.documentElement.dataset.themeId + ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) + : null; const surfaceColor = normalizeThemeColor( getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; + const backgroundColor = onboardingActive + ? "#000" + : (themeChromeColor ?? surfaceColor ?? fallbackColor); if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { if (typeof document === "undefined" || typeof window === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); // Keep the editor's draft visible until an explicit refresh restores the selection. - if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; + if ( + preservePreview && + !onboardingActive && + document.documentElement.dataset?.themeId === THEME_PREVIEW_ID + ) { + return; + } const appearanceMode = readAppearanceModePreference(theme); const followSystem = appearanceMode === "system"; const systemDark = followSystem ? getSystemDark() : false; @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview lastAppliedTheme.appearanceMode === appearanceMode && themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) ) { - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } return; } @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); - const isDark = resolvedAppearance === "dark"; - document.documentElement.classList.toggle("dark", isDark); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + } else { + applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); + document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); + } lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } +/** Own the document-wide dark palette used by the first-run wizard and its portals. */ +export function mountOnboardingTheme(): () => void { + if (typeof document === "undefined" || typeof window === "undefined") return () => {}; + + const root = document.documentElement; + applyThemePalette("dark", "dark"); + root.dataset.onboardingSurface = ""; + root.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + emitChange(); + + return () => { + delete root.dataset.onboardingSurface; + root.style.backgroundColor = ""; + document.body.style.backgroundColor = ""; + lastAppliedTheme = null; + applyTheme(getStored(), { suppressTransitions: true, preservePreview: false }); + emitChange(); + }; +} + export async function syncDesktopThemePreference( bridge: DesktopThemeBridge, theme: Theme, @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); - const resolvedTheme = resolveThemeAppearance( - theme, - systemDark, - followSystem, - appearanceMode, - themeHalves, - ); + const resolvedTheme = isOnboardingThemeActive() + ? "dark" + : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); if ( lastSnapshot && lastSnapshot.theme === theme && diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts index e2a8b6d1b4b1..b042f19893da 100644 --- a/apps/web/src/hooks/useThreadActions.test.ts +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -1,7 +1,40 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions"; +import { + navigateAfterThreadDeletion, + requestThreadUnpinConfirmation, + ThreadArchiveBlockedError, +} from "./useThreadActions"; +import { toastManager } from "../components/ui/toast"; + +describe("navigateAfterThreadDeletion", () => { + afterEach(() => vi.restoreAllMocks()); + + it("reports a rejected navigation without failing the completed deletion", async () => { + const addToast = vi.spyOn(toastManager, "add").mockReturnValue("navigation-error"); + + await expect( + navigateAfterThreadDeletion(() => Promise.reject(new Error("route unavailable"))), + ).resolves.toBeUndefined(); + + expect(addToast).toHaveBeenCalledOnce(); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Thread deleted, but navigation failed", + description: "route unavailable", + }), + ); + }); + + it("does not report an error after successful navigation", async () => { + const addToast = vi.spyOn(toastManager, "add"); + + await navigateAfterThreadDeletion(() => Promise.resolve()); + + expect(addToast).not.toHaveBeenCalled(); + }); +}); describe("ThreadArchiveBlockedError", () => { it("keeps the blocked thread context with the fixed message", () => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 64915228c779..cefe81a7fa09 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentSupportsPinning, readEnvironmentSupportsPinReorder, + readEnvironmentSupportsActiveReorder, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, @@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadActiveReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Update this environment's server to reorder active threads."; + } +} + export async function requestThreadUnpinConfirmation(input: { enabled: boolean; title: string; @@ -144,6 +157,21 @@ export async function requestThreadUnpinConfirmation(input: { ); } +/** Report navigation separately so a completed deletion can still finish worktree cleanup. */ +export async function navigateAfterThreadDeletion(navigate: () => Promise) { + const result = await settlePromise(navigate); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread deleted, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -170,6 +198,9 @@ export function useThreadActions() { const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); + const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -383,39 +414,20 @@ export function useThreadActions() { clearTerminalUiState(threadRef); if (shouldNavigateToFallback) { - if (fallbackThreadId) { - const fallbackThread = readThreadShell( - scopeThreadRef(threadRef.environmentId, fallbackThreadId), - ); - if (fallbackThread) { - const navigationResult = await settlePromise(() => - router.navigate({ + const fallbackThread = fallbackThreadId + ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId)) + : null; + await navigateAfterThreadDeletion(() => + fallbackThread + ? router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams( scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), ), replace: true, - }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } - } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } + }) + : router.navigate({ to: "/", replace: true }), + ); } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { @@ -444,9 +456,10 @@ export function useThreadActions() { ? refreshResult : null; if (cleanupFailure) { + const removalFailed = removeResult._tag === "Failure"; const error = squashAtomCommandFailure(cleanupFailure); - const message = error instanceof Error ? error.message : "Unknown error removing worktree."; - console.error("Failed to remove orphaned worktree after thread deletion", { + const message = error instanceof Error ? error.message : "An error occurred."; + console.error("Worktree cleanup failed after thread deletion", { threadId: threadRef.threadId, projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, @@ -455,11 +468,16 @@ export function useThreadActions() { toastManager.add( stackedThreadToast({ type: "error", - title: "Thread deleted, but worktree removal failed", - description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + title: removalFailed + ? "Failed to delete worktree" + : "Worktree deleted, but Git status refresh failed", + description: removalFailed + ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}` + : message, }), ); - return cleanupFailure; + // The thread was deleted. Cleanup has its own toast; returning its + // failure would make callers incorrectly report a thread deletion error. } return deleteResult; }, @@ -629,6 +647,26 @@ export function useThreadActions() { [reorderPinnedThreadMutation], ); + const reorderActiveThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + if (!readEnvironmentSupportsActiveReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadActiveReorderUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderActiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderActiveThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -727,6 +765,7 @@ export function useThreadActions() { unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, }), [ archiveThread, @@ -735,6 +774,7 @@ export function useThreadActions() { deleteThread, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 68b0101be28a..45d10f28d735 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { --terminal-selection-background: var(--app-theme-terminal-selection-background); } +/* The first-run flow owns the whole document so portaled menus and tooltips + use the same fixed palette as the wizard. This follows the theme mapping so + saved custom themes cannot override it while onboarding is mounted. */ +html[data-onboarding-surface]:root { + color-scheme: dark; + --accent: #262626; + --accent-foreground: #fff; + --appearance-contrast-target: #fff; + --app-chrome-background: #000; + --background: #000; + --border: #262626; + --card: #000; + --card-foreground: #fff; + --destructive: var(--color-red-400); + --foreground: #fff; + --icon-muted: #a1a1aa; + --input: #262626; + --muted: #171717; + --muted-foreground: #a1a1aa; + --placeholder: #71717a; + --popover: #171717; + --popover-foreground: #fff; + --ring: #737373; + --secondary: #171717; + --secondary-foreground: #fff; + --secondary-label: #a1a1aa; + --success-foreground: var(--color-emerald-400); + --terminal-background: #000; + --terminal-cursor: #fff; + --terminal-foreground: #fff; + --terminal-selection-background: rgb(255 255 255 / 20%); +} + /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { } } -/* Contrast stays in ordinary custom properties so both Tailwind utilities and - global/imperative chrome styles resolve the same adjusted role. Redeclare on - the sidebar because it owns a local semantic palette. */ +/* Recompute contrast wherever a subtree owns its own semantic color palette. */ :root, -[data-app-sidebar] { +[data-app-sidebar], +[data-onboarding-surface] { --contrast-toolbar-foreground: color-mix( in oklab, color-mix( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8cbc45966529..df005571193e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, - isChatNewShortcut, - isChatNewLocalShortcut, isDiffToggleShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -21,8 +19,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowModelPickerJumpHints, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -498,17 +495,21 @@ describe("thread navigation helpers", () => { it("shows jump hints only when configured modifiers match", () => { assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + shouldShowThreadJumpHintsForModifiers( + event({ metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), ); assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -516,13 +517,13 @@ describe("thread navigation helpers", () => { it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ); assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), @@ -539,47 +540,36 @@ describe("model picker navigation helpers", () => { assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); }); - - it("shows jump hints only while the model picker context is active", () => { - assert.isFalse( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: false }, - }), - ); - assert.isTrue( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: true }, - }), - ); - }); }); describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { - assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.new", ); - assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.new", ); }); it("matches chat.newLocal shortcut", () => { - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.newLocal", ); - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newLocal", ); }); @@ -699,11 +689,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isFalse( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: true }, }), + "terminal.new", ); assert.isFalse( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -711,11 +702,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: false }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: false }, }), + "chat.new", ); }); @@ -735,11 +727,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true }), keybindings, { platform: "Linux", context: { terminalFocus: true }, }), + "chat.new", ); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2a8d385cb42b..844984d4a02e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -288,14 +288,6 @@ export function threadTraversalDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -337,32 +329,6 @@ export function modelPickerJumpIndexFromCommand(command: string): number | null return index === -1 ? null : index; } -export function shouldShowModelPickerJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); -} - -export function shouldShowModelPickerJumpHintsForModifiers( - modifiers: ShortcutModifierStateLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - - for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; -} - export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -411,30 +377,6 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } -export function isPreviewRefreshShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "preview.refresh", options); -} - -export function isChatNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.new", options); -} - -export function isChatNewLocalShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 79c1092f94d0..d62aee512887 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -445,7 +445,7 @@ export function startAttachmentUpload(input: { * persisted draft upload survives cancellation (an environment switch cancels * the old job, and the draft still references that server copy). */ -export function cancelAttachmentUpload(imageId: string): void { +function cancelAttachmentUpload(imageId: string): void { const job = jobsByImageId.get(imageId); if (!job) { return; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 7d031e537c65..2866e88f45f6 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,7 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -export const DIFF_THEME_NAMES = { +const DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark", } as const; @@ -81,7 +81,7 @@ interface RenderablePatchOptions { compactPartialHunkOffsets?: boolean; } -export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { +function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { if (!file.isPartial) return file; let splitLineStart = 0; diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 6029c9bb3dab..ad4971d331d8 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,14 +1,12 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, - openPullRequestLink, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, - PullRequestLinkOpenError, shouldOpenPullRequestExternally, } from "./openPullRequestLink"; import { ProjectId, type RepositoryIdentity } from "@t3tools/contracts"; @@ -184,33 +182,6 @@ describe("matchesLinkedPullRequestUrl", () => { }); }); -describe("openPullRequestLink", () => { - it("opens the requested pull request URL", async () => { - const openExternal = vi.fn(async () => undefined); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; - - await openPullRequestLink({ openExternal }, targetUrl); - - expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); - }); - - it("reports bridge failures with a safe target origin", async () => { - const cause = new Error("desktop shell unavailable"); - const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; - const openExternal = vi.fn(async () => Promise.reject(cause)); - - const result = openPullRequestLink({ openExternal }, targetUrl); - - await expect(result).rejects.toEqual( - new PullRequestLinkOpenError({ - targetOrigin: "https://github.com", - cause, - }), - ); - await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); - }); -}); - describe("shouldOpenPullRequestExternally", () => { it("uses the browser for command-click and control-click", () => { expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0050c62bc727..2d46e3984fd9 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,12 +1,10 @@ import type { EnvironmentId, - LocalApi, RepositoryIdentity, ScopedThreadRef, ThreadLinkedPullRequest, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; -import * as Schema from "effect/Schema"; import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; @@ -19,41 +17,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; -export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( - "PullRequestLinkOpenError", - { - targetOrigin: Schema.NullOr(Schema.String), - cause: Schema.Defect(), - }, -) { - static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { - let targetOrigin: string | null = null; - try { - targetOrigin = new URL(targetUrl).origin; - } catch { - // Keep malformed URLs out of diagnostics while preserving the open failure below. - } - return new PullRequestLinkOpenError({ targetOrigin, cause }); - } - - override get message(): string { - return this.targetOrigin === null - ? "Unable to open pull request link." - : `Unable to open pull request link at ${this.targetOrigin}.`; - } -} - -export async function openPullRequestLink( - shell: Pick, - targetUrl: string, -): Promise { - try { - await shell.openExternal(targetUrl); - } catch (cause) { - throw PullRequestLinkOpenError.fromCause(targetUrl, cause); - } -} - /** Builds a GitHub URL that remains available when the pull request API cannot be read. */ export function gitHubPullRequestBrowserUrl( identity: RepositoryIdentity | null | undefined, diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 464c8c8a94d4..beabc856f60e 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -111,7 +111,7 @@ async function previewAnnotationScreenshotFile( } /** Upper bound on turning a picked element's crop into a composer attachment. */ -export const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 3836d2a39169..754d3825bc58 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -31,8 +31,6 @@ type RuntimeLayerSource = | typeof relayTracingLayer | ReturnType; -export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); - const primaryHttpRuntime = ManagedRuntime.make( PrimaryEnvironmentHttpClient.layer.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 87b9b12ea8bc..4c409a3b1ca1 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -26,7 +26,7 @@ export function createMemoryStorage(): StateStorage { }; } -export function isStateStorage( +function isStateStorage( storage: Partial | null | undefined, ): storage is StateStorage { return ( diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 4b520c9bef4a..199054b1d84b 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { appendTerminalContextsToPrompt, - buildTerminalContextPreviewTitle, buildTerminalContextBlock, countInlineTerminalContextPlaceholders, deriveDisplayedUserMessageState, @@ -135,20 +134,6 @@ describe("terminalContext", () => { }); }); - it("returns null preview title when every context is invalid", () => { - expect( - buildTerminalContextPreviewTitle([ - makeContext({ - terminalId: " ", - }), - makeContext({ - id: "context-2", - text: "\n\n", - }), - ]), - ).toBeNull(); - }); - it("tracks inline terminal context placeholders in prompt text", () => { const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; expect(countInlineTerminalContextPlaceholders(`a${placeholder}b${placeholder}`)).toBe(2); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 72f49a2f22d2..68d85f08d3b1 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,21 +65,7 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -function previewTerminalContextText(text: string): string { - const normalized = normalizeTerminalContextText(text); - if (normalized.length === 0) { - return ""; - } - const lines = normalized.split("\n"); - const visibleLines = lines.slice(0, 3); - if (lines.length > 3) { - visibleLines.push("..."); - } - const preview = visibleLines.join("\n"); - return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview; -} - -export function normalizeTerminalContextSelection( +function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { const text = normalizeTerminalContextText(selection.text); @@ -99,10 +85,7 @@ export function normalizeTerminalContextSelection( }; } -export function formatTerminalContextRange(selection: { - lineStart: number; - lineEnd: number; -}): string { +function formatTerminalContextRange(selection: { lineStart: number; lineEnd: number }): string { return selection.lineStart === selection.lineEnd ? `line ${selection.lineStart}` : `lines ${selection.lineStart}-${selection.lineEnd}`; @@ -129,27 +112,6 @@ export function formatInlineTerminalContextLabel(selection: { return `@${terminalLabel}:${range}`; } -export function buildTerminalContextPreviewTitle( - contexts: ReadonlyArray, -): string | null { - if (contexts.length === 0) { - return null; - } - const previewParts: string[] = []; - for (const context of contexts) { - const normalized = normalizeTerminalContextSelection(context); - if (!normalized) continue; - const preview = previewTerminalContextText(normalized.text); - previewParts.push( - preview.length > 0 - ? `${formatTerminalContextLabel(normalized)}\n${preview}` - : formatTerminalContextLabel(normalized), - ); - } - const previews = previewParts.join("\n\n"); - return previews.length > 0 ? previews : null; -} - function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { return normalizeTerminalContextText(selection.text) .split("\n") diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 2644ea67adec..7785bceaac73 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,5 +1,4 @@ export { - activeThreadAnchorTimestampMs, getLatestThreadForProject, getThreadSortTimestamp, resolveSettledThreadTimestamp, diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 42f9f13c7cda..7c9e8e8553b3 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -43,7 +43,7 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { }; } -export function getElectronPlatformClassNames( +function getElectronPlatformClassNames( platform: string, ): | readonly [typeof ELECTRON_CLASS_NAME] diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index f696cb462246..d75c4c2de902 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,11 +1,9 @@ export { buildProjectGroups, deriveLogicalProjectKey, - deriveLogicalProjectKeyFromRef, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, derivePhysicalProjectKeyFromPath, - deriveProjectGroupLabel, deriveProjectGroupingOverrideKey, getProjectOrderKey, resolveProjectGroupingMode, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 4ff195083d51..1a39f06098ac 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -153,7 +153,7 @@ function applyInstanceModelPreferences( ); } -export function normalizeCustomModelEntries( +function normalizeCustomModelEntries( models: ReadonlyArray, builtInModelSlugs: ReadonlySet, ): CustomModelDefinition[] { @@ -179,7 +179,7 @@ export function normalizeCustomModelEntries( return normalizedModels; } -export function getAppModelOptions( +function getAppModelOptions( settings: UnifiedSettings, providers: ReadonlyArray, provider: ProviderDriverKind, diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 95d390b90026..81cd18e207de 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -41,15 +41,6 @@ export interface ClientTracingConfig { readonly exportIntervalMs?: number; } -export const ClientTracingLive = Layer.succeed( - Tracer.Tracer, - Tracer.make({ - span(options) { - return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); - }, - }), -); - export function configureClientTracing(config: ClientTracingConfig = {}): Promise { if (config.exportIntervalMs === undefined && activeConfigKey !== null) { return pendingConfiguration; diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts new file mode 100644 index 000000000000..ca35f6322e3b --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, +} from "./firstRun.logic"; + +const freshWorkspace = { + enabled: true, + hydrated: true, + completed: false, + bootstrapped: true, + authoritative: true, + workspaceAuthoritative: true, + workspaceProvenanceAuthoritative: true, + catalogReady: true, + serverConfigAvailable: true, + workspaceFresh: true, + projectCount: 1, + threadCount: 1, +} as const; + +describe("resolveFirstRunDecision", () => { + it("opens the wizard for an authoritative fresh workspace", () => { + expect(resolveFirstRunDecision(freshWorkspace)).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("does not permanently complete onboarding from cached project counts", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("backfills completion once existing projects are confirmed by the server", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("does not complete onboarding while another environment is still bootstrapping", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + bootstrapped: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for managed environments before treating a workspace as new", () => { + expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the environment catalog is ready", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + catalogReady: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the server configuration is available", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + serverConfigAvailable: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from cached remote projects", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from a single cached remote project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for live data before judging a single cached project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits when the initial welcome is pending and opens the wizard after completion", () => { + const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: pendingProvenance, + }), + ).toEqual({ decision: "pending", persistCompletion: false }); + + const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: completedProvenance, + }), + ).toEqual({ decision: "wizard", persistCompletion: false }); + }); + + it("does not wait for server data after onboarding is already complete", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + bootstrapped: false, + completed: true, + serverConfigAvailable: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +describe("isFirstRunWorkspaceProvenanceAuthoritative", () => { + it("waits for cwd bootstrap when the initial catalog is empty", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }), + ).toBe(false); + }); + + it("accepts an empty catalog after cwd bootstrap completes", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }), + ).toBe(true); + }); + + it("waits for a welcome before treating an empty catalog as final", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: false, + bootstrapStatus: null, + }), + ).toBe(false); + }); + + it("accepts a legacy welcome without bootstrap status", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: null, + }), + ).toBe(true); + }); +}); + +describe("transitionFirstRunGateState", () => { + it("shows recovery without mounting the app when evidence stalls", () => { + expect( + transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }), + ).toEqual({ decision: "pending", stalled: true }); + }); + + it.each(["app", "wizard"] as const)( + "resolves stalled recovery to %s only after authoritative evidence", + (decision) => { + expect( + transitionFirstRunGateState( + { decision: "pending", stalled: true }, + { type: "evidence", decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it("keeps recovery visible while evidence remains pending", () => { + const state = { decision: "pending", stalled: true } as const; + expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( + state, + ); + }); + + it("allows authoritative wizard evidence to replace an app decision", () => { + expect( + transitionFirstRunGateState( + { decision: "app", stalled: false }, + { type: "evidence", decision: "wizard" }, + ), + ).toEqual({ decision: "wizard", stalled: false }); + }); +}); + +describe("resolveHostedFirstRunDecision", () => { + it("keeps the shell hidden until client settings are hydrated", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: false, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the saved environment catalog before judging a hosted install", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("opens onboarding when a hosted install has no saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("backfills onboarding for a hosted install with saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 1, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("opens the app immediately after hosted onboarding is complete", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: true, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +const primaryEnvironmentId = "primary-environment"; +const bootstrapProject = { + id: "bootstrap-project", + environmentId: primaryEnvironmentId, + workspaceRoot: "/projects/current", +}; +const bootstrapThread = { + id: "bootstrap-thread", + projectId: bootstrapProject.id, + environmentId: primaryEnvironmentId, + latestTurn: null, + latestUserMessageAt: null, + session: null, +}; + +describe("isFreshFirstRunWorkspace", () => { + it("accepts an empty workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [], + }), + ).toBe(true); + }); + + it("accepts only the unused project and thread created from the server cwd", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current/", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects an existing unused cwd project and thread reused by startup", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a nonempty workspace when an older server omits creation provenance", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("normalizes Windows project paths before checking the bootstrap workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "C:\\Projects\\Current\\", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects projects from another environment even when their paths match", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [{ ...bootstrapProject, environmentId: "remote-environment" }], + threads: [], + }), + ).toBe(false); + }); + + it("rejects threads from another environment", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, environmentId: "remote-environment" }], + }), + ).toBe(false); + }); + + it("rejects a thread that does not belong to the bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, projectId: "another-project" }], + }), + ).toBe(false); + }); + + it("rejects a thread when there is no bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that already has a user message", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [ + { + ...bootstrapThread, + latestUserMessageAt: "2026-08-23T12:00:00.000Z", + }, + ], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has started a turn", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has a provider session", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, session: { status: "ready" } }], + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts new file mode 100644 index 000000000000..013dbb02d527 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -0,0 +1,184 @@ +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +export type FirstRunDecision = "pending" | "app" | "wizard"; + +export interface FirstRunGateState { + readonly decision: FirstRunDecision; + readonly stalled: boolean; +} + +type FirstRunGateEvent = + | { readonly type: "evidence"; readonly decision: FirstRunDecision } + | { readonly type: "timeout" }; + +interface FirstRunWorkspaceInput { + readonly primaryEnvironmentId: string | null; + readonly serverCwd: string | null; + readonly bootstrapProjectId?: string | undefined; + readonly bootstrapThreadId?: string | undefined; + readonly bootstrapProjectCreated?: boolean | undefined; + readonly bootstrapThreadCreated?: boolean | undefined; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly environmentId: string; + readonly workspaceRoot: string; + }>; + readonly threads: ReadonlyArray<{ + readonly id: string; + readonly projectId: string; + readonly environmentId: string; + readonly latestTurn: unknown; + readonly latestUserMessageAt: string | null; + readonly session: unknown; + }>; +} + +interface FirstRunDecisionInput { + readonly enabled: boolean; + readonly hydrated: boolean; + readonly completed: boolean; + readonly bootstrapped: boolean; + readonly authoritative: boolean; + readonly workspaceAuthoritative: boolean; + readonly workspaceProvenanceAuthoritative: boolean; + readonly catalogReady: boolean; + readonly serverConfigAvailable: boolean; + readonly workspaceFresh: boolean; + readonly projectCount: number; + readonly threadCount: number; +} + +interface HostedFirstRunDecisionInput { + readonly hydrated: boolean; + readonly completed: boolean; + readonly catalogReady: boolean; + readonly environmentCount: number; +} + +export function isFirstRunWorkspaceProvenanceAuthoritative(input: { + readonly welcomeReceived: boolean; + readonly bootstrapStatus: "pending" | "complete" | null; +}): boolean { + // An empty catalog is not final while cwd auto-bootstrap is pending. Older + // servers omit bootstrapStatus, so a received welcome with null stays valid. + return input.welcomeReceived && input.bootstrapStatus !== "pending"; +} + +/** Keeps the authenticated app unmounted until workspace evidence settles. */ +export function transitionFirstRunGateState( + state: FirstRunGateState, + event: FirstRunGateEvent, +): FirstRunGateState { + if (event.type === "timeout") { + return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; + } + + if ( + state.decision === "wizard" || + event.decision === "pending" || + (state.decision === "app" && event.decision !== "wizard") + ) { + return state; + } + + return { decision: event.decision, stalled: false }; +} + +/** Only a project and thread created by this startup count as a fresh nonempty workspace. */ +export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { + if (input.projects.length > 1 || input.threads.length > 1) { + return false; + } + + const bootstrapProject = input.projects[0]; + if (bootstrapProject !== undefined) { + if ( + input.bootstrapProjectCreated !== true || + input.bootstrapProjectId !== bootstrapProject.id || + input.serverCwd === null || + bootstrapProject.environmentId !== input.primaryEnvironmentId || + normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== + normalizeProjectPathForComparison(input.serverCwd) + ) { + return false; + } + } + + const bootstrapThread = input.threads[0]; + if (bootstrapThread === undefined) { + return true; + } + + return ( + bootstrapProject !== undefined && + input.bootstrapThreadCreated === true && + input.bootstrapThreadId === bootstrapThread.id && + bootstrapThread.environmentId === input.primaryEnvironmentId && + bootstrapThread.projectId === bootstrapProject.id && + bootstrapThread.latestTurn === null && + bootstrapThread.latestUserMessageAt === null && + bootstrapThread.session === null + ); +} + +/** Cached projects may open the app, but only live workspace data may complete onboarding. */ +export function resolveFirstRunDecision(input: FirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.enabled || (input.hydrated && input.completed)) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.projectCount > 1 || input.threadCount > 1) { + return { + decision: "app", + persistCompletion: + input.bootstrapped && + input.authoritative && + input.workspaceAuthoritative && + input.catalogReady && + input.serverConfigAvailable, + }; + } + + if ( + !input.bootstrapped || + !input.authoritative || + !input.workspaceProvenanceAuthoritative || + !input.catalogReady || + !input.serverConfigAvailable + ) { + return { decision: "pending", persistCompletion: false }; + } + + return input.workspaceFresh + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: input.workspaceAuthoritative }; +} + +/** Hosted onboarding depends on saved environments because there is no primary server. */ +export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.completed) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.catalogReady) { + return { decision: "pending", persistCompletion: false }; + } + + return input.environmentCount === 0 + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: true }; +} diff --git a/apps/web/src/onboarding/firstRun.ts b/apps/web/src/onboarding/firstRun.ts new file mode 100644 index 000000000000..4daafc7cc924 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings"; + +/** + * Marks first-run onboarding finished (or skipped) so FirstRunGate never + * routes to the welcome wizard again. The gate itself lives in + * `components/onboarding/FirstRunGate.tsx`. + */ +export function useCompleteOnboarding(): () => Promise { + return useCallback(async () => { + await ensureClientSettingsHydrated(); + const onboardingCompletedAt = new Date().toISOString(); + await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt })); + }, []); +} diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts new file mode 100644 index 000000000000..07028abd28b4 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -0,0 +1,245 @@ +import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "./projectImport.logic"; + +const now = Date.parse("2026-08-22T12:00:00.000Z"); + +function candidate( + path: string, + overrides: Partial = {}, +): AgentSessionProjectCandidate { + return { + title: path.split("/").at(-1) ?? path, + path, + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-08-20T12:00:00.000Z", + alreadyImported: false, + ...overrides, + }; +} + +describe("partitionOnboardingProjects", () => { + it("keeps existing projects available for thread history import", () => { + const imported = candidate("/projects/current", { alreadyImported: true }); + const available = candidate("/projects/other"); + + expect(partitionOnboardingProjects([imported, available], now)).toEqual({ + available: [imported, available], + recent: [imported, available], + }); + }); + + it("keeps projects older than 30 days out of the default selection", () => { + const recent = candidate("/projects/recent"); + const older = candidate("/projects/older", { + lastActiveAt: "2026-07-01T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, older], now)).toEqual({ + available: [recent, older], + recent: [recent], + }); + }); + + it("keeps future activity out of the default selection", () => { + const recent = candidate("/projects/recent"); + const future = candidate("/projects/future", { + lastActiveAt: "2026-08-23T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, future], now)).toEqual({ + available: [recent, future], + recent: [recent], + }); + }); +}); + +describe("resolveOnboardingProjectId", () => { + const localEnvironmentId = EnvironmentId.make("local"); + const remoteEnvironmentId = EnvironmentId.make("remote"); + const localProjectId = ProjectId.make("local-project"); + + it("uses the scanned project ID before the project reaches the client", () => { + expect( + resolveOnboardingProjectId( + [], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("uses the scanned project ID when the client still has an older project at that root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("stale-project"), + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("returns null to create a project when neither the scan nor the client has a project ID", () => { + expect( + resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), + ).toBeNull(); + }); + + it("finds an existing project by normalized root in the target environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "C:\\Work\\Repo", + }, + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "C:\\Work\\Repo\\", + }, + ], + localEnvironmentId, + candidate("c:/work/repo"), + ), + ).toBe(localProjectId); + }); + + it("does not reuse a project from another environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); + + it("finds an alias after the scanner returns its persisted project root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/real/projects/repo", + }, + ], + localEnvironmentId, + candidate("/real/projects/repo"), + ), + ).toBe(localProjectId); + }); + + it("finds the current root owner when the scan has no project ID", () => { + const recreatedProjectId = ProjectId.make("recreated-project"); + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/other", + }, + { + id: recreatedProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBe(recreatedProjectId); + }); + + it("does not reuse a moved project when the scan has no project ID", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/moved", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); +}); + +describe("resolveOnboardingLandingProject", () => { + it("skips a failed first project for a later project with imported history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/failed", "/projects/imported"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("prefers a partial first import that added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/partial", "/projects/complete"], + new Map([["/projects/partial", "partial"]]), + new Map([["/projects/complete", "complete"]]), + ), + ).toBe("partial"); + }); + + it("uses a completed zero-history project when no import added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/empty", "/projects/failed"], + new Map(), + new Map([["/projects/empty", "empty"]]), + ), + ).toBe("empty"); + }); + + it("keeps an earlier successful import available on retry", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/imported", "/projects/retry"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("ignores cached successes outside the current retry selection", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/current"], + new Map([["/projects/previous", "previous"]]), + new Map([ + ["/projects/previous", "previous"], + ["/projects/current", "current"], + ]), + ), + ).toBe("current"); + }); +}); diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts new file mode 100644 index 000000000000..d723b911b665 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -0,0 +1,55 @@ +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; + +const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +export function partitionOnboardingProjects( + candidates: ReadonlyArray, + now = Date.now(), +) { + const cutoff = now - RECENT_PROJECT_WINDOW_MS; + + return { + available: candidates, + recent: candidates.filter((candidate) => { + if (candidate.lastActiveAt === null) return false; + const lastActiveAt = Date.parse(candidate.lastActiveAt); + return lastActiveAt >= cutoff && lastActiveAt <= now; + }), + }; +} + +/** Use the server's project match before the client snapshot, which can lag behind the scan. */ +export function resolveOnboardingProjectId( + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }>, + environmentId: EnvironmentId, + candidate: Pick, +): ProjectId | null { + if (candidate.projectId !== undefined) return candidate.projectId; + const environmentProjects = projects.filter((project) => project.environmentId === environmentId); + const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); + if (currentRootMatch !== undefined) return currentRootMatch.id; + return null; +} + +/** Prefer a selected project with imported history, then a completed empty import. */ +export function resolveOnboardingLandingProject( + selection: ReadonlyArray, + projectsWithImportedHistory: ReadonlyMap, + completedProjects: ReadonlyMap, +): T | undefined { + for (const path of selection) { + const project = projectsWithImportedHistory.get(path); + if (project !== undefined) return project; + } + for (const path of selection) { + const project = completedProjects.get(path); + if (project !== undefined) return project; + } + return undefined; +} diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts new file mode 100644 index 000000000000..ab742ac51b44 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -0,0 +1,317 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "./providerReadiness.logic"; + +const readyCodex: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getOnboardingProviderState", () => { + it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => { + expect(getOnboardingProviderState(readyCodex)).toBe("ready"); + }); + + it("treats authenticated providers as ready only when their provider status is ready", () => { + expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe( + "ready", + ); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "error", + }), + ).toBe("attention"); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "warning", + }), + ).toBe("attention"); + }); + + it("offers sign-in only when the server reports an authentication failure", () => { + expect( + getOnboardingProviderState({ + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }), + ).toBe("signIn"); + expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention"); + expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention"); + }); + + it("does not offer installation or sign-in for disabled providers", () => { + expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe( + "disabled", + ); + expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled"); + }); + + it("offers installation only when an enabled provider is missing", () => { + expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe( + "install", + ); + }); + + it("waits for a provider snapshot before offering an action", () => { + expect(getOnboardingProviderState(undefined)).toBe("checking"); + }); +}); + +describe("selectOnboardingProvidersByDriver", () => { + it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => { + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe( + readyCodex, + ); + }); + + it("prefers a provider with an actionable sign-in over a failed provider", () => { + const failedCodex: ServerProvider = { ...readyCodex, status: "error" }; + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe( + signedOutCodex, + ); + }); + + it("prefers installed providers over missing or disabled instances", () => { + const disabledCodex: ServerProvider = { ...readyCodex, enabled: false }; + const missingCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + installed: false, + status: "error", + }; + + expect( + selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"), + ).toBe(readyCodex); + }); + + it("handles provider snapshots that have not arrived", () => { + expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0); + }); + + it("keeps a ready custom account when the default account is signed out", () => { + const signedOutDefault: ServerProvider = { + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }; + const readyCustom: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + }; + + expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe( + readyCustom, + ); + }); +}); + +describe("resolveOnboardingProviderLoginCommand", () => { + it("uses the selected Codex account binary", () => { + const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/codex-work/bin/codex" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/codex-work/bin/codex login"); + }); + + it("uses the selected Claude account binary", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude_work"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/claude-work/bin/claude" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/claude-work/bin/claude auth login"); + }); + + it("quotes a Codex path with spaces for PowerShell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login"); + }); + + it("quotes a Claude path with shell metacharacters on POSIX", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + binaryPath: "/opt/Claude Tools/$current/claude", + }, + }, + }, + "linux", + ), + ).toBe("'/opt/Claude Tools/$current/claude' auth login"); + }); + + it.each([ + ["~/my tools/codex", "~/'my tools/codex' login"], + ["~\\my tools/codex", "~/'my tools/codex' login"], + ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`], + ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`], + ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"], + ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath, + }, + }, + }, + "linux", + ), + ).toBe(expectedCommand); + }); + + it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/codex\\work/codex", + }, + }, + }, + platform, + ), + ).toBe("'/opt/codex\\work/codex' login"); + }); + + it("keeps a plain Windows path unquoted", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("C:\\Tools\\codex.exe login"); + }); + + it("uses the default command when an old server reports an unknown shell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/Codex Tools/codex", + }, + }, + }, + "unknown", + ), + ).toBe("codex login"); + }); +}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts new file mode 100644 index 000000000000..939b4c64cd64 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -0,0 +1,99 @@ +import { + ClaudeSettings, + CodexSettings, + type ExecutionEnvironmentPlatformOs, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; + +function quoteProviderBinary( + binaryPath: string, + fallback: string, + platform: ExecutionEnvironmentPlatformOs, +): string { + if ( + SAFE_SHELL_BINARY_PATTERN.test(binaryPath) && + (platform === "windows" || !binaryPath.includes("\\")) + ) { + return binaryPath; + } + if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`; + if (platform === "darwin" || platform === "linux") { + if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) { + return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`; + } + return `'${binaryPath.replaceAll("'", `'"'"'`)}'`; + } + return fallback; +} + +export function getOnboardingProviderState(provider: ServerProvider | undefined) { + if (provider === undefined) return "checking"; + if (!provider.enabled || provider.status === "disabled") return "disabled"; + if (!provider.installed) return "install"; + if (provider.auth.status === "unauthenticated") return "signIn"; + if (provider.status === "ready") return "ready"; + return "attention"; +} + +const PROVIDER_STATE_PRIORITY = { + checking: 0, + disabled: 1, + install: 2, + attention: 3, + signIn: 4, + ready: 5, +} as const; + +/** Select the most usable configured instance for each provider driver. */ +export function selectOnboardingProvidersByDriver( + providers: ReadonlyArray | null | undefined, +) { + const providersByDriver = new Map(); + + for (const provider of providers ?? []) { + const existing = providersByDriver.get(provider.driver); + if ( + existing === undefined || + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] > + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)] + ) { + providersByDriver.set(provider.driver, provider); + } + } + + return providersByDriver; +} + +/** Use the selected provider instance's binary when the setup terminal opens its login flow. */ +export function resolveOnboardingProviderLoginCommand( + provider: ServerProvider, + settings: ServerSettings, + platform: ExecutionEnvironmentPlatformOs, +): string { + const instance = settings.providerInstances[provider.instanceId]; + + if (provider.driver === "claudeAgent") { + const config = decodeClaudeSettings( + instance ? (instance.config ?? {}) : settings.providers.claudeAgent, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; + return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; + } + + if (provider.driver === "codex") { + const config = decodeCodexSettings( + instance ? (instance.config ?? {}) : settings.providers.codex, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; + return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; + } + + return provider.driver; +} diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts new file mode 100644 index 000000000000..9928f83b27db --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -0,0 +1,211 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "./targetEnvironment.logic"; + +const primaryEnvironment = { + environmentId: EnvironmentId.make("primary"), + connection: { phase: "connected" }, + entry: { + target: new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("primary"), + label: "This computer", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + }, + label: "This computer", +} as const; + +const olderRelay = { + environmentId: EnvironmentId.make("older-remote"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("older-remote"), + label: "Older computer", + }), + }, + label: "Older computer", +} as const; + +const newerRelay = { + environmentId: EnvironmentId.make("newer-relay"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("newer-relay"), + label: "New computer", + }), + }, + label: "New computer", +} as const; + +const pairedRemote = { + environmentId: EnvironmentId.make("paired-remote"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("paired-remote"), + label: "Direct computer", + connectionId: "paired-remote", + }), + }, + label: "Direct computer", +} as const; + +const sshEnvironment = { + environmentId: EnvironmentId.make("ssh-remote"), + connection: { phase: "connected" }, + entry: { + target: new SshConnectionTarget({ + environmentId: EnvironmentId.make("ssh-remote"), + label: "SSH computer", + connectionId: "ssh-remote", + }), + }, + label: "SSH computer", +} as const; + +const desktopLocalEnvironment = { + environmentId: EnvironmentId.make("desktop-local-wsl"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("desktop-local-wsl"), + label: "WSL", + connectionId: "local:wsl:Ubuntu", + }), + }, + label: "WSL", +} as const; + +describe("resolveOnboardingTargetEnvironment", () => { + it("waits for the exact paired machine instead of using an older connected machine", () => { + const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pendingPairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the exact paired machine once it connects", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBe(pairedRemote); + }); + + it("waits for a newly paired machine that has not appeared in the catalog", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the primary machine for local onboarding", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("does not substitute a remote machine when the local primary is offline", () => { + const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [offlinePrimary, olderRelay], + primaryEnvironment: offlinePrimary, + pairedEnvironmentId: null, + }), + ).toBeNull(); + }); + + it("uses the newest connected remote when no exact machine was selected", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, olderRelay, newerRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(newerRelay); + }); + + it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [ + primaryEnvironment, + olderRelay, + pairedRemote, + sshEnvironment, + desktopLocalEnvironment, + ], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(olderRelay); + }); + + it("uses the primary computer when no relay connection exists", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("falls back to the connected primary when no remote is available", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); +}); + +describe("isOnboardingRelayEnvironment", () => { + it("includes only T3 Connect relay targets", () => { + expect( + [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter( + isOnboardingRelayEnvironment, + ), + ).toEqual([olderRelay]); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts new file mode 100644 index 000000000000..6045b8c441e2 --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -0,0 +1,49 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +interface OnboardingEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + readonly entry: { readonly target: ConnectionTarget }; +} + +export function isOnboardingRelayEnvironment( + environment: Pick, +): boolean { + return environment.entry.target._tag === "RelayConnectionTarget"; +} + +/** Keep a directly paired machine pinned while its initial connection completes. */ +export function resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, +}: { + readonly mode: "local" | "connect" | "direct"; + readonly environments: ReadonlyArray; + readonly primaryEnvironment: TEnvironment | null; + readonly pairedEnvironmentId: EnvironmentId | null; +}): TEnvironment | null { + if (mode === "direct" && pairedEnvironmentId !== null) { + const pairedEnvironment = environments.find( + (environment) => environment.environmentId === pairedEnvironmentId, + ); + return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null; + } + + const connectedRelayEnvironments = environments.filter( + (environment) => + environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment), + ); + + if (mode === "connect" && connectedRelayEnvironments.length > 0) { + return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null; + } + + if (primaryEnvironment?.connection.phase === "connected") { + return primaryEnvironment; + } + + return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null); +} diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index a5623be4d0fe..206dea56d468 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -43,7 +43,7 @@ export function boundConfiguredLocalServerUrls( return bounded; } -export function useDiscoveredPorts( +function useDiscoveredPorts( environmentId: EnvironmentId | null, configuredUrls?: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..f9956f9c02c8 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -18,12 +18,11 @@ import { readThreadPreviewState, reconcilePreviewServerSessions, rememberPreviewUrl, - removePreviewThread, resetPreviewStateForTests, - subscribeThreadPreviewState, setActivePreviewTab, updatePreviewServerSnapshot, } from "./previewStateStore"; +import { appAtomRegistry } from "./rpc/atomRegistry"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); @@ -353,7 +352,7 @@ describe("previewStateStore (single-tab)", () => { }, }; let updateCount = 0; - const unsubscribe = subscribeThreadPreviewState(ref, () => { + const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => { updateCount += 1; }); @@ -609,12 +608,4 @@ describe("previewStateStore (single-tab)", () => { `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, ); }); - - it("removeThread strips the entry", () => { - const snapshot = makeSnapshot(); - applyPreviewServerSnapshot(ref, snapshot); - removePreviewThread(ref); - const state = readThreadPreviewState(ref); - expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); - }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index eb1052feeb87..1e7ec3706618 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -173,19 +173,6 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); } -export function subscribeThreadPreviewState( - ref: ScopedThreadRef, - listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, -): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); - let previous = appAtomRegistry.get(atom); - return appAtomRegistry.subscribe(atom, (state) => { - const prior = previous; - previous = state; - listener(state, prior); - }); -} - export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; @@ -472,13 +459,6 @@ export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { })); } -export function removePreviewThread(ref: ScopedThreadRef): void { - const threadKey = scopedThreadKey(ref); - appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); - syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); - changedPreviewThreadKeys.delete(threadKey); -} - export function isPreviewSupportedInRuntime(): boolean { if (typeof window === "undefined") return false; return Boolean(window.desktopBridge?.preview); diff --git a/apps/web/src/projectIconOptions.ts b/apps/web/src/projectIconOptions.ts index 9f2fc6c028be..a2213fdd4bd2 100644 --- a/apps/web/src/projectIconOptions.ts +++ b/apps/web/src/projectIconOptions.ts @@ -1,7 +1,7 @@ import { iconNames, type IconName } from "lucide-react/dynamic"; export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; -export const POPULAR_PROJECT_ICONS = [ +const POPULAR_PROJECT_ICONS = [ "folder-code", "code-2", "terminal", diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 48186392e8a3..525be17a72e0 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -70,8 +70,11 @@ function sanitizePlanFileSegment(input: string): string { return sanitized.length > 0 ? sanitized : "plan"; } +/** Prefix of the message the app sends when the user approves a plan. */ +export const PLAN_IMPLEMENTATION_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:\n"; + export function buildPlanImplementationPrompt(planMarkdown: string): string { - return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; + return `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`; } export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 428bbe91317b..fe95cf54890a 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -298,7 +298,7 @@ export function sortProviderInstanceEntries( * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. */ -export function getProviderInstanceEntry( +function getProviderInstanceEntry( providers: ReadonlyArray, instanceId: ProviderInstanceId, ): ProviderInstanceEntry | undefined { diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index b1bed7020e62..568e2b839b91 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -30,7 +30,7 @@ export function getProviderModels( return getProviderSnapshot(providers, provider)?.models ?? []; } -export function getProviderSnapshot( +function getProviderSnapshot( providers: ReadonlyArray, provider: ProviderDriverKind, ): ServerProvider | undefined { @@ -38,13 +38,6 @@ export function getProviderSnapshot( return providers.find((candidate) => candidate.instanceId === defaultInstanceId); } -export function getProviderInteractionModeToggle( - providers: ReadonlyArray, - provider: ProviderDriverKind, -): boolean { - return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; -} - // Resolve an instance selection to the correlated live driver. If the // instance is absent, fall back to a live enabled provider instead of // inferring a driver from the missing instance id. diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 41f75eb384f1..d66ca4789a48 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -180,12 +180,6 @@ export function parseReviewCommentMessageSegments( return segments; } -export function hasReviewCommentMessageSegments(value: string): boolean { - return parseReviewCommentMessageSegments(value).some( - (segment) => segment.kind === "review-comment", - ); -} - export function formatReviewCommentFence(language: string, contents: string): string { const longestBacktickRun = Math.max( 0, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 7f981f2bcda0..4336aa2a35ec 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { migratePersistedRightPanelState, + pullRequestSurface, pullRequestSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, @@ -16,10 +17,114 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")) const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); beforeEach(() => { - useRightPanelStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); }); describe("rightPanelStore", () => { + const completedDiff = { id: "diff", kind: "diff" } as const; + const linkedPullRequest = pullRequestSurface({ + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }); + + it.each(["diff-first", "pull-request-first"])( + "keeps the linked pull request above the completed diff with %s delivery", + (order) => { + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + const requests = + order === "diff-first" + ? [completedDiff, linkedPullRequest] + : [linkedPullRequest, completedDiff]; + for (const surface of requests) store.openProactive(refA, surface, revision); + + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), + ).toEqual(linkedPullRequest); + + store.open(refA, "diff"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }, + ); + + it.each([ + { choice: "file", choose: () => useRightPanelStore.getState().openFile(refA, "src/app.ts") }, + { + choice: "pull request", + choose: () => + useRightPanelStore.getState().openPullRequest(refA, { ...linkedPullRequest, number: 41 }), + }, + { choice: "browser", choose: () => useRightPanelStore.getState().openBrowser(refA, "tab-a") }, + { + choice: "terminal", + choose: () => useRightPanelStore.getState().openTerminal(refA, "term-1"), + }, + { + choice: "same tab", + choose: () => useRightPanelStore.getState().activateSurface(refA, "diff"), + }, + { choice: "hide", choose: () => useRightPanelStore.getState().close(refA) }, + { choice: "toggle", choose: () => useRightPanelStore.getState().toggle(refA, "diff") }, + { choice: "close all", choose: () => useRightPanelStore.getState().closeAllSurfaces(refA) }, + { + choice: "terminal close", + choose: () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.closeTerminal(refA, "terminal:term-1", "term-1"); + }, + }, + ])("keeps a later $choice choice when automatic requests arrive", ({ choose }) => { + const store = useRightPanelStore.getState(); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + choose(); + const chosen = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(false); + expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(false); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe( + chosen, + ); + }); + + it("allows automatic panels for a later turn after a manual choice", () => { + const store = useRightPanelStore.getState(); + const firstTurnRevision = store.getUserActionRevision(refA); + store.openFile(refA, "src/app.ts"); + expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false); + + const nextTurnRevision = store.getUserActionRevision(refA); + expect(store.openProactive(refA, completedDiff, nextTurnRevision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + + it("keeps manual choices scoped to their thread and environment", () => { + const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId); + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + store.openFile(refB, "src/app.ts"); + store.openFile(otherEnvironment, "src/app.ts"); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBe("file"); + expect( + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, otherEnvironment), + ).toBe("file"); + }); + + it("does not treat resource reconciliation as a manual choice", () => { + const store = useRightPanelStore.getState(); + store.openFile(refA, "src/app.ts"); + const revision = store.getUserActionRevision(refA); + store.reconcileBrowserSurfaces(refA, ["agent-browser"]); + store.reconcileFileSurfaces(refA, false); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + it("drops the legacy singleton terminal surface during migration", () => { expect( migratePersistedRightPanelState({ diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index bc7da2d5a9e7..acf673b042bd 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = [ +const RIGHT_PANEL_KINDS = [ "diff", "files", "file", @@ -88,6 +88,18 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; + /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ + userActionRevisionByThreadKey: Record; + getUserActionRevision: (ref: ScopedThreadRef) => number; + /** + * Open a surface on behalf of the app, not the user. Refused when the user + * made a panel choice after `expectedUserActionRevision` was read. + */ + openProactive: ( + ref: ScopedThreadRef, + surface: Extract, + expectedUserActionRevision: number, + ) => boolean; open: ( ref: ScopedThreadRef, kind: Exclude, @@ -237,6 +249,29 @@ const updateThread = ( return { ...byThreadKey, [threadKey]: next }; }; +// Every store action is a user choice unless it goes through `automaticUpdate`. +// Only `openProactive` and resource reconciliation are automatic, so a new +// action counts as a user choice by default. +const automaticUpdate = ( + state: RightPanelStoreState, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Partial => ({ + byThreadKey: updateThread(state.byThreadKey, threadKey, updater), +}); + +const userAction = ( + state: RightPanelStoreState, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Partial => ({ + byThreadKey: updateThread(state.byThreadKey, threadKey, updater), + userActionRevisionByThreadKey: { + ...state.userActionRevisionByThreadKey, + [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, + }, +}); + function normalizeRevealLine(line: number | undefined): number | null { if (line === undefined || !Number.isFinite(line)) return null; return Math.max(1, Math.trunc(line)); @@ -359,37 +394,62 @@ export function migratePersistedRightPanelState(persistedState: unknown): { export const useRightPanelStore = create()( persist( - (set) => ({ + (set, get) => ({ byThreadKey: {}, + userActionRevisionByThreadKey: {}, + getUserActionRevision: (ref) => + get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, + openProactive: (ref, surface, expectedUserActionRevision) => { + let opened = false; + set((state) => { + const threadKey = scopedThreadKey(ref); + if ( + (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision + ) { + return state; + } + // A linked PR takes priority over a completed-turn diff. Manual actions + // always apply, and later user choices reject both proactive requests. + if ( + surface.kind === "diff" && + selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" + ) { + return state; + } + opened = true; + return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface)); + }); + return opened; + }, open: (ref, kind) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); } return upsertSurface(current, singletonSurface(kind)); }), - })), + ), openBrowser: (ref, tabId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = browserSurface(tabId); const withoutPlaceholder = tabId ? current.surfaces.filter((entry) => entry.id !== "browser:new") : current.surfaces; return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), - })), + ), openPullRequest: (ref, target) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { return upsertSurface(current, pullRequestSurface(target)); }), - })), + ), openFile: (ref, relativePath, line) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); @@ -413,10 +473,10 @@ export const useRightPanelStore = create()( : [...withoutStandaloneExplorer, surface], }; }), - })), + ), openAttachment: (ref, attachment) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); @@ -425,16 +485,16 @@ export const useRightPanelStore = create()( attachmentSurface(attachment), ); }), - })), + ), openTerminal: (ref, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => upsertSurface(current, terminalSurface(terminalId)), ), - })), + ), splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, isOpen: true, activeSurfaceId: surfaceId, @@ -451,10 +511,10 @@ export const useRightPanelStore = create()( }; }), })), - })), + ), activateTerminal: (ref, surfaceId, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, activeSurfaceId: surfaceId, surfaces: current.surfaces.map((surface) => @@ -465,10 +525,10 @@ export const useRightPanelStore = create()( : surface, ), })), - })), + ), closeTerminal: (ref, surfaceId, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find( (entry) => entry.id === surfaceId && entry.kind === "terminal", ); @@ -504,18 +564,18 @@ export const useRightPanelStore = create()( ), }; }), - })), + ), activateSurface: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) ? { ...current, isOpen: true, activeSurfaceId: surfaceId } : current, ), - })), + ), closeSurface: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); @@ -530,10 +590,10 @@ export const useRightPanelStore = create()( activeSurfaceId: fallback?.id ?? null, }; }), - })), + ), closeOtherSurfaces: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find((entry) => entry.id === surfaceId); if (!surface || current.surfaces.length === 1) return current; return { @@ -543,10 +603,10 @@ export const useRightPanelStore = create()( activeSurfaceId: surface.id, }; }), - })), + ), closeSurfacesToRight: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0 || index === current.surfaces.length - 1) return current; const surfaces = current.surfaces.slice(0, index + 1); @@ -559,18 +619,18 @@ export const useRightPanelStore = create()( activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId, }; }), - })), + ), closeAllSurfaces: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), - })), + ), reconcileBrowserSurfaces: (ref, tabIds) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); const existingBrowser = current.surfaces.filter( @@ -596,10 +656,10 @@ export const useRightPanelStore = create()( : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), }; }), - })), + ), reconcileFileSurfaces: (ref, workspaceAvailable) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { if (workspaceAvailable) return current; const surfaces = current.surfaces.filter( (surface) => @@ -619,29 +679,29 @@ export const useRightPanelStore = create()( : (surfaces.at(-1)?.id ?? null), }; }), - })), + ), show: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.isOpen ? current : { ...current, isOpen: true }, ), - })), + ), close: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.isOpen ? { ...current, isOpen: false } : current, ), - })), + ), toggleVisibility: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, isOpen: !current.isOpen, })), - })), + ), toggle: (ref, kind) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const active = current.surfaces.find( (surface) => surface.id === current.activeSurfaceId, ); @@ -654,13 +714,20 @@ export const useRightPanelStore = create()( } return upsertSurface(current, singletonSurface(kind)); }), - })), + ), removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); - if (!(threadKey in state.byThreadKey)) return state; + if ( + !(threadKey in state.byThreadKey) && + !(threadKey in state.userActionRevisionByThreadKey) + ) { + return state; + } const { [threadKey]: _removed, ...rest } = state.byThreadKey; - return { byThreadKey: rest }; + const { [threadKey]: _revision, ...userActionRevisionByThreadKey } = + state.userActionRevisionByThreadKey; + return { byThreadKey: rest, userActionRevisionByThreadKey }; }), }), { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..4a7cd3a9abf6 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,14 +9,18 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as PrismRouteImport } from './routes/prism' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsPrismRouteImport } from './routes/settings.prism' +import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -30,6 +34,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const WelcomeRoute = WelcomeRouteImport.update({ + id: '/welcome', + path: '/welcome', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -40,6 +49,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const PrismRoute = PrismRouteImport.update({ + id: '/prism', + path: '/prism', + getParentRoute: () => rootRouteImport, +} as any) const PairRoute = PairRouteImport.update({ id: '/pair', path: '/pair', @@ -69,6 +83,16 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsPrismRoute = SettingsPrismRouteImport.update({ + id: '/prism', + path: '/prism', + getParentRoute: () => SettingsRoute, +} as any) +const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -135,8 +159,10 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/prism': typeof PrismRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -147,6 +173,8 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/prism': typeof SettingsPrismRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -155,8 +183,10 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/prism': typeof PrismRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -167,6 +197,8 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/prism': typeof SettingsPrismRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -178,8 +210,10 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/connect': typeof ConnectRoute '/pair': typeof PairRoute + '/prism': typeof PrismRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -190,6 +224,8 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/prism': typeof SettingsPrismRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -202,8 +238,10 @@ export interface FileRouteTypes { | '/' | '/connect' | '/pair' + | '/prism' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -214,6 +252,8 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/prism' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -222,8 +262,10 @@ export interface FileRouteTypes { to: | '/connect' | '/pair' + | '/prism' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -234,6 +276,8 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/prism' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' @@ -244,8 +288,10 @@ export interface FileRouteTypes { | '/_chat' | '/connect' | '/pair' + | '/prism' | '/settings' | '/usage' + | '/welcome' | '/_chat/pull-requests' | '/connect_/callback' | '/projects/$projectKey' @@ -256,6 +302,8 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/prism' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -267,14 +315,23 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute + PrismRoute: typeof PrismRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/welcome': { + id: '/welcome' + path: '/welcome' + fullPath: '/welcome' + preLoaderRoute: typeof WelcomeRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -289,6 +346,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/prism': { + id: '/prism' + path: '/prism' + fullPath: '/prism' + preLoaderRoute: typeof PrismRouteImport + parentRoute: typeof rootRouteImport + } '/pair': { id: '/pair' path: '/pair' @@ -331,6 +395,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/prism': { + id: '/settings/prism' + path: '/prism' + fullPath: '/settings/prism' + preLoaderRoute: typeof SettingsPrismRouteImport + parentRoute: typeof SettingsRoute + } + '/settings/projects': { + id: '/settings/projects' + path: '/projects' + fullPath: '/settings/projects' + preLoaderRoute: typeof SettingsProjectsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -442,6 +520,8 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsPrismRoute: typeof SettingsPrismRoute + SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -454,6 +534,8 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsPrismRoute: SettingsPrismRoute, + SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } @@ -466,8 +548,10 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, ConnectRoute: ConnectRoute, PairRoute: PairRoute, + PrismRoute: PrismRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 64e858d186eb..12cdd946f6c4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; +import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; @@ -97,6 +98,13 @@ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + const returningFromWelcomeRef = useRef(pathname === "/welcome"); + + useEffect(() => { + if (pathname === "/welcome") { + returningFromWelcomeRef.current = true; + } + }, [pathname]); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -116,6 +124,19 @@ function RootRouteView() { ); } + // The welcome wizard is full-screen like /pair, but keeps toasts so its + // connect/import actions can report failures. + if (pathname === "/welcome") { + return ( + + + + + + + ); + } + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> @@ -133,6 +154,10 @@ function RootRouteView() { ); + // FirstRunGate holds back everything below it — including EventRouter, + // whose welcome payload navigates into a thread — until the first-run + // decision is known, so a fresh install renders nothing (not the shell, + // not a flash of threads) before landing on the welcome wizard. return ( @@ -141,21 +166,28 @@ function RootRouteView() { - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - - - - - - - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {appShell} - {/* Above the router: a theme draft is judged by walking the app, so the - editor has to survive navigation away from settings. */} - + + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + + + + + + + {primaryEnvironmentAuthenticated ? ( + + ) : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {appShell} + {/* Above the router: a theme draft is judged by walking the app, so the + editor has to survive navigation away from settings. */} + + ); @@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() { return null; } -function EventRouter() { +function EventRouter({ + skipInitialBootstrapNavigation, +}: { + readonly skipInitialBootstrapNavigation: boolean; +}) { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -394,6 +430,7 @@ function EventRouter() { const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); + const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation); const handledConfigEventRef = useRef(serverConfigEvent); const [keybindingsToastController] = useState(() => createKeybindingsUpdateToastController({}), @@ -425,6 +462,11 @@ function EventRouter() { if (readPathname() !== "/") { return; } + if (skipInitialBootstrapNavigationRef.current) { + skipInitialBootstrapNavigationRef.current = false; + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + return; + } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { return; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index ba96e8986a97..0fd9fc92945a 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; +import { LinkIcon, PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "../commandPaletteBus"; @@ -21,10 +22,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const { environments } = useEnvironments(); + const { environments, isReady } = useEnvironments(); - if (authGateState.status === "hosted-static" && environments.length === 0) { - return ; + if (authGateState.status === "hosted-static") { + if (!isReady) return null; + if (environments.length === 0) return ; } return ; @@ -79,6 +81,8 @@ function IndexDraftLanding() { /> ) : null; } + // First-run routing to the welcome wizard happens in FirstRunGate at the + // root, before this route ever renders. return ; } @@ -93,7 +97,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
    @@ -157,17 +161,21 @@ function HostedStaticOnboardingState() {
    - Connect an environment to get started + Connect to a computer running T3 Code + + This browser connects to T3 Code running on your computer or a server. Start the T3 + Code desktop app or command-line server on that machine and keep it running. + {cloudEnabled - ? "Sign in to T3 Connect to connect a linked environment through its managed tunnel, or add a reachable backend manually." - : "Add a reachable backend manually to start working from this browser."} + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."}
    diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index a9537423a7f0..7aab91d1b82a 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,5 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { Spinner } from "~/components/ui/spinner"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; import type { @@ -26,10 +28,8 @@ import { LayersIcon, ListChecksIcon, PenLineIcon, - LoaderIcon, Maximize2Icon, Minimize2Icon, - RefreshCwIcon, SearchIcon, } from "lucide-react"; import { @@ -86,6 +86,7 @@ import { writePullRequestListPreferences, } from "../components/pullRequest/pullRequestListPreferences"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; +import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { @@ -356,27 +357,6 @@ function PullRequestsRouteView() { ), [environments], ); - const scopedProjects = useMemo(() => { - // Two machines can hold the same repository, so a title the workspace carries twice is told - // apart by the environment it lives on rather than left as two identical rows. - const titleCounts = new Map(); - for (const project of projects) { - titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); - } - return projects - .map((project) => ({ - id: project.id, - environmentId: project.environmentId, - title: - (titleCounts.get(project.title) ?? 0) > 1 - ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` - : project.title, - workspaceRoot: project.workspaceRoot, - faviconPath: project.faviconPath ?? null, - projectIcon: project.projectIcon ?? null, - })) - .toSorted((left, right) => left.title.localeCompare(right.title)); - }, [environmentLabels, projects]); // The scope the URL asks for, once the environments have had their say about whether it exists. const scopedProjectId = useMemo( () => resolveProjectScope(search.projectId, projects, projectsKnown), @@ -386,6 +366,10 @@ function PullRequestsRouteView() { () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), [projects, scopedEnvironmentId, scopedProjectId], ); + const scopedProjects = useMemo( + () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), + [environmentLabels, projects, scopedProject], + ); // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. @@ -1597,7 +1581,11 @@ function PullRequestsRouteView() { ) : firstLoad ? ( ) : listQuery.error && entries.length === 0 ? ( - listQuery.refresh()} /> + listQuery.refresh()} + /> ) : carriedToNothing ? ( ) : entries.length === 0 ? ( @@ -1674,7 +1662,7 @@ function PullRequestsRouteView() {
    {loadingMore ? ( - + {sentCursors === null ? "Updating pull requests" : "Loading more"} ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( @@ -2334,7 +2322,7 @@ function PullRequestRefreshControl({ onClick={onRefresh} disabled={refreshing} > - + ); } diff --git a/apps/web/src/routes/prism.tsx b/apps/web/src/routes/prism.tsx new file mode 100644 index 000000000000..955016a8fa9e --- /dev/null +++ b/apps/web/src/routes/prism.tsx @@ -0,0 +1,4 @@ +// fork: prism (fork-owned file) +import { createFileRoute } from "@tanstack/react-router"; +import { PrismPage } from "../fork/prism/PrismPage"; +export const Route = createFileRoute("/prism")({ component: PrismPage }); diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx index 6ae03719c042..d636c0a953ef 100644 --- a/apps/web/src/routes/projects.$projectKey.tsx +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -1,15 +1,17 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; - export const Route = createFileRoute("/projects/$projectKey")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, params }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" ) { throw redirect({ to: "/pair", replace: true }); } + throw redirect({ + to: "/settings/projects", + search: { project: params.projectKey, machine: undefined }, + replace: true, + }); }, - component: () => , }); diff --git a/apps/web/src/routes/settings.prism.tsx b/apps/web/src/routes/settings.prism.tsx new file mode 100644 index 000000000000..c0d1f5c56503 --- /dev/null +++ b/apps/web/src/routes/settings.prism.tsx @@ -0,0 +1,8 @@ +// fork: prism (fork-owned file) +import { createFileRoute } from "@tanstack/react-router"; + +import { PrismSettingsPanel } from "../fork/prism/PrismSettingsPanel"; + +export const Route = createFileRoute("/settings/prism")({ + component: PrismSettingsPanel, +}); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx new file mode 100644 index 000000000000..fa79f46fbb2c --- /dev/null +++ b/apps/web/src/routes/settings.projects.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ProjectsSettings } from "../components/settings/ProjectsSettings"; + +export const Route = createFileRoute("/settings/projects")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + machine: typeof search.machine === "string" ? search.machine : undefined, + }), + component: ProjectsRoute, +}); + +function ProjectsRoute() { + const { project, machine } = Route.useSearch(); + const navigate = Route.useNavigate(); + return ( + { + void navigate({ + search: { project: project ?? undefined, machine: machine ?? undefined }, + replace: true, + }); + }} + /> + ); +} diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx new file mode 100644 index 000000000000..10caa4dd46fb --- /dev/null +++ b/apps/web/src/routes/welcome.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; + +/** + * First-run welcome wizard. Full-screen, outside the sidebar shell (the root + * route mounts this path bare, like /pair). Reached only via the first-run + * gate on the index route; visiting it directly after onboarding is harmless — + * finishing again just refreshes the completion flag. + */ +export const Route = createFileRoute("/welcome")({ + beforeLoad: ({ context }) => { + const { authGateState } = context; + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: WelcomeRouteView, +}); + +function WelcomeRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + const openNewThread = useNewThreadHandler(); + // An authenticated gate means a primary server is serving this app — + // desktop, `npx t3`, or a dev server — and that server is "this machine" + // no matter what hostname the browser used. Only hosted-static has no + // local server to offer. + const localAvailable = authGateState.status === "authenticated"; + return ( + { + if (projectRef !== undefined) { + void openNewThread(projectRef, { replace: true }).catch(() => { + void navigate({ to: "/", replace: true }); + }); + return; + } + void navigate({ to: "/", replace: true }); + }} + /> + ); +} diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 9015a3c40b00..2731efecfc85 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -112,7 +112,7 @@ export function acknowledgeRpcRequest(requestId: string): void { setSlowRpcAckRequests(slowRequests.filter((request) => request.requestId !== requestId)); } -export function clearAllTrackedRpcRequests(): void { +function clearAllTrackedRpcRequests(): void { for (const pending of pendingRpcAckRequests.values()) { clearTimeout(pending.timeoutId); } diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 493de5f93bd2..7d0e4777a3a0 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1 @@ -export { - isTransportConnectionErrorMessage, - sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime/errors"; +export { sanitizeThreadErrorMessage } from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dfb0c49be614..401934bce5ba 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -13,8 +13,6 @@ import { createMessageAttachmentPreviewProjector, deriveActiveWorkStartedAt, deriveActivePlanState, - derivePendingApprovals, - derivePendingUserInputs, deriveTimelineEntries, deriveTimelineEntriesWithState, deriveWorkLogEntries, @@ -23,9 +21,7 @@ import { isLatestTurnSettled, selectHandoffImageResources, selectMessageImageResources, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, } from "./session-logic"; let nextActivityId = 0; @@ -65,401 +61,6 @@ function makeActivity(overrides: { }; } -describe("derivePendingApprovals", () => { - it.each([{}, { requestType: "unknown" }])( - "exposes legacy OpenCode approvals without a known request kind: %j", - (legacyPayload) => { - const requested = makeActivity({ - kind: "approval.requested", - payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, - }); - - expect(derivePendingApprovals([requested])).toEqual([ - { - requestId: "per-legacy", - requestKind: "command", - createdAt: requested.createdAt, - detail: "*", - }, - ]); - }, - ); - - it.each(["tool_user_input", "auth_tokens_refresh"])( - "does not turn %s into an approval", - (requestType) => { - const activity = makeActivity({ - kind: "approval.requested", - payload: { requestId: "not-an-approval", requestType }, - }); - - expect(derivePendingApprovals([activity])).toEqual([]); - }, - ); - - it("tracks open approvals and removes resolved ones", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-1", - requestKind: "command", - detail: "bun run lint", - }, - }), - makeActivity({ - id: "approval-close", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "approval.resolved", - summary: "Approval resolved", - tone: "info", - payload: { requestId: "req-2" }, - }), - makeActivity({ - id: "approval-closed-request", - createdAt: "2026-02-23T00:00:01.500Z", - kind: "approval.requested", - summary: "File-change approval requested", - tone: "approval", - payload: { requestId: "req-2", requestType: "unknown" }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "bun run lint", - }, - ]); - }); - - it("maps canonical requestType payloads into pending approvals", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-request-type", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-request-type", - requestType: "command_execution_approval", - detail: "pwd", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-request-type", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "pwd", - }, - ]); - }); - - it("keeps app access approvals and persistence choices from remote activities", () => { - const options = [ - { decision: "decline", label: "Decline" }, - { decision: "acceptAlways", label: "Always allow Safari" }, - { decision: "accept", label: "Approve" }, - ]; - const activities = [ - makeActivity({ - kind: "approval.requested", - summary: "App access approval requested", - tone: "approval", - payload: { - requestId: "req-safari", - requestType: "mcp_elicitation_approval", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-safari", - requestKind: "mcp-elicitation", - createdAt: "2026-02-23T00:00:00.000Z", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - ]); - }); - - it("derives dynamic tool requests as actionable generic approvals", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-dynamic-tool", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Approval requested", - tone: "approval", - payload: { - requestId: "req-dynamic-tool", - requestType: "dynamic_tool_call", - detail: "Search the web", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-dynamic-tool", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "Search the web", - }, - ]); - }); - - it("clears stale pending approvals when provider reports unknown pending request", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-stale", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-stale-1", - requestType: "unknown", - }, - }), - makeActivity({ - id: "approval-failed-stale", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.approval.respond.failed", - summary: "Provider approval response failed", - tone: "error", - payload: { - requestId: "req-stale-1", - detail: "Unknown pending permission request: req-stale-1", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([]); - }); - - it("clears stale pending approvals when the backend marks them stale after restart", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-stale-restart", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-stale-restart-1", - requestKind: "command", - }, - }), - makeActivity({ - id: "approval-failed-stale-restart", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.approval.respond.failed", - summary: "Provider approval response failed", - tone: "error", - payload: { - requestId: "req-stale-restart-1", - detail: - "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([]); - }); -}); - -describe("derivePendingUserInputs", () => { - it("keeps free-text questions without suggested answers", () => { - const question = { - id: "0", - header: "Question", - question: "What should it be named?", - options: [], - allowCustomAnswer: true, - multiSelect: false, - }; - const activities = [ - makeActivity({ - id: "async-question", - kind: "user-input.requested", - summary: "User input requested", - payload: { requestId: "async-1", responseMode: "message", questions: [question] }, - }), - ]; - expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]); - }); - - it("preserves native choice values and the custom-answer restriction", () => { - const question = { - id: "interaction-result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, - }; - const activities = [ - makeActivity({ - id: "native-user-input", - kind: "user-input.requested", - summary: "User input requested", - payload: { requestId: "req-native-choice", questions: [question] }, - }), - ]; - - expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]); - }); - - it("tracks open structured prompts and removes resolved ones", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "user-input-open", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: true, - }, - ], - }, - }), - makeActivity({ - id: "user-input-resolved", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "user-input.resolved", - summary: "User input submitted", - tone: "info", - payload: { - requestId: "req-user-input-2", - answers: { - sandbox_mode: "workspace-write", - }, - }, - }), - makeActivity({ - id: "user-input-open-2", - createdAt: "2026-02-23T00:00:01.500Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-2", - questions: [ - { - id: "approval", - header: "Approval", - question: "Continue?", - options: [ - { - label: "yes", - description: "Continue execution", - }, - ], - multiSelect: false, - }, - ], - }, - }), - ]; - - expect(derivePendingUserInputs(activities)).toEqual([ - { - requestId: "req-user-input-1", - createdAt: "2026-02-23T00:00:01.000Z", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: true, - }, - ], - }, - ]); - }); - - it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "user-input-open-stale", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: false, - }, - ], - }, - }), - makeActivity({ - id: "user-input-failed-stale", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - tone: "error", - payload: { - requestId: "req-user-input-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", - }, - }), - ]; - - expect(derivePendingUserInputs(activities)).toEqual([]); - }); -}); - describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ @@ -809,121 +410,43 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("workEntryIndicatesToolFailure", () => { - const base = { - id: "w1", - createdAt: "2026-01-01T00:00:00.000Z", - label: "Read", - }; - - it("is true for error tone", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "error", - detail: "nothing special", - }), - ).toBe(true); - }); - - it("is true when lifecycle says failed even if detail is empty", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "failed", - }), - ).toBe(true); - }); - - it("detects file-not-found style tool output with completed lifecycle", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "File not found: C:\\foo\\nonexistent.ts", - }), - ).toBe(true); - }); - - it("detects glob no files and PowerShell command errors", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Glob", - tone: "tool", - detail: "No files found", - }), - ).toBe(true); - expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Bash", - tone: "tool", - detail: - "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", - }), - ).toBe(true); - }); - - it("is false for successful completed tools", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "Found 3 matching files", - }), - ).toBe(false); - }); - - it("treats successful tool rows as success candidates", () => { - expect( - workEntryIndicatesToolSuccess({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "ok", - }), - ).toBe(true); - expect( - workEntryIndicatesToolSuccess({ - ...base, - tone: "tool", - toolLifecycleStatus: "inProgress", - detail: "…", - }), - ).toBe(false); - expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); - expect( - workEntryIndicatesToolNeutralStatus({ - ...base, - tone: "tool", - toolLifecycleStatus: "inProgress", - detail: "…", - }), - ).toBe(true); +describe("workEntryIndicatesToolNeutralStatus", () => { + it("keeps active tools neutral and agent spawns visible", () => { + const entry = { + id: "work-1", + createdAt: "2026-01-01T00:00:00.000Z", + label: "Read", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }; + expect(workEntryIndicatesToolNeutralStatus(entry)).toBe(true); expect( workEntryIndicatesToolNeutralStatus({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "ok", + ...entry, + agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] }, }), ).toBe(false); - }); - - it("does not run heuristics on non-tool info rows", () => { expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Context compacted", - tone: "info", - detail: "File not found in conversation", - }), + workEntryIndicatesToolNeutralStatus({ ...entry, toolLifecycleStatus: "completed" }), ).toBe(false); }); + + it.each(["waiting", "cancelled", "interrupted"])( + "keeps the status of a %s background task", + (status) => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.progress", + payload: { taskId: "background-1", agentKind: "background", status }, + }), + ]); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + toolLifecycleStatus: status === "waiting" ? "inProgress" : "stopped", + }); + expect(workEntryIndicatesToolNeutralStatus(entries[0]!)).toBe(true); + }, + ); }); describe("deriveWorkLogEntries", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5c9d89e5ea33..bbef3a4945a6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,26 +1,29 @@ +import { + requestKindFromRequestType, + type PendingApproval, +} from "@t3tools/client-runtime/pending-requests"; import * as Option from "effect/Option"; import * as Arr from "effect/Array"; -import * as Schema from "effect/Schema"; import { shallow } from "zustand/vanilla/shallow"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; import { commandDetailRepeatsCommand, extractCommandOutputText, + extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, + workEntryIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, + type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { - ApprovalRequestId, isToolLifecycleItemType, type AssetResource, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, - ProviderDriverKind, - ProviderApprovalOption, - ProviderRequestKind, type ToolLifecycleItemType, - type UserInputQuestion, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -36,51 +39,16 @@ import { type TurnDiffSummary, } from "./types"; -export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; - -export type ProviderPickerKind = ProviderDriverKind; +export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; -export const PROVIDER_OPTIONS: Array<{ - value: ProviderPickerKind; - label: string; - available: boolean; - /** Shown on the model picker sidebar when relevant */ - pickerSidebarBadge?: "new" | "soon"; -}> = [ - { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, - { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, - { - value: ProviderDriverKind.make("opencode"), - label: "OpenCode", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("cursor"), - label: "Cursor", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("grok"), - label: "Grok", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("antigravity"), - label: "Antigravity", - available: true, - pickerSidebarBadge: "new", - }, -]; +export { formatDuration } from "@t3tools/shared/orchestrationTiming"; -export type WorkLogToolLifecycleStatus = - | "inProgress" - | "completed" - | "failed" - | "declined" - | "stopped"; +export { + workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, + type WorkLogToolLifecycleStatus, +} from "@t3tools/client-runtime/work-log/presentation"; export interface WorkLogEntry { id: string; @@ -139,24 +107,6 @@ const derivedWorkLogEntryByActivity = new WeakMap< DerivedWorkLogEntry >(); -export interface PendingApproval { - requestId: ApprovalRequestId; - requestKind: ProviderRequestKind; - createdAt: string; - detail?: string; - appName?: string; - options?: ReadonlyArray; -} - -const isProviderRequestKind = Schema.is(ProviderRequestKind); -const isProviderApprovalOption = Schema.is(ProviderApprovalOption); - -export interface PendingUserInput { - requestId: ApprovalRequestId; - createdAt: string; - questions: ReadonlyArray; -} - export interface ActivePlanState { createdAt: string; turnId: TurnId | null; @@ -205,103 +155,6 @@ export interface TimelineEntriesProjection { readonly entries: TimelineEntry[]; } -export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { - if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { - return true; - } - if (entry.command !== undefined && entry.command.trim().length > 0) { - return true; - } - if (entry.requestKind !== undefined) { - return true; - } - return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); -} - -/** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */ -function toolDetailTextLooksLikeFailure(text: string): boolean { - const t = text.toLowerCase(); - if (t.includes("file not found")) { - return true; - } - if (t.includes("no files found")) { - return true; - } - if ( - t.includes("enoent") || - t.includes("no such file or directory") || - t.includes("no such file") - ) { - return true; - } - if (t.includes("cannot find path") && t.includes("because it does not exist")) { - return true; - } - if (t.includes("commandnotfoundexception")) { - return true; - } - if (t.includes("is not recognized as the name of a cmdlet")) { - return true; - } - if (t.includes("is not recognized") && t.includes("the term '")) { - return true; - } - if (t.includes("a parameter cannot be found that matches parameter name")) { - return true; - } - if (t.includes("command not found")) { - return true; - } - if (//i.test(text)) { - return true; - } - if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) { - return true; - } - if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) { - return true; - } - return false; -} - -function workEntryIndicatesToolFailureFromOutput( - entry: WorkLogEntry, - includeCommand: boolean, -): boolean { - if (entry.tone === "error") { - return true; - } - const ls = entry.toolLifecycleStatus; - if (ls === "failed" || ls === "declined") { - return true; - } - if (!workLogEntryIsToolLike(entry)) { - return false; - } - const parts: string[] = []; - if (entry.detail) { - parts.push(entry.detail); - } - if (includeCommand && entry.command) { - parts.push(entry.command); - } - const blob = parts.join("\n"); - if (blob.length === 0) { - return false; - } - return toolDetailTextLooksLikeFailure(blob); -} - -/** True when a tool failed, including providers that put error output in `command`. */ -export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, true); -} - -/** True when the rendered result indicates failure. The command itself is user intent, not output. */ -export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, false); -} - /** Severe failures keep the red treatment ordinary tool failures lost: runtime * errors and orchestration `*.failed` activities (provider.turn.start.failed, * checkpoint.capture.failed, ...) mean the turn or a core side effect broke, @@ -313,30 +166,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { ); } -/** Tool/command row completed without failure (blue check affordance). */ -export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { - if (!workLogEntryIsToolLike(entry)) { - return false; - } - if (workEntryIndicatesToolFailure(entry)) { - return false; - } - if (entry.tone === "thinking") { - return false; - } - const ls = entry.toolLifecycleStatus; - if (ls === "failed" || ls === "declined") { - return false; - } - if (ls === "inProgress") { - return false; - } - if (ls === "stopped") { - return false; - } - return true; -} - /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { // Spawn CTA rows are never neutral-hidden: mid-run they derive from @@ -390,208 +219,6 @@ export function deriveActiveWorkStartedAt( return sendStartedAt; } -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - case "dynamic_tool_call": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - case "mcp_elicitation_approval": - return "mcp-elicitation"; - default: - return null; - } -} - -function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { - const normalized = detail?.toLowerCase(); - if (!normalized) { - return false; - } - return ( - normalized.includes("stale pending approval request") || - normalized.includes("stale pending user-input request") || - normalized.includes("unknown pending approval request") || - normalized.includes("unknown pending permission request") || - normalized.includes("unknown pending user-input request") || - normalized.includes("unknown pending user input request") || - normalized.includes("unknown pending codex user input request") - ); -} - -export function derivePendingApprovals( - activities: ReadonlyArray, -): PendingApproval[] { - const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - - for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.make(payload.requestId) - : null; - const requestKind = - payload && isProviderRequestKind(payload.requestKind) - ? payload.requestKind - : payload - ? requestKindFromRequestType(payload.requestType) - : null; - const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; - const appName = payload && typeof payload.appName === "string" ? payload.appName : undefined; - const options = Array.isArray(payload?.options) - ? payload.options.filter(isProviderApprovalOption) - : undefined; - - if ( - activity.kind === "approval.requested" && - requestId && - payload?.requestType !== "tool_user_input" && - payload?.requestType !== "auth_tokens_refresh" - ) { - openByRequestId.set(requestId, { - requestId, - // Older OpenCode requests can have no recognized approval kind. - requestKind: requestKind ?? "command", - createdAt: activity.createdAt, - ...(detail ? { detail } : {}), - ...(appName ? { appName } : {}), - ...(options && options.length > 0 ? { options } : {}), - }); - continue; - } - - if (activity.kind === "approval.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.approval.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - continue; - } - } - - return [...openByRequestId.values()].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); -} - -function parseUserInputQuestions( - payload: Record | null, -): ReadonlyArray | null { - const questions = payload?.questions; - if (!Array.isArray(questions)) { - return null; - } - const parsed = questions - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - const question = entry as Record; - if ( - typeof question.id !== "string" || - typeof question.header !== "string" || - typeof question.question !== "string" || - !Array.isArray(question.options) - ) { - return null; - } - const options = question.options - .map((option) => { - if (!option || typeof option !== "object") return null; - const optionRecord = option as Record; - if ( - typeof optionRecord.label !== "string" || - typeof optionRecord.description !== "string" - ) { - return null; - } - return { - label: optionRecord.label, - description: optionRecord.description, - ...(typeof optionRecord.value === "string" ? { value: optionRecord.value } : {}), - }; - }) - .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0 && question.allowCustomAnswer === false) { - return null; - } - return { - id: question.id, - header: question.header, - question: question.question, - options, - multiSelect: question.multiSelect === true, - ...(typeof question.allowCustomAnswer === "boolean" - ? { allowCustomAnswer: question.allowCustomAnswer } - : {}), - }; - }) - .filter((question): question is UserInputQuestion => question !== null); - return parsed.length > 0 ? parsed : null; -} - -export function derivePendingUserInputs( - activities: ReadonlyArray, -): PendingUserInput[] { - const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - - for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.make(payload.requestId) - : null; - const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; - - if (activity.kind === "user-input.requested" && requestId) { - const questions = parseUserInputQuestions(payload); - if (!questions) { - continue; - } - openByRequestId.set(requestId, { - requestId, - createdAt: activity.createdAt, - questions, - }); - continue; - } - - if (activity.kind === "user-input.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.user-input.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return [...openByRequestId.values()].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); -} - function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = activity.payload && typeof activity.payload === "object" @@ -865,25 +492,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } -function extractWorkLogToolLifecycleStatus( - payload: Record | null, -): WorkLogToolLifecycleStatus | undefined { - if (!payload) { - return undefined; - } - const s = payload.status; - if ( - s === "inProgress" || - s === "completed" || - s === "failed" || - s === "declined" || - s === "stopped" - ) { - return s; - } - return undefined; -} - function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const cachedEntry = derivedWorkLogEntryByActivity.get(activity); if (cachedEntry) { diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8cf3c5665aca..3489ae57bf5d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -15,11 +15,12 @@ export interface SidebarProjectSnapshot extends Project { groupedProjectCount: number; environmentPresence: EnvironmentPresence; // True iff every non-primary member of this group lives in a - // desktopLocal env (today: the WSL backend). The sidebar uses this + // desktop-local environment. The sidebar uses this // to differentiate "lives on this machine but in a sandbox" from // "lives on a real remote" so the project header can pick a - // container icon instead of the generic cloud icon. + // local-device treatment instead of the generic remote treatment. allRemoteMembersAreDesktopLocal: boolean; + allRemoteMembersAreWsl: boolean; memberProjects: readonly SidebarProjectGroupMember[]; memberProjectRefs: readonly ScopedProjectRef[]; remoteEnvironmentLabels: readonly string[]; @@ -55,11 +56,12 @@ export function buildSidebarProjectSnapshots(input: { settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; - // Returns true when an env id maps to a desktopLocal saved-env - // record (today: the WSL backend). Defaults to "false for every + // Returns true when an env id maps to a desktop-local saved-env + // record. Defaults to "false for every // env" so callers that don't care about the distinction get the // legacy behavior. isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; + isWslEnvironment?: (environmentId: EnvironmentId) => boolean; }): SidebarProjectSnapshot[] { return buildProjectGroups({ projects: input.projects, @@ -95,9 +97,12 @@ export function buildSidebarProjectSnapshots(input: { .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false); + const isWsl = input.isWslEnvironment ?? (() => false); const allRemoteMembersAreDesktopLocal = remoteMembers.length > 0 && remoteMembers.every((member) => isDesktopLocal(member.environmentId)); + const allRemoteMembersAreWsl = + remoteMembers.length > 0 && remoteMembers.every((member) => isWsl(member.environmentId)); return { ...representative, @@ -107,6 +112,7 @@ export function buildSidebarProjectSnapshots(input: { environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", allRemoteMembersAreDesktopLocal, + allRemoteMembersAreWsl, memberProjects: members, memberProjectRefs: group.memberProjectRefs, remoteEnvironmentLabels, diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 116f27b95f97..4e757e6954a5 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -3,8 +3,6 @@ import type { ElementType } from "react"; import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; export { DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - formatChangeRequestAction, - formatCreateChangeRequestPhrase, getChangeRequestTerminology, resolveChangeRequestPresentation, type ChangeRequestPresentation, diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts new file mode 100644 index 000000000000..996ddb0ea730 --- /dev/null +++ b/apps/web/src/state/agentSessions.ts @@ -0,0 +1,25 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Scan of Claude Code / Codex home directories on an environment, surfacing + * project candidates for the welcome wizard's import step. The scan walks the + * filesystem server-side, so results are cached briefly and refreshed when the + * import step remounts. + */ +export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:agent-sessions:scan", + tag: WS_METHODS.agentSessionsScan, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, +}); + +export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:agent-sessions:import", + tag: WS_METHODS.agentSessionsImport, +}); diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b5..d1ef71f8662d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,5 +1,16 @@ -import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; +import { + createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, +} from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c44c5b437b63..d9610e20717f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -39,7 +39,7 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pip Atom.withLabel("web-thread-status:empty"), ); -export const activeEnvironmentIdAtom = Atom.make(null).pipe( +const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("web-active-environment-id"), ); @@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } +export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadActiveReorder === true + ); +} + export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cdc..f085075fdd7c 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -11,7 +11,6 @@ import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; -import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -85,7 +84,3 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } - -export function useEnvironmentConnectionState(environmentId: EnvironmentId) { - return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); -} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..1792c5e9e599 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -14,7 +14,6 @@ import type { OrchestrationThread, ProjectContentMatch, ProjectEntryKind, - ThreadId, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; @@ -28,7 +27,6 @@ import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; -import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; @@ -103,35 +101,6 @@ export function useThreadSearch( }; } -export function useThreadDetail( - environmentId: EnvironmentId | null, - threadId: ThreadId | null, -): ThreadDetailView { - const state = useEnvironmentThread(environmentId, threadId); - return { - data: Option.getOrNull(state.data), - error: Option.getOrNull(state.error), - isPending: state.status === "synchronizing", - isDeleted: state.status === "deleted", - }; -} - -export function useBranches(target: VcsRefTarget) { - const query = target.query?.trim() ?? ""; - return useEnvironmentQuery( - target.environmentId !== null && target.cwd !== null - ? vcsEnvironment.listRefs({ - environmentId: target.environmentId, - input: { - cwd: target.cwd, - ...(query.length > 0 ? { query } : {}), - limit: VCS_REF_LIST_LIMIT, - }, - }) - : null, - ); -} - export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 0eacc933da49..f13965e5b4d8 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt initialConfigValueAtom: environmentSession.initialConfigValueAtom, environmentThemes: true, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, @@ -49,7 +50,7 @@ const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { welcome: null, }; -export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { +const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { const environmentId = get(primaryEnvironmentIdAtom); if (environmentId === null) { return EMPTY_PRIMARY_SERVER_STATE; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 1f88da2f971d..b1719819da9d 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -4,7 +4,6 @@ import { } from "@t3tools/client-runtime/connection"; import { createEnvironmentShellAtoms, - createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, type EnvironmentShellState, @@ -21,10 +20,6 @@ import { isHostedStaticApp } from "../hostedPairing"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); -export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - shellStateValueAtom: environmentShell.stateValueAtom, -}); export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index fd936f99ff23..c7caaa6a35a7 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -16,7 +16,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); -export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index be65400c9800..617ac93e4b7c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,7 +13,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; @@ -70,7 +70,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } export function useUsage( @@ -108,26 +108,17 @@ export function useUsage( [environments, selectedEnvironmentIds], ); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of selectedEnvironments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [selectedEnvironments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [selectedEnvironments, windowKey], + ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 34c6c9830a0a..3c466378ba8b 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -6,8 +6,6 @@ import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, type TerminalBufferLineLike, } from "./terminal-links"; @@ -154,46 +152,6 @@ describe("collectWrappedTerminalLinkLine", () => { }); }); -describe("resolveWrappedTerminalLinkRange", () => { - it("maps wrapped URL matches back to the correct buffer rows", () => { - const prefix = "see "; - const firstSegment = `${prefix}https://example.com/a`; - const secondSegment = "/bc?x=1"; - const lines = [ - createBufferLine("prompt> "), - createBufferLine(firstSegment), - createBufferLine(secondSegment, true), - ]; - const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); - - expect(wrappedLine).not.toBeNull(); - if (!wrappedLine) { - throw new Error("Expected wrapped terminal line to be present."); - } - - const [match] = extractTerminalLinks(wrappedLine.text); - expect(match).toEqual({ - kind: "url", - text: "https://example.com/a/bc?x=1", - start: prefix.length, - end: firstSegment.length + secondSegment.length, - }); - if (!match) { - throw new Error("Expected wrapped URL match to be present."); - } - - const range = resolveWrappedTerminalLinkRange(wrappedLine, match); - - expect(range).toEqual({ - start: { x: prefix.length + 1, y: 2 }, - end: { x: secondSegment.length, y: 3 }, - }); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false); - }); -}); - describe("resolvePathLinkTarget", () => { it("resolves relative paths against cwd", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 204d0a742a05..59e2082a7359 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -14,16 +14,6 @@ export interface TerminalLinkMatch { end: number; } -export interface TerminalLinkBufferPosition { - x: number; - y: number; -} - -export interface TerminalLinkBufferRange { - start: TerminalLinkBufferPosition; - end: TerminalLinkBufferPosition; -} - export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string; @@ -199,43 +189,6 @@ export function collectWrappedTerminalLinkLine( }; } -function resolveCharacterPosition( - segments: ReadonlyArray, - characterIndex: number, -): TerminalLinkBufferPosition { - for (const segment of segments) { - if (characterIndex < segment.endIndex) { - return { - x: characterIndex - segment.startIndex + 1, - y: segment.bufferLineNumber, - }; - } - } - - const lastSegment = segments[segments.length - 1]; - return { - x: Math.max(lastSegment?.text.length ?? 0, 1), - y: lastSegment?.bufferLineNumber ?? 1, - }; -} - -export function resolveWrappedTerminalLinkRange( - wrappedLine: WrappedTerminalLinkLine, - match: Pick, -): TerminalLinkBufferRange { - return { - start: resolveCharacterPosition(wrappedLine.segments, match.start), - end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), - }; -} - -export function wrappedTerminalLinkRangeIntersectsBufferLine( - range: TerminalLinkBufferRange, - bufferLineNumber: number, -): boolean { - return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; -} - export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index e9933cdb4b73..ee3240b41b08 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -23,8 +23,6 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, - terminalLinkAtColumn, - terminalLinkAtPosition, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -433,7 +431,7 @@ describe("shouldBlinkTerminalCursor", () => { }); }); -describe("terminalLinkAtColumn", () => { +describe("terminalLinkAtPositionWithRange", () => { it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { const cells = [ cell("🙂"), @@ -450,9 +448,11 @@ describe("terminalLinkAtColumn", () => { wrapsToNext: false, }; - expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 2)?.text).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([row], 0, cells.length - 1)?.text).toBe( + "https://t3.codes", + ); + expect(terminalLinkAtPositionWithRange([row], 0, 0)).toBeNull(); expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ start: { x: 2, y: 0 }, end: { x: cells.length - 1, y: 0 }, @@ -473,10 +473,10 @@ describe("terminalLinkAtColumn", () => { row("C:\\repo\\file.ts", false), ]; - expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); - expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file"); + expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts"); expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ text: "https://example.com/reference", range: { @@ -495,13 +495,13 @@ describe("terminalLinkAtColumn", () => { }); // The head of the wrapped line scrolled above the viewport. const headCut = [row("ple.com/missing", true), row("head", true)]; - expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + expect(terminalLinkAtPositionWithRange(headCut, 0, 4)).toBeNull(); // The bottom row soft-wraps on below the viewport. const tailCut = [row("https://t3.codes", false, true)]; - expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange(tailCut, 0, 8)).toBeNull(); // A partial bottom row is provably complete and still resolves. const complete = [row("https://t3.codes", false), row("", false)]; - expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange(complete, 0, 8)?.text).toBe("https://t3.codes"); // A wide grapheme earlier in the row must not break truncation detection: // the soft-wrap flag decides, not string-length-versus-cell-count. const wideFull: GhosttyRow = { @@ -514,7 +514,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: true, }; - expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange([wideFull], 0, 8)).toBeNull(); // Unwritten trailing cells prove the bottom row is complete. const unwrittenTail: GhosttyRow = { cells: [ @@ -526,7 +526,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: false, }; - expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([unwrittenTail], 0, 8)?.text).toBe("https://t3.codes"); }); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 826dcd014053..29aaac6f6abd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -244,14 +244,6 @@ function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: n return offset; } -export function terminalLinkAtPosition( - rows: GhosttySnapshot["rowData"], - rowIndex: number, - column: number, -): string | null { - return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; -} - export interface TerminalLinkWithRange { readonly text: string; readonly range: GhosttyCellRange; @@ -325,10 +317,6 @@ export function terminalLinkAtPositionWithRange( return null; } -export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { - return terminalLinkAtPosition([row], 0, column); -} - export function isTerminalCopyShortcut( event: Pick, platform = navigator.platform, diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts index 1b4b26fb6988..3a9bf9484ea1 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -34,7 +34,7 @@ import type { Dispatch, SetStateAction } from "react"; * Call `beginRender()` before each component invocation and `reset()` in * `beforeEach` to drop persisted state between tests. */ -export function createReactHookHarness() { +function createReactHookHarness() { let cursor = 0; let slots: unknown[] = []; const nextIndex = () => cursor++; diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a76885a862ee..e1d9bfb74cab 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,7 +34,6 @@ import { OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, - createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, themeColorToHex, @@ -91,50 +90,6 @@ describe("theme files", () => { } }); - it("derives a readable palette from extreme simple-editor colors", () => { - const light = createManagedThemeColors("light", "#111827", "#ffff00"); - const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); - const darkDefaults = getDefaultThemeColors("dark"); - - expect(asHex(light.canvas)).not.toBe("#111827"); - expect(asHex(dark.canvas)).not.toBe("#ffffff"); - expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1); - expect(light.secondaryLabel).toBe(light.textMuted); - expect(dark.secondaryLabel).toBe(dark.textMuted); - expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5); - // Status colors fall back to T3 Code's standard red and amber rather than - // the flagship palette's, so no generated theme inherits a brand tint. - const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; - for (const colors of [light, dark]) { - const [errorRed, errorGreen, errorBlue] = channels(colors.error); - // Red leads by a wide margin; the old default was a pink whose blue sat - // close behind its red. - expect(errorRed).toBeGreaterThan(errorGreen * 2); - expect(errorRed).toBeGreaterThan(errorBlue * 2); - expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); - expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( - 4.5, - ); - const [warnRed, warnGreen, warnBlue] = channels(colors.warning); - expect(warnRed).toBeGreaterThan(warnBlue); - expect(warnGreen).toBeGreaterThan(warnBlue); - } - expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); - }); - it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -158,6 +113,12 @@ describe("theme files", () => { ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; + const channels = (value: string) => + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. @@ -193,6 +154,17 @@ describe("theme files", () => { expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); + // Semantic statuses stay red and amber instead of inheriting a brand tint. + const [errorRed, errorGreen, errorBlue] = channels(colors.error); + expect(errorRed).toBeGreaterThan(errorGreen * 2); + expect(errorRed).toBeGreaterThan(errorBlue * 2); + expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + const [warnRed, warnGreen, warnBlue] = channels(colors.warning); + expect(warnRed).toBeGreaterThan(warnBlue); + expect(warnGreen).toBeGreaterThan(warnBlue); } }); @@ -202,8 +174,8 @@ describe("theme files", () => { const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), - createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }), - createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }), + createVividThemeColors("light", "#0d1117", "#69b1ff"), + createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f67eb943e3fb..ef776dced9b7 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -22,15 +22,10 @@ export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; -export const T3_CHAT_THEME_LABEL = "T3 Chat"; -export const GROVE_THEME_ID = "grove" as const; -export const GROVE_THEME_LABEL = "Grove"; +const GROVE_THEME_ID = "grove" as const; export const OCEAN_THEME_ID = "ocean" as const; -export const OCEAN_THEME_LABEL = "Ocean"; -export const EMBER_THEME_ID = "ember" as const; -export const EMBER_THEME_LABEL = "Ember"; -export const IRIS_THEME_ID = "iris" as const; -export const IRIS_THEME_LABEL = "Iris"; +const EMBER_THEME_ID = "ember" as const; +const IRIS_THEME_ID = "iris" as const; export const THEME_FILE_VERSION = 1 as const; export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; @@ -475,12 +470,6 @@ type ThemeRgbColor = { b: number; }; -type ThemeHslColor = { - h: number; - s: number; - l: number; -}; - type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; @@ -601,48 +590,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { }; } -function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { - const red = color.r / 255; - const green = color.g / 255; - const blue = color.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const lightness = (max + min) / 2; - - if (delta === 0) return { h: 0, s: 0, l: lightness }; - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - let hue = 0; - if (max === red) hue = ((green - blue) / delta) % 6; - else if (max === green) hue = (blue - red) / delta + 2; - else hue = (red - green) / delta + 4; - - return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness }; -} - -function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor { - const hue = ((color.h % 360) + 360) % 360; - const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s; - const hueSector = hue / 60; - const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1)); - const match = color.l - chroma / 2; - const [red, green, blue] = - hueSector < 1 - ? [chroma, secondary, 0] - : hueSector < 2 - ? [secondary, chroma, 0] - : hueSector < 3 - ? [0, chroma, secondary] - : hueSector < 4 - ? [0, secondary, chroma] - : hueSector < 5 - ? [secondary, 0, chroma] - : [chroma, 0, secondary]; - - return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 }; -} - function mixThemeRgbColors( base: ThemeRgbColor, overlay: ThemeRgbColor, @@ -1046,205 +993,6 @@ function standardMutedThemeText( return readableThemeText(background, foreground, 1, target); } -function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor { - const selected = parseThemeRgbColor( - value, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ); - const hsl = themeRgbToHsl(selected); - return themeHslToRgb({ - h: hsl.h, - // A background tint should support the selected mode, not turn the whole - // app into a high-saturation surface. - s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2), - l: - appearance === "dark" - ? Math.min(0.13, Math.max(0.07, hsl.l)) - : Math.min(0.985, Math.max(0.94, hsl.l)), - }); -} - -function managedThemeAccent( - value: string, - appearance: ThemeAppearance, - background: ThemeRgbColor, -): ThemeRgbColor { - const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 }); - const hsl = themeRgbToHsl(selected); - const preferredLightness = - appearance === "dark" - ? Math.min(0.72, Math.max(0.42, hsl.l)) - : Math.min(0.58, Math.max(0.35, hsl.l)); - const lightnessRange: readonly [number, number] = - appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58]; - const saturation = Math.min(hsl.s, 0.82); - const candidates = Array.from({ length: 61 }, (_, index) => { - const lightness = - lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1); - const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); - return { color, lightness, contrast: themeContrastRatio(color, background) }; - }); - // Leave a little room for browser color conversion at render time. - const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); - const pool = readableCandidates.length > 0 ? readableCandidates : candidates; - - return pool.reduce((best, candidate) => { - const distance = Math.abs(candidate.lightness - preferredLightness); - const bestDistance = Math.abs(best.lightness - preferredLightness); - return distance < bestDistance || - (distance === bestDistance && candidate.contrast > best.contrast) - ? candidate - : best; - }).color; -} - -/** - * Creates the guided palette used by the basic theme editor. The two user - * colors control the mood, while dependent roles are generated together so - * text, surfaces, message actions, code, and terminal UI stay coherent. - */ -export function createManagedThemeColors( - appearance: ThemeAppearance, - backgroundValue: string, - accentValue: string, - options?: { - /** Use the seeds exactly as given instead of nudging them into the - * readability envelope. Derived foregrounds still adapt for contrast. */ - exactSeeds?: boolean; - }, -): ThemeColors { - const defaults = getDefaultThemeColors(appearance); - const canvas = options?.exactSeeds - ? parseThemeRgbColor( - backgroundValue, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ) - : managedThemeBackground(backgroundValue, appearance); - const accent = options?.exactSeeds - ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }) - : managedThemeAccent(accentValue, appearance, canvas); - const text = readableThemeForeground(canvas); - const textMuted = standardMutedThemeText(canvas, text); - // The top bar is part of the main panel, not a separate chrome layer: it - // shares the canvas, and its controls sit on the panel's own surfaces. - const chrome = canvas; - const sidebar = mixThemeRgbColors(canvas, accent, 0.08); - const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035); - const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06); - const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08); - const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06); - const mutedForeground = readableThemeText(muted, text, 1, 4.6); - const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6); - const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14); - const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18); - const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08); - const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14); - const accentForeground = readableThemeForeground(accent); - // Code and terminal are large surfaces: they keep the canvas hue instead of - // drifting toward the foreground grey. Code sits just above the canvas — - // a whisper of the text tint — and the terminal sits on the canvas itself. - const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025); - const terminalBackground = canvas; - const messageActionHover = mixThemeRgbColors( - accent, - accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND, - 0.12, - ); - - // The update family follows the accent instead of inheriting the default - // palette's brand color, so generated themes carry their own identity in - // update pills and banners. Error and warning stay semantic defaults. - const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16); - const updateForeground = mixThemeRgbColors( - accent, - appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND, - 0.35, - ); - - return { - ...defaults, - ...standardStatusColors(canvas), - update: themeRgbToThemeColor(accent), - updateForeground: themeRgbToThemeColor(updateForeground), - updateSurface: themeRgbToThemeColor(updateSurface), - canvas: themeRgbToThemeColor(canvas), - chrome: themeRgbToThemeColor(chrome), - toolbar: themeRgbToThemeColor(chrome), - toolbarForeground: themeRgbToThemeColor(text), - toolbarBorder: themeRgbToThemeColor(toolbarBorder), - toolbarControl: themeRgbToThemeColor(toolbarControl), - toolbarControlForeground: themeRgbToThemeColor(text), - toolbarControlHover: themeRgbToThemeColor(accentSurface), - surface: themeRgbToThemeColor(canvas), - surfaceRaised: themeRgbToThemeColor(surfaceRaised), - surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), - text: themeRgbToThemeColor(text), - textMuted: themeRgbToThemeColor(textMuted), - // Borders blend through the accent before lightening so control chrome - // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) - // instead of flattening to grey. - border: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), - text, - 0.1, - ), - ), - input: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), - text, - appearance === "dark" ? 0.14 : 0.13, - ), - ), - focus: themeRgbToThemeColor(accent), - accent: themeRgbToThemeColor(accent), - accentForeground: themeRgbToThemeColor(accentForeground), - secondary: themeRgbToThemeColor(secondary), - secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), - muted: themeRgbToThemeColor(muted), - mutedForeground: themeRgbToThemeColor(mutedForeground), - placeholder: themeRgbToThemeColor(placeholder), - secondaryLabel: themeRgbToThemeColor(textMuted), - iconMuted: themeRgbToThemeColor(textMuted), - accentSurface: themeRgbToThemeColor(accentSurface), - accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToThemeColor(messageSurface), - messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToThemeColor(accent), - messageActionForeground: themeRgbToThemeColor(accentForeground), - messageActionHover: themeRgbToThemeColor(messageActionHover), - codeBackground: themeRgbToThemeColor(codeBackground), - codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToThemeColor(sidebar), - sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), - ), - sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), - ), - terminalBackground: themeRgbToThemeColor(terminalBackground), - terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToThemeColor(accent), - terminalSelection: themeRgbToThemeColor( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), - ), - terminalScrollbar: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), - ), - terminalScrollbarHover: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), - ), - }; -} - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; diff --git a/apps/web/src/threadSelectionStore.test.ts b/apps/web/src/threadSelectionStore.test.ts index 3bd97b97d40a..e73f4d96864c 100644 --- a/apps/web/src/threadSelectionStore.test.ts +++ b/apps/web/src/threadSelectionStore.test.ts @@ -1,7 +1,10 @@ import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { useThreadSelectionStore } from "./threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "./threadSelectionStore"; const THREAD_A = ThreadId.make("thread-a"); const THREAD_B = ThreadId.make("thread-b"); @@ -16,6 +19,59 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().clearSelection(); }); + describe("bulk deletion cleanup", () => { + it("clears missing selection rows and completed deletions while retaining a failed thread", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_C); + const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; + // A deleted successfully but its shell has not refreshed; B failed; + // C was deleted elsewhere and never entered this client's delete loop. + const existingThreads = new Set([THREAD_A, THREAD_B]); + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), (key) => + existingThreads.has(ThreadId.make(key)), + ), + ); + + expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([THREAD_B]); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); + }); + + it("exits selection mode when the last selected thread disappeared elsewhere", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete([THREAD_A], new Set(), () => false), + ); + + expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); + }); + + it("keeps unprocessed and hidden live threads and selections added while deletion was pending", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_C); + const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; + store.toggleThread(THREAD_D); + // Only A completed before interruption. B is unprocessed; C still + // has a shell even though its row is outside the rendered page. + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), () => true), + ); + + expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([ + THREAD_B, + THREAD_C, + THREAD_D, + ]); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_D); + }); + }); + describe("toggleThread", () => { it("adds a thread to empty selection", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); diff --git a/apps/web/src/threadSelectionStore.ts b/apps/web/src/threadSelectionStore.ts index 2b4022a68fb6..fd7f9366eba8 100644 --- a/apps/web/src/threadSelectionStore.ts +++ b/apps/web/src/threadSelectionStore.ts @@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState { const EMPTY_SET = new Set(); +/** Clear completed deletions and missing threads, retaining failed or unprocessed threads. */ +export function getThreadKeysToDeselectAfterDelete( + selectedThreadKeys: readonly string[], + deletedThreadKeys: ReadonlySet, + hasThread: (threadKey: string) => boolean, +): string[] { + return selectedThreadKeys.filter((key) => deletedThreadKeys.has(key) || !hasThread(key)); +} + export const useThreadSelectionStore = create((set, get) => ({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null, diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 578587510db1..8c6287010d8f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -8,37 +8,9 @@ import { formatRelativeTimeLabel, formatShortTimestamp, getRelativeTimeState, - getTimestampFormatOptions, resolveTimestampLocale, } from "./timestampFormat"; -describe("getTimestampFormatOptions", () => { - it("omits hour12 when locale formatting is requested", () => { - expect(getTimestampFormatOptions("locale", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }); - }); - - it("builds a 12-hour formatter with seconds when requested", () => { - expect(getTimestampFormatOptions("12-hour", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - }); - - it("builds a 24-hour formatter without seconds when requested", () => { - expect(getTimestampFormatOptions("24-hour", false)).toEqual({ - hour: "numeric", - minute: "2-digit", - hour12: false, - }); - }); -}); - describe("resolveTimestampLocale", () => { it("defers to the runtime default when the host reports no locale", () => { expect(resolveTimestampLocale(null)).toBeUndefined(); @@ -57,19 +29,45 @@ describe("resolveTimestampLocale", () => { expect(resolveTimestampLocale("not a locale")).toBeUndefined(); expect(resolveTimestampLocale("en_GB")).toBeUndefined(); }); +}); + +describe("formatShortTimestamp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it.each([ + ["en-GB", "15:44"], + ["en-US", "3:44 PM"], + ])("honors %s and the explicit hour-cycle settings", async (locale, localTime) => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => locale } }); + vi.resetModules(); + const { formatShortTimestamp: format } = await import("./timestampFormat"); + const date = new Date(2026, 3, 7, 15, 44).toISOString(); + // ICU can separate the day period with a narrow no-break space. + expect(format(date, "locale").replace(/[  ]/g, " ")).toBe(localTime); + expect(format(date, "12-hour").replace(/[  ]/g, " ")).toMatch(/^3:44 [ap]m$/i); + expect(format(date, "24-hour")).toBe("15:44"); + }); +}); - it("renders the host locale's hour cycle under the locale setting", () => { - const formatAt1544 = (systemLocale: string | null) => - new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { - ...getTimestampFormatOptions("locale", false), - timeZone: "UTC", - }) - .format(new Date("2026-04-07T15:44:00.000Z")) - // ICU separates the day period with a narrow no-break space. - .replace(/[  ]/g, " "); +describe("formatChatTimestampTooltip", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it.each(["de-DE", "it-IT"])("keeps the English date label in a %s runtime", async (locale) => { + const DateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { + return new DateTimeFormat(locales ?? locale, options); + }); + vi.resetModules(); + const { formatChatTimestampTooltip: format } = await import("./timestampFormat"); + const date = new Date(2026, 5, 4, 14, 4).toISOString(); - expect(formatAt1544("en-GB")).toBe("15:44"); - expect(formatAt1544("en-US")).toBe("3:44 PM"); + expect(format(date, "24-hour")).toBe("14:04, 4th June 2026"); }); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c6a9bdd29e10..9dd463bb50fa 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,6 +1,6 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; -export function getTimestampFormatOptions( +function getTimestampFormatOptions( timestampFormat: TimestampFormat, includeSeconds: boolean, ): Intl.DateTimeFormatOptions { @@ -81,7 +81,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". Localizing the whole label is a separate change. -const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index d595b12b618e..83913ecee9f2 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -3,6 +3,7 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import * as Schema from "effect/Schema"; +import { manualInstallCommand } from "@q1code/core/brand"; // fork: base import { APP_VERSION } from "./branding"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; @@ -12,7 +13,7 @@ export interface VersionMismatch { readonly hint: string; } -export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; +const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; // Runtime failures retain their identity until the next attempt. Dismiss only // that attempt, across chat remounts, without clearing the error in Settings. @@ -116,7 +117,7 @@ export function supportsServerUpdateThreadContinuation( /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand(targetVersion: string): string { - return `npx t3@${targetVersion}`; + return manualInstallCommand(targetVersion); // fork: base } export function serverUpdateGuidance(capability: ServerSelfUpdateCapability): string { diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 6d7582aad8d5..ec40c49810dc 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -25,7 +25,9 @@ See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts). Antigravity separates account profiles per instance while sharing installed executables across the environment. It forces file-based credential storage because the native macOS keychain entry would otherwise be shared across instances. The launch environment removes ambient Google credentials, -so an instance cannot silently use another account or billing project. +so an instance cannot silently use another account or billing project. The agent also resolves +its user-global skill directories under that profile, so the profile links those two directories +back to the user's real `~/.gemini`; MCP servers, hooks, and rules there stay out of the profile. See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts). The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives diff --git a/docs/operations/development.md b/docs/operations/development.md index dec3e8681fd3..e07758caa657 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -69,6 +69,23 @@ Use `vp run lint:mobile` for native mobile changes. CI owns the full suite; see The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused Windows investigation while that suite is not a required gate. +### Unused code + +`vp run knip:check` checks unused files and dependencies across the repo, then +unused runtime exports in `apps/web` and every internal package under `packages/`. +CI enforces both checks. +Exported types and Effect schemas are allowed without consumers. The schema preprocessor +recognizes schema types, including aliases and schema classes; functions that create or decode +schemas remain checked. Completely unused files remain checked too. +Named exports in web UI component modules are kept as complete component sets. Knip ignores +unused exports in `apps/web/src/components/ui/*.tsx`, while still reporting an entire unused file. +Use `vp run knip --workspace apps/web` to audit one workspace, including exports, +or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. +The full export audit still has findings and is not a repo-wide CI gate. Extend the +export check's workspace selectors as more workspaces become clean. Review callers before +deleting code; production mode can also report development scripts and test fixtures. +Runtime-discovered entrypoints and dependency exceptions belong in [knip.jsonc](../../knip.jsonc). + ## Desktop artifacts Local artifact builds are unsigned by default and write to `release/`: diff --git a/docs/operations/release.md b/docs/operations/release.md index 02f453ab36a5..4217c76f1f1e 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - push tag matching `v*.*.*` for stable releases - - scheduled nightly check every three hours + - scheduled nightly check every 30 minutes - manual `workflow_dispatch` for either channel - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. @@ -158,8 +158,10 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled check every three hours + - scheduled check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` +- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies. +- Manual nightlies bypass the time and change checks. Nightly runs remain serialized. Scheduled runs wait for an active nightly to finish, then check the publication gap before building. - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: - current tag format: `vX.Y.Z-nightly.YYYYMMDD.` diff --git a/docs/user/composer.md b/docs/user/composer.md index 2220fa53c0ec..4a8df5333664 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -56,6 +56,19 @@ is unavailable or has changed, the saved quote remains readable. Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. +## Recall a sent prompt + +Press `ArrowUp` in an empty composer to bring back the last prompt you sent in this thread. Press +`ArrowUp` again to go further back, and `ArrowDown` to come forward. Moving forward past the newest +prompt clears the composer. Recall walks the prompts loaded in the thread. Attachments, terminal +context, and other extras from the original message are not restored, only the text you typed. A +composer that holds an attachment or a picked element does not count as empty. + +When the composer has text, the arrow keys move the caret as usual. Recall takes over only while +the text is an unedited recalled prompt, with the caret on the first visual line for `ArrowUp` or +the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it +into a normal draft. + ## Prompt stash On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 747bc52c07ec..c76c18544df2 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,11 +1,28 @@ # Project settings -Open **Settings → Projects** and select a project to change its preferences. +Open **Settings → Projects**. The project and machine pickers start at **All projects** and +**All machines**. + +Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. +Select an individual project to override a default. Reset its row to inherit again. Changing a +default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence +over machine defaults when the project has no explicit workspace override. + +Select a machine to limit edits to it. **All machines** writes defaults to connected machines; +offline machines keep their previous values. Mixed values are indicated when selected machines +or checkouts disagree. Browser access changes apply when an agent session next starts. + +Project grouping has a client-wide default across machines, with individual checkout overrides. +Shared actions apply to inheriting projects; editing a project's actions creates an independent list. +Reset that list to use shared actions again. Existing project actions are preserved. + +Project names, icons, removal, and importing actions from a checkout remain project-specific. +When there are several checkouts, the checkout picker selects which actions and grouping to edit. ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies -to every checkout in the project group and appears on connected clients. Choose **Automatic** to +to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. ## Keep the default branch current diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 7466cba309ac..fe5010abf1f4 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -88,6 +88,10 @@ legacy `.agent/skills` directory. Among these project locations, the first copy wins in this order: `.gemini/skills`, `.agents/skills`, `.agent/skills`. See [commands and skills](./composer.md#commands-and-skills) for invoking them. +Skills for every project go in `~/.gemini/config/skills` or +`~/.gemini/antigravity-cli/skills`. Antigravity does not read `~/.agents/skills`, +so a skill there only appears when the project itself is your home directory. + Antigravity accepts images, PDFs, text files, and supported audio formats directly. Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and 50 MiB total attachments per message. Unsupported formats are rejected. These @@ -123,6 +127,9 @@ is refused while the runtime is in use. ## Check access and troubleshoot +A server restart keeps your Google sign-in. The provider shows the saved account +until a session, a refresh, or a sign-out reports something new. + To check access and reload models, use **Refresh provider status** in web or desktop provider settings, or **Refresh models** in mobile thread settings. If asked to sign in again, use setup on web or desktop. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04e..b10123ab6223 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Balance new threads across machines + +Auto balance is off by default. On web and desktop, enable it in +**Settings → Connections → Load balancing** to automatically choose a machine for +new threads in projects grouped across connected environments. +Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and +memory available, **Less often** to reduce its share, or **Manual only** to exclude +it from automatic selection. These are preferences, not fixed traffic percentages. +Preferences are saved separately in each client. + +The composer checks eligible machines when choosing a draft's environment, then keeps +that choice stable. Choose **Auto balance** again to check current resources, or choose +a specific machine to override it. Choosing a branch or worktree also keeps the draft +on that machine. Existing threads stay where they started. If resource checks are +unavailable or all eligible machines are full, choose a machine manually to continue. +Mobile keeps its manual environment selection. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale diff --git a/docs/user/swiftui-mobile.md b/docs/user/swiftui-mobile.md new file mode 100644 index 000000000000..3811c9a467bd --- /dev/null +++ b/docs/user/swiftui-mobile.md @@ -0,0 +1,36 @@ +# SwiftUI mobile + +The native SwiftUI app connects to one or more T3 Code computers. Each server owns the settled +state of its threads and its automatic settlement settings. Change those settings per connection +by opening the environment's connection details. + +Use **Refresh models** in the model picker for a new or existing task to reload models for the +selected computer. Other connected computers are not refreshed. + +## Attachments and sharing + +One message can contain up to eight photos, videos, or files. Images can be up to 10 MB. Other +files can be up to 50 MB, or the lower limit reported by the connected server. Older servers accept +images only. + +Attachments start uploading while you compose. If an upload fails, the draft keeps its local copy +so you can retry or remove it. Tap an image, PDF, video, or other file to preview it with native +controls when iOS supports that format. + +You can share text, links, photos, videos, and files from another app into T3 Code. Choose a project +to add the shared content to a new-task draft. The share extension never sends the draft. + +## Voice input + +On supported devices with iOS 26 or later, the composer can transcribe up to five minutes of audio +on the device. Voice input needs microphone permission. The first use can also require Apple's +speech model download. + +Tap the checkmark to confirm the recording. T3 Code inserts editable text into the draft and never +sends it automatically. + +## Codex content + +Codex file citations open the cited file when available. Artifact templates include a +**Use** action. **Use** inserts an editable prompt into the composer. Review or change it before +you send it. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e6c89c0343e7..4286ed654d81 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,12 +22,42 @@ worktree**, each background submission creates its own worktree. ## Pin and reorder threads -Pin a thread from its menu to keep it above your active work. Drag pinned threads -to reorder them on web and desktop, or use **Move up** and **Move down** on mobile. -The order syncs across devices. +Pin a thread from its menu to keep it above your active work. Pinning does not prevent automatic settlement. Settling a thread removes its pin. +On web and desktop, drag a thread between sections to change its state. Drag a thread up into +the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active +list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled +thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed +shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake +time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. +Pinned and active boundary labels appear only while dragging, without moving the rows. The +destination boundary highlights. When you cross into another section, the dragged thread shows +its destination, such as **→ Active**. Its usual pin, status, and hover actions hide during the +drag. Reordering within the same section does not show a destination badge. When there are no +pins, drag to the top edge to pin a thread. Section labels also identify empty sections and a +collapsed settled shelf. + +Drag within the pinned or active section to change its order. Other rows slide aside to show the +spot where the thread will land. Drops into either section keep the position you choose. On +mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server +saves the order, so it survives a refresh and appears on your other connected devices. + +On web and desktop, the list also animates section changes made with thread actions such as +**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion +preference. While dragging, rows follow the insertion gap without replaying a second transition +after the drop. + +New threads appear above the active threads you have arranged. Settling clears a thread's active +position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active +position until you move it again. Thread activity does not change the order. The settled shelf +continues to use settlement time. + +If dragging is unavailable for one environment, update the T3 Code server running in that +environment. Pinned and active reordering require server support. Threads from older servers keep +their default order until the server is updated. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list @@ -49,8 +79,13 @@ in the warning. Changing a rule does not reopen already settled threads. ## Link a pull request +The server finds the PR for each unsettled thread's saved branch, even when your +apps are closed. Settled threads keep their saved links. Update the server if +automatic branch links do not appear. + On web and desktop, right-click a pull request link in a thread and choose -**Link to thread**. Use **Unlink from thread** on the same link to remove it. +**Link to thread** to select a different PR. Use **Unlink from thread** on the +same link to return to the branch PR, if one exists. The linked pull request participates in automatic settlement. ## Find and reference work diff --git a/docs/user/updating.md b/docs/user/updating.md index 14500cbe4620..d72df7382f56 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -11,12 +11,15 @@ Server updates restart the connection and can interrupt active agents and terminal commands. Saved threads, settings, and project files remain. **Settings → General → Continue threads after restarts** is off by default. -Enable it for each environment to resume supported active threads after an -update, crash, or machine restart. T3 Code must start again on that machine; +Enable it to resume supported active threads after an update, crash, or machine +restart. Changes are saved to connected environments that support this setting; +update older servers first. If a supported environment was offline or has a +different value, use **Apply to all** in Settings after it connects. +T3 Code must start again on that machine; the setting does not enable automatic startup. Terminal commands may still be interrupted, and threads without saved provider resume state need a new message. -If you previously enabled continuation for updates, enable this environment -setting once to allow recovery without a connected client. +If you previously enabled continuation for updates, enable this setting once +to allow recovery without a connected client. ## Update a connected server diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..4c4af3299acb 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,12 +39,26 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also -compares quota consumed with time elapsed in each window, so you can judge your pace before the -next reset. +**Usage → Limits** pools every subscription account it can see per provider, so with several Codex +or Claude accounts across your environments and hubs you read one number per window rather than a +list. Each window card shows how much of the pool is left and a bar with one segment per account, +ordered by which resets soonest; when the provider reports reset times, the card also says when +the next reset lands and how much it hands back. The hatched +part of a segment is what that reset restores. Tap a segment or account row for the account's plan, +where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked +reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below +the bar show each account's quota, countdown, and credits. Tap a row to open its details. + +The same account signed in on more than one environment, or reported by a hub as well, counts once. +Filter with the environment dropdown to see what a single machine has. If a window looks stale, refresh Limits to re-check every provider and hub. +Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the +current model's limits without leaving the conversation. The result opens above the composer and +closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh +anything. The command is offered only for providers that appear under **Usage → Limits**. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md new file mode 100644 index 000000000000..9a2aa116670d --- /dev/null +++ b/docs/user/welcome-wizard.md @@ -0,0 +1,62 @@ +# Welcome wizard + +T3 Code shows a setup flow when you open a new installation or connect to the +hosted app for the first time. Existing workspaces skip this flow. + +## Choose a connection + +- **This computer** runs agents on the computer that hosts T3 Code. It does not + require an account. +- **T3 Connect** connects computers that are signed in to your account. Run + `npx t3 connect` on each computer you want to add, then start T3 Code or run + `npx t3 serve` so the computer stays available. +- **Pair a server** connects directly to a server on your network or tailnet. + Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and + paste the pairing link. You can also run `npx t3 serve --host
    ` and + use `npx t3 pair` when the server is already reachable on your network. + +If T3 Code cannot confirm the workspace during startup, the setup flow shows +**Still connecting** instead of opening the app. Select **Reload** to try again. + +If T3 Code cannot read your saved settings, it shows **Could not read settings**. +Select **Retry** after storage becomes available. Setup does not replace +unreadable settings with defaults. + +## Check your agents + +T3 Code checks the selected computer for Claude Code and Codex. If an agent is +not installed or signed in, select its action to open a terminal with the +correct command ready to run. Other providers can be enabled in Settings. + +The setup terminal uses the home directory and environment configured for the +selected provider instance. Sensitive values remain redacted in Settings and +terminal metadata while the terminal process can use them. + +## Import your projects + +T3 Code finds directories that Claude Code or Codex has used. The default +selection includes projects active within the last 30 days. Select **Choose** +to include older projects or change the selection. + +A large or malformed history can reach the scan limit. T3 Code keeps the +projects it found and warns when projects or conversations may be missing. + +Imported projects include Codex and Claude conversations active within the last +30 days. You can continue those conversations in T3 Code. + +Conversation import is best effort. T3 Code keeps the first user prompt and the +newest remaining visible user and assistant messages, with 200 messages total. +It omits tool activity and attachments. For Codex, it omits generated setup +context only when a canonical user event and a valid shared turn ID identify the +same user turn. Ambiguous legacy or response-only context stays in the imported +conversation so T3 Code does not remove user text. It reads one conversation at +a time and skips files larger than 16 MiB. It ignores malformed records and skips +unreadable or unparseable conversations. + +Each import attempt reads up to 100 conversation files and 64 MiB per project, +with up to 100,000 input records. Run import again to continue a large batch. +Completed conversations are not imported again. You can continue without the +remaining history. + +You can skip agent setup and project import. Select **Back** to return to a +previous step. diff --git a/fork/FEATURES.md b/fork/FEATURES.md new file mode 100644 index 000000000000..91550cdf63b4 --- /dev/null +++ b/fork/FEATURES.md @@ -0,0 +1,141 @@ +# Feature registry + +Every `Fork-Feature: ` trailer in the series points at an entry here. `fork-feature` scaffolds entries; `fork-audit` and `fork-sync` update status. + +Fields per entry: + +- **status**: `active` (in the series and on), `planned` (entry exists, no code yet), `paused` (code in the series, flag stays off, not maintained through conflicts), `upstreamed` (upstream ships it; fork commits dropped), `dropped` (removed; kept here so the slug stays reserved). +- **purpose**: one line. +- **flags**: keys in `packages/fork-core/src/flags.ts`. `base` has none of its own. +- **owned dirs**: fork-owned locations the feature may create files in. +- **seams**: upstream files touched, each with a `// fork: ` marker. Must agree with `fork/SEAMS.md`. +- **tests**: what proves it works and what proves flags-off parity. +- **upstream**: candidate commits, PR links, merged SHAs. +- **removal condition**: when this entry becomes `upstreamed` or `dropped`. + +--- + +## base + +- **status**: active +- **purpose**: everything q1code needs to exist as a fork: flags, brand, home dir, GitHub-only updates, CI, skills. +- **flags**: none. Owns the registry. +- **owned dirs**: `packages/fork-core/`, `apps/server/src/fork/ForkFlags.ts`, `apps/server/src/fork/cli/forkCommand.ts` (the `q1code fork` group), `apps/web/src/fork/forkSettingsSearch.ts`, `packages/client-runtime` fork flag hook, `apps/web/src/fork/settings/`, `apps/mobile/src/fork/settings/`, `.github/workflows/fork-ci.yml`, `fork-sync-watch.yml`, `fork-release.yml`, `.agents/skills/fork-*/`, `scripts/fork/`, `fork/`. +- **seams**: + - `CLAUDE.md` (`@fork/FORK.md` line) + - `packages/contracts/src/environment.ts` (`forkFlags` optional key on `ExecutionEnvironmentCapabilities`) + - `apps/server/src/environment/ServerEnvironment.ts` (publish flags into capabilities) + - `apps/server/src/os-jank.ts` (`resolveBaseDir` default `~/.q1code`) + - `apps/server/src/cloud/pinnedRuntime.ts` (GitHub release tarball install spec, entry path `node_modules/q1code/dist/bin.mjs`, checksum verify) + - `apps/server/src/cli/invocation.ts` (CLI name) + - `apps/server/src/bin.ts` (one import, one `forkCommand` entry in the root subcommands) + - `apps/web/src/versionSkew.ts` (manual update command runs the release `install.sh`) + - `apps/server/package.json` (name `q1code`, bin `q1code`) + - `scripts/build-desktop-artifact.ts` (appId `sc.mic.q1code`, product name) + - `apps/web` title and About label + - `packages/shared/src/connectAuth.ts` (hosted app URL default from env) + - `apps/web/src/components/settings/SettingsPanels.tsx` (one ``) + - `apps/web/src/components/settings/settingsSearch.ts` (one entry) + - `apps/web/src/components/settings/useAvailableSettingsSearchItems.ts` (one import, one `.filter(isForkSettingsSearchItemVisible(...))` on the available items; the id-to-flag map lives in `apps/web/src/fork/forkSettingsSearch.ts`) + - `apps/mobile/app.config.ts` (bundle id, team, applinks host; EAS owner and projectId removed) when iOS ships +- **tests**: `packages/fork-core` unit tests for flag resolution order; `apps/server/src/fork/ForkFlags.test.ts`; upstream suites run on `fork` in `fork-ci.yml` for parity; `scripts/fork/seams.ts` budget; `scripts/fork/leak-check.ts` on `up/*`. +- **upstream**: no. Generic pieces that fall out (for example a per-instance extra env var setting) get their own candidate commits under the feature that needs them. +- **removal condition**: never while the fork exists. + +## update-check + +- **status**: planned +- **purpose**: server polls `https://api.github.com/repos/q1/q1code/releases/latest` daily and surfaces the newest version through capabilities so the existing client update UI works with no hosted web app. +- **flags**: `update-check` (registry default off until implemented; when it lands, decide whether it becomes the one default-on flag, since without it web-mode users hear nothing about updates). +- **owned dirs**: `apps/server/src/fork/updateCheck/`. +- **seams**: none beyond `base` (rides the `forkFlags`/capabilities key; may need one optional capabilities field for the discovered version). +- **tests**: poller unit test with a fake fetch; parity test that the capabilities field is absent when the flag is off. +- **upstream**: no. Upstream's discovery is client-driven by design. +- **removal condition**: upstream adds server-side release discovery for GitHub-provider updates. + +## prism + +- **formerly**: `cliproxy`; renamed in the series on 2026-09-04, older commits keep the old trailer. The old `fork.json` key and `T3FORK_CLIPROXY` / `Q1CODE_CLIPROXY_SYNC_*` env vars are ignored; `ForkFlags.ts` logs one warning per leftover at server start. +- **status**: active (phase 1: sidecar lifecycle and provider wiring; phase 2: accounts HTTP API, client runtime, cross-machine sync; phase 3: the Accounts UI on web and mobile; phase 4: the usage-source bridge into Usage → Limits) +- **engine ownership**: `q1/prism` is the maintained CLIProxyAPI fork. Preserve its upstream history, module/API compatibility, and Grok support. Keep the released upstream pin until a tested Prism engine artifact replaces it; creating the fork alone does not change deployed binaries. +- **account redesign constraints**: prioritize Claude subscription OAuth and ChatGPT/Codex subscription OAuth, retaining Grok. One authoritative primary owns the pool; placement belongs to mic.sc. Other serving gateways receive encrypted credentials with coordinated refresh ownership. Expired or revoked credentials must become visible and leave routing when sign-in is required. The first client milestone is web, desktop, and the carried SwiftUI client; retain RN compatibility. Prism is the default with a direct-provider option per thread; automatically retry a failed Prism turn once through local direct credentials and expose that transition in the thread. Cloudflare integration follows account and synchronization correctness. +- **refresh ownership**: version 3 sync distributes an authoritative serving snapshot with refresh tokens removed recursively and refresh disabled. Only the primary enrolls, rotates, disables, and deletes pooled credentials; replicas never push credentials back. A replica rejects older bundles before modifying files, reconciles deletions from the complete snapshot, and keeps its last snapshot on transport/decryption failure. No automatic ownership promotion. This uses the existing sync seam and flag; focused tests cover token stripping, primary authority despite clock skew, deletion, corrupt bundles, and flags-off parity. The generic engine refresh-disabled guard is an upstream candidate; snapshot distribution and client ownership guidance are fork-only. +- **provider/navigation seams**: replace the broad `ClaudeHome.ts` environment override with one import and two driver decorators in `provider/builtInDrivers.ts`. The decorators retain native provider behavior without creating a proxy instance while Prism is off, advertise a standard per-thread `prism-route` model option, and keep direct credentials outside the managed Codex home. Add one guarded fork-owned navigation component through an import and JSX call in `components/sidebar/SidebarChrome.tsx`; the page is fork-owned. Removing the ClaudeHome seam and adding these two leaves 40 files. Standard model options carry the route across web, desktop, RN, and SwiftUI without changing contracts. Existing threads with no option select Prism while enabled. Failed Prism turns get at most one direct retry; cancellation never retries. Adapter tests must exercise event ordering, terminal failures, cleanup, route changes, and flags-off parity. These changes are fork-only. +- **lifecycle increment**: reuse the existing `prism` flag and owned directories; no new flag or upstream seam. Add optional lifecycle fields to the fork account contract and preserve compatibility with stock/older gateway responses. The engine publishes expiry/refresh timestamps through its authenticated management API without returning credential material. The server maps these into client data; web/desktop and RN consume additive account metadata, and SwiftUI uses the same authenticated API in its top-level Prism area. Unknown expiry remains unknown, never an invented deadline. Account disable/enable and remove/re-login remain reversible. Local and remote clients use the environment's existing authenticated connection. +- **planned commit split**: engine `feat(management): expose credential expiry and refresh timing` is an upstream candidate with focused management tests. The q1code contract, account-health presentation, per-thread provider routing/fallback, synchronization ownership, and client navigation are fork-only. Account lifecycle uses zero new seams; provider and navigation work require their own exact seam review before implementation, keeping the 40-file budget. +- **purpose**: Prism, the fork's account proxy: CLIProxyAPI (router-for-me, MIT, Go) baked into q1code as a managed sidecar so provider CLIs share one pool of accounts with load balancing and cross-machine sync. +- **summary (phase 1)**: `PrismBinary` resolves the executable (`fork.json.prism.binaryPath`, then the copy bundled at `dist/prism//cli-proxy-api`, then a cached or fresh download of the pinned release under `~/.q1code/prism/bin//`, sha256-verified against the release `checksums.txt`; pin in `packages/fork-core/src/prism.pin.json`). `PrismService` renders `~/.q1code/prism/config.yaml` (loopback host, port from `fork.json`, `auth-dir ~/.q1code/prism/auths`, one API key and one management secret from the server secret store, `allow-remote: false`, control panel, plugins, usage statistics and request logs off), spawns `cli-proxy-api -config `, waits for the port and `GET /v0/management/routing/strategy`, supervises with capped exponential backoff, and stops when the flag turns off or the server shuts down. Sidecar output goes to the server log at debug level with both secrets redacted. Claude and Codex driver decorators select the pool by default, expose a per-thread direct option, and retry failed pooled turns once using local credentials. Codex uses a separate managed home per provider instance with shared non-auth history; local authentication stays in the direct home. Other q1code provider adapters retain their native paths; the gateway retains Grok support. Config changes to the `prism` section apply on the next proxy start (flag off/on, or `POST restart`). **External mode** (`prism.mode: "external"`, for a CLIProxyAPI something else already runs, such as a container, so two proxies never refresh the same OAuth tokens): nothing is resolved, rendered, or spawned. The service reads the proxy's management secret and a client API key from the server secret store (`prism.external.managementSecretName` / `apiKeySecretName`, defaulting to `prism-management-secret` / `prism-api-key`; a missing one is `failed` with `lastError` naming the `q1code fork secret set ` command), probes `GET /v0/management/routing/strategy` with the bearer secret, then publishes `{ baseUrl, apiKey }` through the same handoff the provider decorators use. While the flag is on it re-probes every 30 s: a failed probe is `failed` with the transport text as `lastError` (never a secret) and clears the endpoint; the next good probe is `ready` again and counts one reconnect in `restarts`. A `mode: "external"` without an `external` section, or a `baseUrl` that is not a bare http(s) origin, is `failed` with a clear `lastError`. `port` is the origin's port (80/443 by default). Sync (`PrismSync.ts`) and the account mtime lookup use `prism.external.authDir` when set (the proxy's own `auth-dir` on this host, same uid), otherwise the managed `auths/`; tombstones stay under `/prism/`. The managed `auths/` is not created in external mode. +- **summary (phase 2, server)**: one contract in `packages/fork-core/src/prismApi.ts` (`@q1code/core/prismApi`: schemas, paths, error classes, and the `HttpApi` group with the environment auth middleware attached). `apps/server/src/fork/prism/PrismHttpApi.ts` implements it with `HttpApiBuilder` and proxies every call through `PrismService.management.request`, so the management secret never leaves the server. The routes mount next to upstream's API in `server.ts`. Endpoints, all under `/api/fork/prism` and all behind environment auth (bearer, DPoP, or session cookie): + + | method | path | scope | does | + | --------- | ------------------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | GET | `status` | `orchestration:read` | proxy `state`/`mode`/`port`/`version`/`baseUrl` (while ready)/`lastError`/`restarts`/`since` plus sync `role`/`lastSyncAt`/`lastSyncError`; works with the flag off | + | POST | `restart` | `access:write` | sidecar: kill and respawn now (counts in `restarts`); external: re-read the secrets and probe now. Waits until `ready` or `failed` (bounded by the ready timeout) and answers the status. 503 `flag-off` when the flag is off | + | GET | `accounts` | `orchestration:read` | `GET /auth-files` mapped to `PrismAccount` (`updatedAt` from the sidecar or the file mtime; `usage.success`/`failed` and the passive `quota` observation when the sidecar reports them) | + | POST | `accounts/login` | `access:write` | `GET /-auth-url?is_webui=true`; returns `sessionId`, `authUrl`, `flow`, `userCode` | + | GET | `accounts/login/:sessionId` | `orchestration:read` | `GET /get-auth-status`; `completed` carries the new `accountId` | + | POST | `accounts/login/:sessionId/callback` | `access:write` | `POST /oauth-callback` with a pasted redirect URL (browser on another machine) | + | DELETE | `accounts/login/:sessionId` | `access:write` | `DELETE /oauth-session` | + | PATCH | `accounts/:id` | `access:write` | `PATCH /auth-files/status` (`disabled`) and `/auth-files/fields` (`weight`) | + | DELETE | `accounts/:id` | `access:write` | `DELETE /auth-files?name=`; records a sync tombstone for the id | + | GET / PUT | `routing` | read / `access:write` | `GET`/`PUT /routing/strategy`; `PUT` also writes `prism.routingStrategy` into `fork.json` (500 `PrismConfigError` when that write fails) | + | GET | `usage` | `orchestration:read` | `GET /api-key-usage` (API-key credentials only; OAuth accounts report nothing here) | + | GET | `sync/export` | `access:write` | primary: every auth file, encrypted, stamped with its mtime, plus the live tombstones (bundle version 2) | + | POST | `sync/push` | `access:write` | primary: decrypt and write entries newer than the local copy; remove files the pushed tombstones cover | + | GET | `sync/status` | `orchestration:read` | role, primary URL, interval, last sync time and error | + + `PUT routing` persists through `ForkFlagsService.update` (`apps/server/src/fork/ForkFlags.ts`): the raw JSON is edited so unknown keys survive, validated against the schema, written temp + fsync + rename with 2-space indent, and re-read, so the strategy survives sidecar and server restarts. With the flag off or the proxy not ready every endpoint except `status`, `restart`, and `sync/status` answers 503 `PrismUnavailableError { reason: "flag-off" | "sidecar-not-ready" | "sync-not-configured", state }`. Sidecar errors relay as 502 `PrismUpstreamError { status, message }`; unknown ids are 404 `PrismNotFoundError`. The client runtime exposes the same API as plain functions in `packages/client-runtime/src/fork/prismClient.ts` (`@t3tools/client-runtime/fork`: `getPrismStatus`, `listPrismAccounts`, `startPrismLogin`, `getPrismLoginStatus`, `completePrismLogin`, `cancelPrismLogin`, `patchPrismAccount`, `deletePrismAccount`, `getPrismRouting`, `setPrismRouting`, `getPrismUsage`, `getPrismSyncStatus`), each over the same `PreparedConnection` + DPoP signer the other environment HTTP helpers take; the header comment there is the UI contract. + +- **summary (phase 2, sync)**: `PrismSync.ts` exports version 3 authoritative serving snapshots. The primary strips refresh-token aliases recursively before AES-256-GCM encryption; replicas strip them again, validate the complete bundle before changes, reconcile by content rather than trusting local mtimes, and remove files absent from the primary. Replica pushes and pooled account mutations are rejected. Shared-key and admin-token secret-store names remain compatible. Older bundles decode only to produce an explicit upgrade error. See [Prism accounts](docs/prism.md) for the upgrade and ownership workflow. + +- **summary (phase 3, UI)**: web Settings gains a "Prism" tab (`/settings/prism`, after Providers in the sidebar, `WaypointsIcon`) that renders `PrismSettingsPanel` from `apps/web/src/fork/prism/` via the fork-owned route shim `apps/web/src/routes/settings.prism.tsx`. The sidebar entry is filtered through `apps/web/src/fork/forkSettingsNav.ts` (`isForkSettingsPathVisible`, applied by the `useForkVisibleSettingsNavItems` seam in `SettingsSidebarNav.tsx`), so with the flag off the sidebar shows exactly upstream's tabs; the URL stays reachable and renders a calm "Prism is off" row with the `T3FORK_PRISM` / `fork.json` hint (no redirect, no crash), and a "needs a primary environment" row in the hosted app. Sections, top to bottom: Status (`PrismStatusSection.tsx`: state badge with "Ready for 3m · 2 restarts" from `since`/`restarts`, mode `sidecar`/`external` with a one-line explanation, base URL with copy button, an Engine line naming the CLIProxyAPI release and whether it is bundled or external, last error in wrapped monospace, and a Restart button that confirms through `dialogs.confirm`, calls `restartPrism`, shows a pending label, and tightens the status poll to 1 s until the proxy reports `ready` or `failed`, capped at 90 s); Accounts (`PrismAccountsTable.tsx`: skeleton rows on first load, an empty state that points at Add account, an inline error banner with Retry when the list fails, per-row pending state that disables that row's controls, optimistic enable/weight edits with rollback and a toast on error, confirm-and-delete, the "Requests" column and quota tooltip, relative `updatedAt`, a static highlight for a freshly added account); Add account (`PrismAddAccount.tsx`: provider select, auth URL as an external link plus copy button, device `userCode`, paste-the-redirect-URL input, cancel); Routing strategy select; Sync (read-only role, primary URL, interval, relative last sync, last error from `getPrismSyncStatus`); API-key usage list. `usePrismApi.ts` binds the client-runtime functions (now including `restart` and `syncStatus`) to the primary environment's prepared connection and DPoP signer, every call resolved to a plain ok/error result; `prismAccountsState.ts` holds the login-flow reducer (idle → starting → pending → completed/failed/cancelled with the paste-redirect transition), the accounts reducer (`reducePrismAccounts`: optimistic patch with a per-row snapshot for rollback, one mutation per row at a time), and the label helpers (`summarizePrismStatus`, `formatPrismSince`, `describePrismMode`, `formatPrismSyncInterval`; usage credentials render only their base URL, never the key); `prismUi.tsx` has the shared badge, copy button, and visibility hook. Status and sync status poll every 10 s only while the tab is mounted and the document is visible; a pending login polls every 2 s. 503 `PrismUnavailableError` renders an inline explanation per `reason`; 401/403 render as an "Administrative access" row like Connections does. Settings search and the command palette know "Proxy status", "Restart proxy", "Accounts", "Add account", "Routing strategy", and "Sync", all pointing at `/settings/prism` section anchors; the entries live in `apps/web/src/fork/forkSettingsSearch.ts` (`FORK_PRISM_SETTINGS_SEARCH_ITEMS`, ids `prism-*`, search terms keep `cliproxy` and `CLIProxyAPI` as aliases, spread into `SETTINGS_SEARCH_ITEMS` by one seam) together with their flag, which the `useAvailableSettingsSearchItems` seam applies, so they only appear while `prism` is on (the `base` entry has no flag and always shows). The panel binds to the primary environment only. Mobile gets a Settings → Prism sub-screen in `apps/mobile/src/fork/prism/`: `PrismSettingsRow.tsx` renders a "Prism" section with one "Accounts" row (valued with the pooled account count once every environment answered, else the proxy state) only while a connected environment reports the flag on, and returns nothing otherwise so the Settings root matches upstream row for row; `PrismSettingsScreen.tsx` is the `SettingsPrism` route (linking path `prism`), exported as a ready-made `createNativeStackScreen` object so the stack registration is one line; `usePrismApi.ts` binds the client-runtime functions to one environment's prepared connection and DPoP signer with every call resolved to a plain ok/error result; `prismSettings.logic.ts` holds the pure helpers. The screen lists every connected environment with the flag on (a deep link with the flag off everywhere shows a calm "Prism is off" card with the `T3FORK_PRISM=1` / `fork.json` hint). Per environment: a Status section (state pill; mode, base URL, engine, since, restarts, last error, and sync as key/value lines; a Restart button behind an Alert confirm that calls `restartPrism` and then polls `getPrismStatus` every 2 s until `ready`/`failed`, giving up after 30 s), an Accounts section (email or label, provider, weight, relative `updatedAt`, request counters when present; an enabled switch that flips optimistically and rolls back with an inline row error; long-press → Alert confirm → `deletePrismAccount`; static skeleton rows on first load, an explicit empty state, and a load error with Retry above whatever list is still there), an Add account section (one chip per `PrismLoginProvider`, `startPrismLogin` → `Linking.openURL(authUrl)`, the device `userCode` shown large with a copy button, an "Open browser" button, a paste-the-redirect-URL input that calls `completePrismLogin` for browsers that cannot reach the server, `getPrismLoginStatus` polled every 2 s while pending, cancel via `cancelPrismLogin`, completed/failed/cancelled cards with Done), and a Routing section (three chips over `getPrismRouting`/`setPrismRouting`, optimistic with rollback). Pull-to-refresh reloads everything; status polls every 10 s only while the screen is focused (`useIsFocused`) and the app is active (`AppState`), never during a restart. 503 `PrismUnavailableError` renders its reason inline; 401/403 render as "Administrative access required". The Status section also carries a "Show pooled accounts on Usage → Limits" switch over `status.usageSource` (absent means on) that calls `setPrismUsageSource` optimistically, ignores a stale status poll while the save is in flight, and rolls back with an inline error (`reducePrismUsageSource` in `prismSettings.logic.ts`); mobile has no API-key usage section, the Limits view owns quota. Weight editing stays web-only. +- **summary (phase 4, usage-source bridge)**: upstream polls "CLIProxyAPI hub" entries (`settings.usageLimitSources`, kind `cliproxy`) in `apps/server/src/usage/UsageLimitSources.ts` and shows their pooled accounts on Usage → Limits. With the flag on, Prism is one of those sources without the user adding a hub. `PrismEnvironment.ts` extends the process-wide `PrismEndpoint` with the management secret (server only, never on the wire) and the `prism.usageSource` toggle (`fork.json`, default true, read on every proxy start and again by `PUT usage-source`); while the proxy is ready and the toggle is on, `prismUsageLimitSource()` is `["prism", { kind: "cliproxy", label: "Prism", url: , managementKey: , enabled: true }]`, `withPrismUsageLimitSource(entries)` appends it to the configured entries (pure; returns the same array when there is nothing to add), and `prismUsageSourceChanges` (a module-level PubSub) emits whenever the entry appears, moves, or disappears so `UsageLimitSources` refreshes at once instead of waiting for the health interval. Dedupe rule: a user-configured entry that targets the proxy's origin, or reuses the id `prism`, wins and Prism adds nothing, so a hub the user pointed at their own proxy keeps its key and its rows. `PrismStatus.usageSource` reports the toggle; `PUT /api/fork/prism/usage-source` (`access:write`; 503 `flag-off` when off, 500 `PrismConfigError` when the write fails) persists it through `ForkFlagsService.update` and republishes the endpoint via `PrismService.reloadUsageSource`, which is what makes the stream fire. Web: Settings → Providers → Usage providers renders `PrismUsageProviderRow` (`apps/web/src/fork/prism/PrismUsageProviderRow.tsx`) as the first row while that environment's flag is on (the row polls the status every 10 s while visible and says "Proxy starting/failed" until it is ready, so a managed proxy that is down is never mistaken for an unconfigured one): "Prism · Managed by q1code · ", whether the accounts are shown, and a link to the Prism tab; no Remove button, the toggle in the Prism tab is the way out. The fork-owned `UsageProvidersEmptyRow` renders upstream's "No usage providers configured." only while the flag is off, so an empty hub list next to the Prism row is not contradictory. Usage → Limits labels the `prism` source "Prism" (`prismUsageSourceKindLabel`); hubs keep "CLI Proxy". The Prism tab's Status section has the switch "Show pooled accounts on Usage → Limits" (`setPrismUsageSource`, optimistic with rollback and a toast on failure; search entry `prism-usage-source`, "Show on Usage → Limits") and a line linking to `/usage`; the tab's API-key usage section and the per-account quota tooltip are gone because the Limits view owns quota now (the "Requests" counters stay; `getPrismUsage` stays in the client runtime for the CLI). Flag off: `withPrismUsageLimitSource` hands back upstream's array untouched, nothing is polled, no row and no label changes: byte-for-byte upstream. Mobile's Limits view still says "CLI Proxy" for the Prism source (no seam there yet). Tests: `PrismEnvironment.test.ts` (entry derivation, append, dedupe by origin and by id, identity when off or toggled off, PubSub emissions with repeats dropped), `PrismUsageLimitSource.test.ts` (upstream's real `make` with a fake `HttpClient`: the Prism entry is polled with the management secret and labelled Prism, dropped when the toggle goes off, yields to a same-origin hub, flag-off parity), `PrismService.test.ts` (the endpoint carries the secret and the toggle; `reloadUsageSource` republishes and emits), `PrismHttpApi.test.ts` (PUT write-through into `fork.json`, 503 when off, 403 without `access:write`, 500 on a failed write), `apps/web/src/fork/prism/prismAccountsState.test.ts` (row text and the source label), `forkSettingsSearch.test.ts` (the new entry). +- **later phases**: weight editing on mobile, provider-instance registration from the UI, sync over `relay-selfhost`. +- **flags**: `prism` (default off; nothing spawns, nothing is written under `~/.q1code/prism`, no env var is injected, and the sync loop does not start when off). Env override `T3FORK_PRISM=1`. The `fork secret` CLI is not behind the flag: it is plain secret-store maintenance. +- **config**: `fork.json` `prism: { mode?: "sidecar" | "external" (default sidecar), usageSource?: boolean (default true; publish the pooled accounts to Usage → Limits), external?: { baseUrl: string (a bare http(s) origin), managementSecretName?: string (default prism-management-secret), apiKeySecretName?: string (default prism-api-key), authDir?: string }, port?: number (default 8317, sidecar only), routingStrategy?: "round-robin" | "weighted-round-robin" | "fill-first", binaryPath?: string, releaseVersion?: string, sync?: { role: "primary" | "replica", primaryUrl?: string, tokenSecretName?: string, sharedKeySecretName?: string, intervalSeconds?: number (default 300, minimum 5) } }`. Env: `T3FORK_PRISM`, `Q1CODE_PRISM_SYNC_TOKEN`, `Q1CODE_PRISM_SYNC_KEY` (fallbacks behind the secret store). +- **owned dirs**: `apps/server/src/fork/prism/`, the `secret` and `prism` commands in `apps/server/src/fork/cli/`, `packages/fork-core/src/prism*`, `packages/client-runtime/src/fork/prismClient*`, `apps/web/src/fork/prism/`, `apps/mobile/src/fork/prism/`, the sidecar step in `.github/workflows/fork-release.yml`. Runtime state under `~/.q1code/prism/` (`config.yaml`, `auths/`, `bin/`, `codex-home/`, `tombstones.json`) and secrets `prism-api-key`, `prism-management-secret` in the server secret store. +- **seams**: + - `apps/server/src/provider/builtInDrivers.ts` (one import and two driver decorators for Claude and Codex) + - `apps/web/src/components/sidebar/SidebarChrome.tsx` (one import and a flag-guarded Prism navigation item) + - `apps/server/src/environment/ServerEnvironment.ts` (one import, one `Layer.provide(Prism.layer)` next to the `base` flags line) + - `apps/server/src/server.ts` (one import, one `prismRoutesLayer` entry in `makeRoutesLayer`; the layer re-provides `Prism.layer`, which Effect memoizes with the instance above, so no second sidecar spawns) + - `apps/server/src/usage/UsageLimitSources.ts` (one namespace import, `withPrismUsageLimitSource(entries)` around the computed source entries, one `refreshOnPrismUsageSourceChange(refresh)` next to the settings-change subscription; identity when off) + - `apps/web/src/components/settings/UsageProviderSettings.tsx` (one import, `` before the hub list, upstream's empty-state row replaced by ``, which renders the same text while the flag is off) + - `apps/web/src/components/usage/UsageLimits.tsx` (one import, `prismUsageSourceKindLabel(source) ?? SOURCE_KIND_LABEL[source.kind]`) + - `apps/server/src/bin.ts` (one import, one `prismCommand` entry in the root subcommand list next to `forkCommand`, both marked `// fork: prism`; inert with the flag off, the command then just reports `prism is off`) + - `packages/fork-core/package.json` depends on `@t3tools/contracts` (for the auth middleware and scope error the group declares); `packages/client-runtime/package.json` `./fork` now points at `src/fork/index.ts` + - `apps/web/src/components/settings/settingsSearch.ts` (one `SettingsPath` member, one `SETTINGS_SECTION_LABELS` entry, one import plus one spread of `FORK_PRISM_SETTINGS_SEARCH_ITEMS`) + - `apps/web/src/components/settings/SettingsSidebarNav.tsx` (one `WaypointsIcon` import and icon entry; one import, one `useForkVisibleSettingsNavItems` call, and the nav render reading from it) + - `apps/web/src/routeTree.gen.ts` (regenerated by the TanStack router plugin for `settings.prism.tsx`; no marker possible, `.gen.ts` is marker-exempt in `scripts/fork/seams.ts`, and the route shim itself is listed as fork-owned there) + - `apps/mobile/src/features/settings/SettingsRouteScreen.tsx` (one import, one `` after `` in each of the local and configured screens); `apps/mobile/src/Stack.tsx` (one import, one `SettingsPrism: prismSettingsStackScreen` entry in `SettingsContentStack`); `apps/mobile/src/features/settings/components/settings-sheet-targets.ts` (one `"SettingsPrism"` union member so `SettingsRow` accepts the target) +- **tests**: `packages/fork-core/src/prism.test.ts` (asset naming, platform keys, URLs), `prismApi.test.ts` (paths, endpoint set, id pattern, shapes), `config.test.ts` (the `prism`, `external`, and `sync` sections; `mode` unset by default); `apps/server/src/fork/prism/PrismService.test.ts` (fake launcher and readiness: nothing spawns when off, start to ready, restart after exit and after a failed probe with `restarts` and `lastError`, stop on flag off and restart on flag on, endpoint published only while ready, `restart` respawns and counts, external mode ready without a spawn or a managed auths dir, missing secret fails with the hint until `restart` after it is stored, failed probe then reconnect, missing section and bad origin, flag off in external mode clears the endpoint and stops probing, `since` stamped, base URL parsing), `PrismBinary.test.ts` (override, bundled, download with checksum verification and extraction, cache hit, checksum mismatch leaves nothing behind, unsupported platform), `PrismConfig.test.ts` (YAML snapshot, atomic 0600 write), `CodexProxyHome.test.ts` (TOML snapshot, shared-state links), `PrismEnvironment.test.ts` (direct Claude authentication remains isolated), `PrismRoutedAdapter.test.ts` (routing, bounded retry, cancellation, and event identity), `PrismHttpApi.test.ts` (in-memory `HttpApiTest` client over a scripted sidecar: 401 without a credential, 503 with the state when off, status carries `mode`/`baseUrl`/`lastError`/`restarts`/`since`, `restart` 503 when off and 403 without `access:write`, account mapping, scope checks, patch/delete bodies, 404 mapping, login start/poll/complete/cancel, routing PUT, 502 relay), `PrismSyncCrypto.test.ts` (round trip, unique nonces, tamper/foreign-key/garbage rejection), `apps/server/src/fork/cli/secret.test.ts` (set from stdin and `--value-file`, 0600, trailing newline stripped, empty/TTY/traversal refused, list, delete), `apps/server/src/fork/cli/prism.test.ts` (the whole CLI over a scripted `HttpClient` and a temp state directory: the exact `status --json` key set and values, bearer header on every call, the secret never printed, one line per field without `--json`, exit 1 with `error` for flag off / missing secret / connection refused / 401 / a later 500 with `reachable` still true, the 5 s timeout driven by `TestClock`, sidecar port and external origin plus secret-name resolution, a misconfigured external section, `accounts` as JSON and as a table, `accounts` failing when unavailable), `PrismSync.test.ts` (primary-only snapshots, refresh-token stripping, deletion reconciliation, version rejection, corrupt snapshot safety, and failed transport state); `packages/client-runtime/src/fork/prismClient.test.ts` (bearer header and URL, 503 mapping, path encoding and JSON patch, typed 403); `apps/web/src/fork/prism/prismAccountsState.test.ts` (login-flow reducer transitions, stale-session answers ignored, paste-redirect round trip, weight parsing, usage-provider row description, Limits-view label, usage-source toggle state); `apps/mobile/src/fork/prism/prismSettings.logic.test.ts` (environment selection by connection phase and flag with the registry default against upstream servers, status key/value lines with and without the newer fields, 503/401/403 wording, account subtitle, the optimistic toggle/rollback and remove reducer including a stale reload mid-toggle, the usage-source reducer (absent field means on, optimistic flip, stale poll ignored, rollback with the error), the login-flow reducer, the focused-and-active polling predicate, restart poll steps and the 30 s deadline, the Accounts row summary); `apps/web/src/fork/forkSettingsNav.test.ts` and `forkSettingsSearch.test.ts` (Prism tab and search-entry visibility with the flag on/off, upstream paths always visible). Component wiring is not tested. `fork-release.yml` verifies the three bundled binaries are in the tarball. +- **upstream**: no. The provider wrapper uses upstream instance environments and standard model options; generic engine lifecycle and refresh fixes are candidates in the engine fork. +- **removal condition**: upstream ships provider account pooling, or Mic retires the proxy. + +## relay-selfhost + +- **status**: planned +- **purpose**: run T3 Connect's relay (`infra/relay`: Cloudflare Worker, Postgres, Clerk, APNs) on Mic's own infrastructure so `q1code connect` works off the tailnet and the `prism` sync can ride the managed-endpoint channel. +- **flags**: `relay-selfhost` (default off; `connect` self-hides when relay public config is absent, so off means upstream behavior). +- **owned dirs**: `apps/server/src/fork/relay/`, `packages/fork-core/src/relay*`. Infra definitions stay in the private repo. +- **seams**: TBD at design time; target zero, since relay config is already resolved at release time from env. +- **tests**: TBD. Must include parity: no relay config baked when the flag is off. +- **upstream**: no. +- **removal condition**: Tailscale-only remains sufficient, or upstream makes the relay endpoint configurable without a fork. + +## swift-ios + +- **status**: active +- **purpose**: the native SwiftUI iOS client from upstream PR pingdotgg/t3code#5178, carried as a fork feature so it can be built locally in Xcode against Mic's own Apple team and bundle identifiers (no EAS, no TestFlight). +- **flags**: none. It is a separate build target, not a runtime toggle; the server does not know which client connects. +- **owned dirs**: `apps/swift-ios/`, `docs/user/swiftui-mobile.md`. +- **carry source**: PR #5178 head `9a3aef3bd7be7cdcbc52a0595d9845bbd269ac91` (branch `t3code/rebuild-mobile-app-swift`). Carried verbatim: `apps/swift-ios/**` and `docs/user/swiftui-mobile.md`. Dropped from the PR: `.github/workflows/swift-ios.yml`, its `AGENTS.md` and `.agents/skills/*` edits, edits to existing `docs/user` pages, and `scripts/generate-swift-wire-fixtures.ts`. Relative to upstream main the PR touches no `packages/**` or `apps/server/**` path. +- **series layout**: one carry commit (`Upstream: pr:5178`), then fork commits on top: the identity override (`Upstream: no`) and build fixes the PR author could take (`Upstream: candidate`, against the PR branch rather than main). Never edit the carry commit in place. +- **refresh procedure**: when `fork-sync` reports that #5178 moved (`gh pr view 5178 -R pingdotgg/t3code --json headRefOid`), re-squash from the PR head: `git fetch upstream refs/pull/5178/head:pr-5178`, drop the old carry commit from the series, `git checkout pr-5178 -- apps/swift-ios docs/user/swiftui-mobile.md`, commit with the same title and a body naming the new head sha, replay the fork commits on top (retire any that the PR absorbed), and rebuild. Drop the carry when #5178 merges: the block evaporates on the next rebase and this entry flips to `upstreamed`; surviving fork commits become ordinary candidates against main. +- **local identity**: copy `apps/swift-ios/Config/Local.xcconfig.example` to `apps/swift-ios/Config/Local.xcconfig` (gitignored) and set `DEVELOPMENT_TEAM`, `T3CODE_BUNDLE_IDENTIFIER_PREFIX`, `T3CODE_DEBUG_DISPLAY_NAME`, `T3CODE_RELEASE_DISPLAY_NAME`. The app, widget, share extension, test bundle, and App Group identifiers all derive from the prefix; register `group.` and `group..dev` under the team before a device build. Unset keys keep upstream values, so with no `Local.xcconfig` the project is identical to upstream's. The URL scheme stays `t3code-swiftui[-dev]` and the widget and share display names stay upstream's. `Scripts/install-device.sh` refuses custom bundle identifiers; install on a device from Xcode instead. +- **build**: `cd apps/swift-ios && xcodebuild -project T3Code.xcodeproj -scheme T3Code -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build`. Tests: `apps/swift-ios/Scripts/ci-test.sh` (newest iPhone simulator; `T3_SWIFT_SIMULATOR_ID` pins one). SwiftPM fetches `clerk-ios`, `Nuke`, and `PhoneNumberKit` from GitHub on first resolve. `GhosttyKit.xcframework` is linked from `apps/mobile/modules/t3-terminal/Vendor/libghostty`, so an upstream GhosttyKit bump can break this app before the PR catches up; upstream's `apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift` shows the current callback shapes. +- **seams**: none. Everything lives under `apps/swift-ios/` plus one added doc page. +- **tests**: the app's own `T3CodeTests` target via `Scripts/ci-test.sh`. Wire fixtures under `apps/swift-ios/Tests/Fixtures/Wire/` were encoded from the PR's contracts at the carry sha; regenerate them with the PR's `scripts/generate-swift-wire-fixtures.ts` (run from a checkout of `pr-5178`, or copy the script in temporarily) after a contract change. At the first carry (fork `7978c9b94`, upstream main `9409dd20a`) the regenerated fixtures were byte-identical to the committed ones. Fork CI has no macOS job yet, so nothing runs these automatically. +- **known protocol risks** (checked against `packages/contracts` on `fork` at the carry): every RPC method the app calls exists in `rpc.ts` or `orchestration.ts` (`server.*`, `projects.*`, `filesystem.browse`, `vcs.*`, `git.runStackedAction`, `review.*`, `terminal.*`, `attachments.*`, `assets.createUrl`, `provider.uploadFeedback`, `orchestration.*`), and every orchestration command type it dispatches (`thread.*`, `project.create`) is declared. The activity kind `provider.approval.respond.failed`, which the app matches by string, is not declared anywhere in contracts; if the server never emits it the app only loses a stale-approval cleanup path. The app does not use the `preview.*`, `projects.add/list/remove`, `projects.searchContents`, `server.getSettings`, or `shell.openInEditor` RPCs. +- **upstream**: `pr:5178` for the carry. Fixes on top are `Upstream: candidate` against the PR branch, offered to the PR author, never to main. +- **removal condition**: #5178 merges to upstream main (entry becomes `upstreamed`), or the RN app proves enough and the carry is dropped. diff --git a/fork/FORK.md b/fork/FORK.md new file mode 100644 index 000000000000..ab8ea54f231e --- /dev/null +++ b/fork/FORK.md @@ -0,0 +1,83 @@ +# q1code + +q1code is a public fork of T3 Code, specialised for one user (Mic) and one private stack. It is not a distribution for other people. Everything about that stack (hostnames, unit files, backup paths) lives in a separate private repo and reaches q1code through env or `~/.q1code/userdata/fork.json`. The public tree never names a private host. + +AGENTS.md still applies in full. This file adds the rules that keep the fork syncable with an upstream that ships ~26 commits a day. + +## Branch model + +``` +upstream/main pingdotgg/t3code. Fetched, never edited. +main fast-forward mirror of upstream/main. Never commit here. +fork main + a linear series of fork commits. Rebased onto main on every sync. + Deployed trunk. Force-pushed with --force-with-lease only. +fork/ optional refs inside the series, moved by `git rebase --update-refs`. +up/ upstream PR branches. Branched from main. Never from fork. +sync/ throwaway branch a sync rebases on. Promoted to fork or reviewed as a PR. +snap/ tag taken at fork before every rebase. Rollback is one force-push. +``` + +The series is the changelog: `git log main..fork` answers "what is different from upstream". `rerere` is on (`rerere.enabled`, `rerere.autoupdate`, `rebase.updateRefs`) so a conflict is resolved once. The rerere cache is committed to the `fork-rerere` orphan branch. + +## Isolation + +Fork code is additive files in fork-owned locations: + +- `packages/fork-core/` (`@q1code/core`): flag registry, brand, fork config, fork RPC contracts. No heavy deps. +- `apps/server/src/fork/`, `apps/web/src/fork/`, `apps/mobile/src/fork/`, `apps/desktop/src/fork/` +- `.github/workflows/fork-*.yml`. Upstream workflows are disabled with `gh workflow disable`, never deleted. +- `.agents/skills/fork-*/` +- `fork/`: this file, `FEATURES.md`, `SEAMS.md` (generated), `docs/`. + +Upstream files are touched only at seams. A seam is at most 3 lines (one import plus one call, JSX element, or array entry), carries `// fork: `, and is either guarded by its flag or inert (an optional key, a registry entry). A seam never restructures upstream code. If a feature needs restructuring, that is an upstream PR first; the feature waits or carries a temporary larger patch marked `Fork-Seam-Debt: yes`. + +Prefer seam points built for extension: `builtInDrivers.ts`, `ExecutionEnvironmentCapabilities`, `settingsSearch.ts`, the command palette list, `ServerEnvironment.ts` capabilities, route tables. Do not seam inside the bodies of `ChatView.tsx`, `Sidebar.tsx`, or `ChatComposer.tsx`; if you must, the seam is one `` whose implementation lives in `apps/web/src/fork/`. + +`fork/SEAMS.md` lists every upstream file the series touches. Budget: 40 files. `scripts/fork/seams.ts` regenerates it and fails over budget. + +## Commits + +Every fork commit carries trailers: + +``` +Fork-Feature: required. `base` for infra, else the feature slug from FEATURES.md +Upstream: no | candidate | pr: | merged: +``` + +Conventional titles as upstream (`feat(web): ...`) so a cherry-pick needs no rewording. + +`candidate` commits must apply on plain `main`: no `@q1code/` imports, no flag checks, no fork paths, no `// fork:` comments. They sit first in the series so they never depend on fork-only commits. Before starting any fork work, ask "would upstream want part of this?" and split the series into `candidate` then `no`. + +## Flags-off parity + +With every flag off, q1code behaves exactly like upstream. Flags live in `packages/fork-core/src/flags.ts`, default off, resolved on the server (`T3FORK_` env, then `fork.json`, then default) and published to clients through `capabilities.forkFlags`. Clients read `useForkFlag('slug')` from `packages/client-runtime`. Nothing runs, renders, or persists behind an off flag. Fork CI enforces this by running upstream's own suites on `fork`. + +## Upstream PRs without leaks + +- Branch `up/` from a freshly fast-forwarded `main`. Never from `fork`. +- `git cherry-pick -x` the candidate commits. Strip `Fork-*` and `Upstream:` trailers. +- `scripts/fork/leak-check.ts` must pass: no `@q1code/`, `/fork/`, `T3FORK_`, `fork:` comments, flag names, `q1`, `q1code` in the diff. +- PR text never mentions fork, downstream, q1, or q1code. Screenshots come from the `up/` build. +- Upstream accepts small focused fixes and not much else. Read their CONTRIBUTING.md before opening anything. + +The `fork-upstream-pr` skill runs this end to end. + +## Sync + +`fork-sync` fetches upstream, fast-forwards `main`, tags `snap/`, rebases `fork` onto `main` on `sync/`, resolves conflicts under fixed rules (upstream wins in upstream files unless a seam is destroyed; re-apply seams minimally; drop commits upstream absorbed; never wholesale ours or theirs), runs targeted checks, and either promotes deterministically or opens one sync PR. An agent that resolved conflicts by hand never promotes. Each run writes `fork/docs/sync-log/.md`. + +## Where to look + +- `fork/FEATURES.md`: the registry. Every slug in a trailer has an entry here. +- `fork/SEAMS.md`: generated seam table. +- `scripts/fork/`: `seams.ts`, `leak-check.ts`, `range-diff-classify.ts`, `sync.sh`, `promote.sh`, `rollback.sh`. +- `.agents/skills/fork-sync`, `fork-audit`, `fork-feature`, `fork-upstream-pr`, `fork-release`, `fork-triage-branches`. + +## Never + +- Commit on `main`. +- Branch an upstream PR from `fork`. +- Delete or edit an upstream workflow file. Disable it. +- Rewrite "T3 Code" strings globally. Brand seams are listed under `base` in FEATURES.md; the rest stays upstream's. +- Put a private hostname, secret, or unit file in this tree. +- Touch `~/.t3`. q1code's home is `~/.q1code`. diff --git a/fork/SEAMS.md b/fork/SEAMS.md new file mode 100644 index 000000000000..914c6f18ede1 --- /dev/null +++ b/fork/SEAMS.md @@ -0,0 +1,48 @@ +# Seams + +Generated by `node scripts/fork/seams.ts`. Do not edit by hand. + +Upstream files touched by the fork series (main...HEAD). +Files: 39 of 40 budget. Lines changed: 856. + +| file | lines changed | features | markers | +| --- | ---: | --- | ---: | +| `apps/desktop/package.json` | +1 / -0 | base | 0 | +| `apps/desktop/src/app/DesktopEarlyElectronStartup.test.ts` | +3 / -3 | base | 0 | +| `apps/desktop/src/app/DesktopEnvironment.test.ts` | +2 / -2 | base | 0 | +| `apps/desktop/src/app/DesktopStatePaths.ts` | +4 / -2 | base | 2 | +| `apps/mobile/package.json` | +1 / -0 | base | 0 | +| `apps/mobile/src/features/settings/components/settings-sheet-targets.ts` | +1 / -0 | cliproxy, prism | 1 | +| `apps/mobile/src/features/settings/SettingsRouteScreen.tsx` | +3 / -0 | cliproxy, prism | 3 | +| `apps/mobile/src/Stack.tsx` | +2 / -0 | cliproxy, prism | 2 | +| `apps/server/package.json` | +2 / -1 | base | 0 | +| `apps/server/src/bin.ts` | +4 / -0 | cliproxy, prism | 4 | +| `apps/server/src/cli/app.test.ts` | +4 / -4 | base | 0 | +| `apps/server/src/cli/invocation.test.ts` | +14 / -14 | base | 0 | +| `apps/server/src/cli/invocation.ts` | +3 / -2 | base | 3 | +| `apps/server/src/cloud/bootService.test.ts` | +20 / -20 | base | 0 | +| `apps/server/src/cloud/bootService.ts` | +3 / -2 | base | 3 | +| `apps/server/src/cloud/pinnedRuntime.test.ts` | +341 / -210 | base | 8 | +| `apps/server/src/cloud/pinnedRuntime.ts` | +9 / -3 | base | 8 | +| `apps/server/src/cloud/selfUpdate.test.ts` | +4 / -1 | base | 3 | +| `apps/server/src/environment/ServerEnvironment.ts` | +6 / -0 | base, cliproxy, prism | 6 | +| `apps/server/src/os-jank.ts` | +2 / -1 | base | 2 | +| `apps/server/src/provider/builtInDrivers.ts` | +3 / -2 | (none) | 3 | +| `apps/server/src/server.ts` | +2 / -0 | cliproxy, prism | 2 | +| `apps/server/src/usage/UsageLimitSources.ts` | +3 / -1 | prism | 3 | +| `apps/web/package.json` | +1 / -0 | base | 0 | +| `apps/web/src/branding.test.ts` | +10 / -10 | base | 0 | +| `apps/web/src/branding.ts` | +2 / -1 | base | 2 | +| `apps/web/src/components/settings/SettingsPanels.tsx` | +7 / -7 | base, cliproxy | 4 | +| `apps/web/src/components/settings/settingsSearch.ts` | +10 / -0 | base, cliproxy, prism | 5 | +| `apps/web/src/components/settings/SettingsSidebarNav.tsx` | +6 / -1 | base, cliproxy, prism | 5 | +| `apps/web/src/components/settings/UsageProviderSettings.tsx` | +3 / -1 | prism | 3 | +| `apps/web/src/components/settings/useAvailableSettingsSearchItems.ts` | +2 / -1 | cliproxy | 2 | +| `apps/web/src/components/sidebar/SidebarChrome.tsx` | +2 / -0 | (none) | 2 | +| `apps/web/src/routeTree.gen.ts` | +42 / -0 | cliproxy | 0 | +| `apps/web/src/versionSkew.ts` | +2 / -1 | base | 2 | +| `CLAUDE.md` | +1 / -0 | base | 0 | +| `packages/client-runtime/package.json` | +5 / -0 | base, cliproxy | 0 | +| `packages/contracts/src/environment.ts` | +1 / -0 | base | 1 | +| `pnpm-lock.yaml` | +34 / -0 | base, cliproxy | 0 | +| `vite.config.ts` | +1 / -0 | base | 1 | diff --git a/fork/docs/audits/20260905T044635Z.md b/fork/docs/audits/20260905T044635Z.md new file mode 100644 index 000000000000..3a66d0e05604 --- /dev/null +++ b/fork/docs/audits/20260905T044635Z.md @@ -0,0 +1,42 @@ +Upstream: 2fb99a7a664faf045f1884f38c620016b34874cb + +# Audit 20260905T044635Z + +Range: `b7d6e65021b021207424c56cb32d6d711fd875fb..2fb99a7a664faf045f1884f38c620016b34874cb` (55 commits). Main is three commits beyond `v0.0.39-nightly.20260905.1284`. This audit covers shipped main, not the open SwiftUI PR's newer head. + +## base + +- Upstream does not replace fork branding, isolated home, GitHub artifact installation or fork flags. +- `ServerEnvironment.ts` and the environment contract add `usagePriceOverrides`. Fork capability decoration preserves it; focused environment tests pass. Settings panels/search change restart-resume wording and worktree preferences without changing the fork settings insertion. Settings sidebar now observes visible sections; its Prism visibility filter still controls rendered navigation and has no submenu of its own. +- Provider maintenance now resolves the installer that owns each binary, preserves the selected executable path, and uses the shared Codex home for standalone updates. The Prism decorator retains direct-driver maintenance unchanged; Codex driver tests pass. +- Fork CI now uses upstream's `setup-apt-mirrors` action before both libsecret installation steps. Upstream Windows Spectre changes affect its desktop release job, which the server-only fork release does not have. +- No new workflow was introduced. Upstream workflows remain disabled; fork workflows remain enabled. GitHub's dynamic dependency graph is not a deploy/release publisher. +- The source package version is still `0.0.38`, including at the newest nightly tag; the published nightly is `0.0.39-nightly.20260905.1284` and latest fork release is `0.0.39-q1.7`. Resolve release version derivation before publishing; do not silently publish a downgrade. + +## prism + +- Upstream does not replace the independent gateway, primary-owned account pool, serving-only sync, quarantine dashboard or per-thread Prism/direct policy. The twelve pending integration commits are included in this sync. +- New custom model settings allow names and option descriptors. Claude/Codex construct those models before the Prism snapshot decorator appends its route descriptor, preserving custom capabilities. Provider/model tests and server/web/mobile typechecks pass. +- Claude limit pauses and model-fallback notices remain upstream runtime events. The routed adapter forwards current-source events and limits direct fallback to terminal failure; its tests pass. +- Usage Limits now filters selected environments; the fork changes only the source label, so filtering remains upstream-owned. Pricing overrides remain distinct from account quota and no duplicate fork pricing implementation is needed. +- Semantic compatibility fix: upstream no longer replaces the live socket when HTTP relay authorization renews. `prismClient.ts` now uses `executeAuthenticatedEnvironmentHttpRequest`, resolves `RemoteEnvironmentAuthorization` from the shared app runtime, builds its custom API client/proof against the current origin, and retries a rejected DPoP credential once. Local cookies, static bearer credentials, and typed Prism errors are preserved. Regression tests exercise current credentials, changed origin on mutation retry, and bounded repeated rejection. Web, desktop and RN share this client; no new seam. + +## update-check + +- Remains planned. Provider installer/version advisory improvements do not implement server-side discovery of q1code GitHub releases. Do not conflate provider updates with q1code application updates. +- No new hook removes the need to design the release-discovery capability. Release version derivation and installation smoke are the nearer prerequisites. + +## relay-selfhost + +- Remains planned. Upstream's credential renewal and network diagnostics improve remote connections but do not deploy a private relay. +- Reuse upstream authorization and connection services; no extra refresh timer or parallel transport is needed for the fork. + +## swift-ios + +- Upstream PR #5178 remains open at `b99405468a6b2be1e0f67d551ece824b1627e35c`; the existing carry stays intact. This sync intentionally does not replace a carried client from an unmerged PR during a shipped-main update. +- The Swift HTTP transport already supports request-bound authorization and refresh on expiry/rejection. Prism uses that shared transport. New capability fields are additive; the carried client does not edit custom model settings. No removed RPC was found in this range. +- Xcode build, simulator and native tests remain unverified on the PC. Refreshing the PR carry and macOS qualification are separate work, not implied by green TypeScript CI. + +## Proposed changes + +No feature status change or removal is warranted. Retain the existing 40-file seam budget. Finish full Fork CI on the new sync head; review source promotion before release. Coordinate real-provider acceptance with the Prism-owned thread. Resolve nightly-based release versioning and perform isolated installer smoke before any mic.sc deploy. diff --git a/fork/docs/prism.md b/fork/docs/prism.md new file mode 100644 index 000000000000..3e938e8c08f6 --- /dev/null +++ b/fork/docs/prism.md @@ -0,0 +1,34 @@ +# Prism accounts + +Prism manages one shared pool of provider accounts. Sign in with a Claude +subscription or a ChatGPT/Codex subscription from Prism → Add account. Enable Prism in Settings first. +Grok remains available. For a remote browser, paste the completed callback URL +when the sign-in flow asks for it. + +Account health shows known token expiry and refresh status. An unknown expiry +means the gateway has not supplied it. A sign-in alert means the account needs +attention; reconnect it through Add account. Disable an account to pause it and +re-enable it to restore it to the pool, or remove it to delete its credential. + +Manage pooled accounts on the primary environment. Serving replicas receive an +encrypted snapshot without refresh tokens; only the primary enrolls accounts, +refreshes credentials, and applies account changes. Replicas can serve while the +primary is unreachable until their access credentials expire. They cannot take +over refresh ownership automatically. + +When upgrading from the older sync protocol, upgrade the primary and all +replicas together. New replicas reject older snapshots. Start replicas with a +fresh auth directory so old refresh-capable credentials cannot run during the +transition. Transfer the latest primary state and stop the old refresh owner +before promoting another gateway. + +Claude and Codex use the Prism pool by default. Choose **Connection → Direct +provider** in a thread's model options to use its local provider credentials. +When Prism cannot serve a turn, q1code retries it once on the local provider and +shows a warning. Retrying a turn may repeat tools that already ran. Cancelling a +turn does not trigger fallback. + +The primary gateway tracks known Claude and Codex quota observations and avoids +accounts whose observed limit has not reset. Unknown quota remains unknown +until the provider reports it. Accounts that need a new sign-in are excluded +from routing until reconnected. diff --git a/fork/docs/sync-log/20260902T084653Z.md b/fork/docs/sync-log/20260902T084653Z.md new file mode 100644 index 000000000000..4d45bce9c6af --- /dev/null +++ b/fork/docs/sync-log/20260902T084653Z.md @@ -0,0 +1,8 @@ +# Sync 20260902T084653Z + +- Upstream range: 14f15cfed..5b7d72aad (1 commit: feat(updates): continue active threads across server restarts (#9167)) +- Conflicts: apps/server/src/cloud/selfUpdate.test.ts. Cause: the fork had re-indented the whole test block by wrapping it.layer in a longer call. Resolved by starting from upstream's file and swapping the layer on one line via a named const. rerere recorded. +- Seam files upstream touched in this range: packages/contracts/src/environment.ts, apps/web/src/versionSkew.ts, apps/web/src/components/settings/settingsSearch.ts, SettingsPanels.tsx (all auto-merged; targeted tests and typechecks green). +- Dropped as absorbed: none. +- Fixups folded: deterministicKeys service key in apps/server/src/fork/releaseTarball.ts. +- Promotion: manual, by the maintainer's agent after fork-ci on sync/20260902T084653Z (bootstrap sync; nothing deployed yet). diff --git a/fork/docs/sync-log/20260902T231054Z.md b/fork/docs/sync-log/20260902T231054Z.md new file mode 100644 index 000000000000..71ed0d4b0961 --- /dev/null +++ b/fork/docs/sync-log/20260902T231054Z.md @@ -0,0 +1,7 @@ +# Sync 20260902T231054Z + +- Upstream range: 5b7d72aad..c742edd46 (29 commits). +- Conflicts: apps/web/src/components/settings/SettingsPanels.tsx, adjacent import lines (upstream added PanelAnimationsPreview next to the fork's ForkSettingsSection import). Resolved by keeping both. rerere recorded. +- Seam files upstream touched: SettingsPanels.tsx (About section sizes). Targeted web and server suites green after rebase. +- Dropped as absorbed: none. Fixups folded: none. +- Promotion: manual by the maintainer's agent after fork-ci on sync/20260902T231054Z. diff --git a/fork/docs/sync-log/20260903T020032Z.md b/fork/docs/sync-log/20260903T020032Z.md new file mode 100644 index 000000000000..8523bfaf1f27 --- /dev/null +++ b/fork/docs/sync-log/20260903T020032Z.md @@ -0,0 +1,5 @@ +# Sync 20260903T020032Z + +- Upstream range: c742edd46..ef6cc0b36 (9 commits). +- Conflicts: none. Range-diff: every fork commit unchanged. Seam files upstream touched: none in this range. +- Gate: pass. Promotion: deterministic path (scripts/fork/promote.sh after fork-ci success on sync/20260903T020032Z). First fully automatic sync. diff --git a/fork/docs/sync-log/20260903T032430Z.md b/fork/docs/sync-log/20260903T032430Z.md new file mode 100644 index 000000000000..142b1b291e8d --- /dev/null +++ b/fork/docs/sync-log/20260903T032430Z.md @@ -0,0 +1,6 @@ +# Sync 20260903T032430Z + +- Ran unattended on spark-01 (mic-q1code-sync.timer, first successful run after creating the local main branch). +- Upstream range: ef6cc0b36..9409dd20a (13 commits). Rebase clean; range-diff unchanged. +- Gate: fail (upstream touched seam files: ServerEnvironment.ts, SettingsPanels.tsx, settingsSearch.ts, contracts/environment.ts, client-runtime/package.json). Review PR q1/q1code#2. All upstream changes additive; seams intact; fork-ci green. +- Promotion: manual after review (scripts/fork/promote.sh from the MacBook). diff --git a/fork/docs/sync-log/20260904T090720Z.md b/fork/docs/sync-log/20260904T090720Z.md new file mode 100644 index 000000000000..5a2e7831d294 --- /dev/null +++ b/fork/docs/sync-log/20260904T090720Z.md @@ -0,0 +1,12 @@ +# Sync 20260904T090720Z + +- Ran by hand from the MacBook (the spark-01 timer had failed three times with sync.sh exit 5 on a stale checkout; see below). +- Upstream range: 9409dd20a..5f878d2a8 (137 commits). Rebase needed hand resolution in three commits: + - `feat(fork): add @q1code/core ...`: `apps/desktop/package.json` and `pnpm-lock.yaml`; upstream added `@napi-rs/keyring` next to the fork's `@q1code/core` dependency. Kept both. + - `feat(fork): brand as q1code ...`: `apps/server/src/cloud/bootService.test.ts` (upstream rewrote the systemctl assertions; took upstream's shape with the fork's unit name) and `SettingsPanels.tsx` (upstream gave the About section an id; took upstream, seams untouched). + - `feat(web): move the cliproxy accounts UI to a Prism settings tab`: `SettingsSidebarNav.tsx`; upstream folded the two nav render paths into one with page sub-sections, so the `navItems` seam moved to the single `map` call. +- Two fixups appended after the gates: the upstream boot-service fake hard-codes `t3code.service` (fork unit name substituted) and upstream's new "Google sign in" / "Antigravity" provider search terms collided with the Prism add-account aliases (aliases dropped). +- Range-diff: 49 unchanged, 4 content-changed (the three resolved commits plus the mobile Prism screen, whitespace only), 0 dropped, 0 added. +- Gate: fail (conflicts). Seams 36/40. Typecheck (fork-core, client-runtime, server, web, mobile) clean; fork tests green. +- Promotion: manual via scripts/fork/promote.sh after fork-ci on the branch. +- Timer note: the unattended runs reported "deterministic tier on c8b5eaefe" and exit 5; the sync checkout on spark-01 needs its `fork` branch re-pointed at origin/fork after the manual promotions. diff --git a/fork/docs/sync-log/20260904T210700Z.md b/fork/docs/sync-log/20260904T210700Z.md new file mode 100644 index 000000000000..d8f7a4324c94 --- /dev/null +++ b/fork/docs/sync-log/20260904T210700Z.md @@ -0,0 +1,49 @@ +# Sync 20260904T210700Z + +- Upstream: `163d86a78430d23414abde323ca70d8379280c0b..b7d6e65021b021207424c56cb32d6d711fd875fb` (17 commits). +- Input: the unpromoted `sync/20260904T201256Z` at `2734ebfa321ca1fd806ce4f14bc949ec8c5249a7`, including its final server dependency correction. That branch already carries the earlier `5f878d2a8..163d86a78` sync. +- Snapshot: `snap/20260904T210700Z` preserves that complete input. Existing `fork`, release, and previous sync refs were deliberately kept unchanged with `rebase.updateRefs=false`. +- Dropped commits: none. All 71 input commits remain. + +## Resolutions + +One manual conflict in `apps/server/src/cloud/bootService.test.ts`: retained upstream's `Path.join` assertion for host portability and the fork's `com.t3tools.q1code.service.plist` name. + +The previous sync's CI run [33915529357](https://github.com/q1/q1code/actions/runs/33915529357) failed two mobile highlighter tests. Both implementations, tests, and Shiki dependency versions match upstream. The focused tests passed locally. A controlled clock crossing Shiki's default 500 ms tokenization budget reproduced the exact incorrectly combined `42;` token from CI. The failed cold-start case took 736 ms in that run. + +Fork CI now runs mobile separately with two test workers, so its grammar initialization does not compete with all other packages. This is folded into the existing fork CI commit. Assertions and runtime tokenization limits are unchanged; the next CI run must validate the scheduling fix. + +Range-diff before this log: 69 unchanged commits, two content changes (the branding assertion and CI scheduling), zero dropped or added commits. Classification: **content**. + +## Seam review + +Upstream touched four files also changed by the fork: + +- `apps/server/src/cloud/bootService.ts`: upstream's writable fsync handle and Windows directory-fsync handling remain intact; the fork's service identity and release hooks remain in place. +- `apps/server/src/cloud/bootService.test.ts`: portable path assertion resolved as above; focused tests pass. +- `apps/desktop/src/app/DesktopEnvironment.test.ts`: upstream's platform-sensitive expectations replayed with the fork identity; focused tests pass. +- `vite.config.ts`: upstream's temporary-directory setup is retained alongside the existing fork formatter exclusion. + +No new seam or extension point was required. Seam gate: 39/40 files. Leak-check applies to upstream extraction branches; no extraction or upstream PR was made in this sync. + +## Local validation + +Commands use the project-local runner through `corepack pnpm exec`. + +- Server: `vp test run src/cloud/bootService.test.ts src/fork src/provider/Layers/ProviderRegistry.test.ts src/environmentTheme.test.ts`; 178 tests across 15 files pass after rerunning the two CLI files with loopback access. The initial sandbox denied port reservation; it was not a source failure. +- Fork core: `vp test run src`; 29 tests across five files pass. +- Client runtime: `vp test run src/fork`; four tests pass. +- Web: `vp test run src/fork`; 27 tests across three files pass. +- Mobile: `vp test run src/fork src/features/diffs/nativeReviewDiffHighlighter.test.ts src/features/review/shikiReviewHighlighter.test.ts`; 45 tests across three files pass. The 19 highlighter tests also pass through the new CI command with `--maxWorkers 2`. +- Desktop: `vp test run src/app/DesktopEnvironment.test.ts`; six tests pass. +- Contracts: `vp test run src/providerRuntime.test.ts`; 12 tests pass. +- Package-scoped typechecks for server, mobile, fork core, client runtime, web, and desktop exit 0. +- Targeted formatting passes. No repository-wide local check, browser, or native simulator run. + +## Promotion + +Pending human review: manual conflict resolution and a CI edit make this ineligible for automatic promotion. The owner has not requested a PR, so no sync PR was created. The prepared sync branch is the review artifact; `fork` and deployed services remain unchanged. Green CI on the pushed head is required before promotion or release. + +Disabled the newly active upstream `cursor-hygiene-webhook.yml` and `windows-tests.yml` workflows through GitHub configuration. Fork workflows remain enabled; upstream workflow files are unchanged. + +Prepared by Codex on GPT-6. diff --git a/fork/docs/sync-log/20260905T044635Z.md b/fork/docs/sync-log/20260905T044635Z.md new file mode 100644 index 000000000000..aa1bc58b09b4 --- /dev/null +++ b/fork/docs/sync-log/20260905T044635Z.md @@ -0,0 +1,29 @@ +# Sync 20260905T044635Z + +- Upstream: `b7d6e65021b021207424c56cb32d6d711fd875fb..2fb99a7a664faf045f1884f38c620016b34874cb` (55 commits), three commits newer than `v0.0.39-nightly.20260905.1284`. +- Input: `prism/account-lifecycle`, including the twelve pending Prism integration commits above the previously reviewed fork. `snap/20260905T044635Z` preserves the promoted fork; `snap/20260905T044635Z-prism` preserves the full input. +- Rebase: clean, no conflicts or rerere resolutions. No feature commit was absorbed or intentionally dropped. Local `fork` remains at its pre-sync value. + +## Adaptations and classification + +The semantic audit found that upstream now renews HTTP authorization without reconnecting. The shared Prism client now uses upstream's renewal helper, preserving typed errors and matching request-bound proofs to renewed origins. Fork CI also adopts upstream's APT mirror fallback. Both edits were fixups autosquashed into their existing fork commits. + +Before fixes, range-diff reported 81 unchanged and three context-only entries. After fixes its default matching reports 79 unchanged, three context-only, two removed and two added entries: the latter pairs are the rewritten client and CI commits with identical subjects, not removed features. These manual content edits and six upstream-touched seam files make automatic promotion ineligible. + +## Seam review + +Upstream touched `apps/server/src/environment/ServerEnvironment.ts`, `packages/contracts/src/environment.ts`, `apps/web/src/components/settings/SettingsPanels.tsx`, `SettingsSidebarNav.tsx`, `settingsSearch.ts`, and `apps/web/src/components/usage/UsageLimits.tsx`. Additive capabilities, navigation visibility, restart settings and selected-environment filtering are preserved. See [the feature audit](../audits/20260905T044635Z.md). + +## Validation + +- Shared HTTP/auth: `vp test run packages/client-runtime/src/fork/prismClient.test.ts packages/client-runtime/src/state/environmentHttpAuth.test.ts` — 26 tests pass. +- Server: focused Prism routing, HTTP, sync, service, Codex driver/provider, Claude catalog and environment suites — 70 tests pass. Three initial environment assertions saw inherited `T3_SERVICE_LAUNCHER_CONTEXT` from the installed host; rerunning that file with only this variable unset passed all seven tests. No production service changed. +- Web/mobile: settings search/visibility, Prism presentation, custom-model editor and usage targets/state — 82 tests pass across seven files. +- Fork core, flags-off environment wiring and shared authorization layer — 68 tests pass across eight files. +- Server, web, mobile and client-runtime package typechecks pass. Targeted client lint/format pass. Seam check passes at 40/40. +- `leak-check.ts` is specifically an upstream-extraction checker and requires a range; no upstream extraction is being published. Its bare invocation reported a usage error and is not represented as a passing gate. +- Full Fork CI: pending on the pushed sync head. No repo-wide local checks, browser verification, Apple build or deployment. + +## Promotion + +Review required. The fork-sync skill prohibits automatic promotion after manual gate edits; the earlier approval applied to the previously reviewed sync only. Per repository instructions, no PR is opened without an explicit request. The sync branch is the concrete review artifact; no fork promotion or release is performed here. diff --git a/fork/docs/sync-log/20260906T212712Z.md b/fork/docs/sync-log/20260906T212712Z.md new file mode 100644 index 000000000000..a4967a33d931 --- /dev/null +++ b/fork/docs/sync-log/20260906T212712Z.md @@ -0,0 +1,30 @@ +# September 6 upstream sync + +- Upstream range: `2fb99a7a664faf045f1884f38c620016b34874cb..ea646c0834a3394ecb0be4a30c5d367e5a9002bd` (includes nightly `v0.0.39-nightly.20260906.1316`). +- Rollback: `snap/20260906T212712Z` at `3de8106e4b9d6f739aa37c53cf752c60dae1fbdd`. +- Replayed all 86 commits; no complete commits dropped. +- Hand resolutions: retained upstream's patched dependency hash with the core workspace link; kept the CLI helper private and updated branding expectations; retained npm/pnpm fallback with verified release tarball installation and tests; combined Projects and Prism routes. +- Upstream's pooled limits view reads each source's label, absorbing the old Prism display-label seam. Removed the obsolete UsageLimits hunk instead of restoring the superseded component. +- Swift build repair: qualify `SwiftUI.Environment` in PrismView to avoid collision with the native connection model. +- Gate repair: forwarded the new adapter `compaction` descriptor through the Prism wrapper, preserving routed native compaction and slash-command compaction. +- Validation: 40 focused CLI/runtime/usage tests and 8 routed-adapter tests; package typechecks for server, web, client-runtime, and core. See the sync PR for final outcomes. Seam inventory: 39 files, budget 40. +- Promotion decision: **pr**, by Codex. Hand resolutions require human review; deployed `fork` is unchanged. No release or installation performed. + +Upstream-touched files in the maintained series: + +- `apps/mobile/src/Stack.tsx` +- `apps/mobile/src/features/settings/SettingsRouteScreen.tsx` +- `apps/server/src/cli/invocation.test.ts` +- `apps/server/src/cli/invocation.ts` +- `apps/server/src/cloud/pinnedRuntime.test.ts` +- `apps/server/src/cloud/pinnedRuntime.ts` +- `apps/server/src/environment/ServerEnvironment.ts` +- `apps/server/src/server.ts` +- `apps/web/src/components/settings/SettingsPanels.tsx` +- `apps/web/src/components/settings/SettingsSidebarNav.tsx` +- `apps/web/src/components/settings/settingsSearch.ts` +- `apps/web/src/routeTree.gen.ts` +- `apps/web/src/versionSkew.ts` +- `packages/client-runtime/package.json` +- `packages/contracts/src/environment.ts` +- `pnpm-lock.yaml` diff --git a/fork/docs/sync-log/README.md b/fork/docs/sync-log/README.md new file mode 100644 index 000000000000..5bcaff36c716 --- /dev/null +++ b/fork/docs/sync-log/README.md @@ -0,0 +1,3 @@ +# Sync log + +Every `fork-sync` run (deterministic tier or agent tier) writes one file here named `.md`, where the stamp matches the `snap/` tag and the `sync/` branch of that run. Each file records: the upstream range rebased over (`..` plus the nightly tag if one landed), the fork commits dropped because upstream absorbed them (with the upstream SHA that replaced each), every conflict and how it was resolved (rerere replay, upstream wins, seam re-applied, commit dropped), the seam files upstream touched in that range, and the promotion decision: `auto` (deterministic gate passed, promoted by `scripts/fork/promote.sh`), `pr` (opened or updated the single sync PR, promoted later by a human via label or approval), or `rolled-back` (promotion reverted to the `snap/` tag), each with who or what made the call (the timer unit, the agent harness, or a person). diff --git a/fork/docs/triage-2026-09-02.md b/fork/docs/triage-2026-09-02.md new file mode 100644 index 000000000000..ab85116f7b9f --- /dev/null +++ b/fork/docs/triage-2026-09-02.md @@ -0,0 +1,252 @@ +# Stale branch triage — 2026-09-02 + +Repo: `q1/q1code` (fork of `pingdotgg/t3code`). Baseline `main` = `14f15cfed` (identical to `upstream/main` at triage time). + +Method per branch: `git log main..origin/` for commits ahead; `git cherry main origin/` for patch-id presence (`+` = not on main by patch-id); `git merge-tree --write-tree main origin/` for a rebase/merge dry run; `git log ..main -- ` plus `gh pr view` for relevance. Content equivalence with upstream squash merges was checked by comparing `git patch-id --stable` of the branch's full diff against the squash commit's diff, and by `git diff origin/ -- ` for residuals. + +## Headline finding + +Only `feat/claude-fable-5-1` is fork-authored work (Michael Fox), and it is byte-for-byte what upstream squash-merged as #9078. The other eight branches are authored entirely by upstream maintainers (Julius Marminge, Theo Browne). They are stale snapshots of `pingdotgg/t3code` work branches that ended up in the fork under the same names (most likely from a `refs/heads/*` mirror push during an early sync). Every one of them was later revised and squash-merged upstream from the `pingdotgg:` head, and the upstream branches have since been deleted. Nothing on any of the nine branches is a candidate for an upstream PR or a fork feature. + +`git cherry` reports `+` for every commit because upstream squash-merges; patch-id equivalence against the squash commits is the meaningful signal and is reported below. + +## Summary + +| branch | ahead | conflicts (merge-tree vs main) | recommendation | archive tag | +| ------------------------------------ | -------------------- | ------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------- | +| feat/claude-fable-5-1 | 3 commits, 7 files | 7 files | drop — fully merged upstream as #9078 (identical patch-id); no follow-up left | archive/feat-claude-fable-5-1 | +| fix/desktop-build-prerequisites | 6 commits, 4 files | 3 files | drop — identical patch-id to upstream #8975 | archive/fix-desktop-build-prerequisites | +| t3code/add-quit-double-click-mode | 1 commit, 10 files | 3 files | drop — earlier snapshot of upstream #9076 (+ follow-up #9141) | archive/t3code-add-quit-double-click-mode | +| t3code/fix-mobile-thread-scrolling | 7 commits, 7 files | 2 files | drop — earlier snapshot of upstream #9013 | archive/t3code-fix-mobile-thread-scrolling | +| t3code/explain-chat-media-paths | 10 commits, 68 files | 25 files | drop — earlier snapshot of upstream #9023 | archive/t3code-explain-chat-media-paths | +| t3code/audit-app-project-command | 4 commits, 26 files | 0 (clean) | drop — earlier snapshot of upstream #8824 | archive/t3code-audit-app-project-command | +| t3code/audit-effect-usage | 2 commits, 51 files | 11 files | drop — Theo's draft; trimmed subset shipped as upstream #7941, rest superseded | archive/t3code-audit-effect-usage | +| t3code/remote-mac-app-updates | 5 commits, 31 files | 21 files | drop — earlier snapshot of upstream #6554 | archive/t3code-remote-mac-app-updates | +| t3code/review-high-priority-feedback | 10 commits, 42 files | 16 files | drop — superseded; narrowed to upstream #5302, remainder never landed | archive/t3code-review-high-priority-feedback | + +All nine branches are archived as tags on `origin` and the remote branches deleted (see "Archive log" at the end). + +--- + +## feat/claude-fable-5-1 + +Merge-base: `2d156a83b` (2026-09-01, feat(shortcuts): copy active thread reference #8994). + +Commits ahead (3), 7 files touched: + +| sha | date | subject | +| --------- | ---------- | ----------------------------------------------------------------------------------- | +| a007576a8 | 2026-09-01 | feat(claude): add Claude Fable 5.1 (author: Julius Marminge, from #9077) | +| 7a2e5a4cf | 2026-09-01 | feat(claude): keep Claude Fable 5.1 out of the legacy section and add fable aliases | +| 267b0905a | 2026-09-01 | fix(claude): demote Claude Fable 5 to legacy | + +Files: `apps/server/src/provider/Layers/ClaudeAdapter.test.ts`, `ClaudeProvider.ts`, `ProviderRegistry.test.ts`, `apps/server/src/provider/ModelManifest.test.ts`, `model-manifest.json`, `packages/contracts/src/model.ts`, `packages/shared/src/model.test.ts`. + +Cherry: all three `+` (upstream squash-merged, so per-commit patch-ids do not match). + +Upstream state: PR #9078 (author q1) MERGED 2026-09-01 as squash `c17d02cff`. Patch-id of the branch's full diff (`8283dec6…`) equals the squash commit's patch-id — identical content. `git diff c17d02cf origin/feat/claude-fable-5-1 -- ` is empty. Later upstream commits on these paths: #9084 (discover Claude models from remote manifest), #9154 (grok). Nothing on the branch is unmerged. + +Merge-tree: conflicts in all 7 touched files (expected: the same change is already on main, so re-applying it collides). + +Recommendation: **drop**. The PR is merged and there are no remaining commits to follow up; `follow-up-to-merged-pr` has an empty set. + +## fix/desktop-build-prerequisites + +Merge-base: `85b656ff3` (2026-08-31, style: format CodeRabbit configuration). Author: Julius Marminge (all commits). + +Commits ahead (6), 4 files touched: + +| sha | date | subject | +| --------- | ---------- | ------------------------------------------------ | +| e51f030c5 | 2026-08-31 | fix(desktop): check artifact build prerequisites | +| 3eab9fc4e | 2026-08-31 | fix(desktop): tighten prerequisite probes | +| bcd940651 | 2026-08-31 | fix(desktop): validate configured Python | +| e79c08b7e | 2026-08-31 | fix(desktop): satisfy effect diagnostics | +| 5b601f7fd | 2026-08-31 | fix(desktop): align Linux prerequisites | +| 98d04853a | 2026-08-31 | fix(desktop): validate discovered Python | + +Files: `CONTRIBUTING.md`, `docs/internals/scripts.md`, `scripts/build-desktop-artifact.test.ts`, `scripts/build-desktop-artifact.ts`. + +Cherry: all `+`. Upstream: PR #8975 (juliusmarminge, head `pingdotgg:fix/desktop-build-prerequisites`) MERGED 2026-09-02 as `082358f9e`. Branch patch-id `ebb2139d…` == squash patch-id — identical content, zero residual. Follow-up upstream on the same script: #9184 (skip cached monitor compiler check). + +Merge-tree: conflicts in `docs/internals/scripts.md`, `scripts/build-desktop-artifact.test.ts`, `scripts/build-desktop-artifact.ts`. + +Recommendation: **drop** (identical to merged #8975). + +## t3code/add-quit-double-click-mode + +Merge-base: `2d156a83b` (2026-09-01). Author: Julius Marminge. + +Commits ahead (1), 10 files touched: + +| sha | date | subject | +| --------- | ---------- | ---------------------------------------------------------- | +| cd5bcf049 | 2026-09-01 | feat(desktop): add configurable quit shortcut confirmation | + +Files: `apps/desktop/src/settings/DesktopClientSettings.test.ts`, `apps/desktop/src/window/DesktopWindow.ts`, `QuitHold.test.ts`, `QuitHold.ts`, `apps/web/src/components/QuitHoldOverlay.tsx`, `settings/SettingsPanels.tsx`, `settings/settingsSearch.ts`, `packages/contracts/src/ipc.ts`, `settings.test.ts`, `settings.ts`. + +Cherry: `+`. Upstream: PR #9076 (juliusmarminge, head `pingdotgg:t3code/add-quit-double-click-mode`) MERGED 2026-09-01 as `9d1879b14` (11 files, +364/-131 vs the snapshot's +182/-95). The fork branch is an earlier, smaller revision; the residual vs the squash is confined to `QuitHold.ts`, `QuitHold.test.ts`, `QuitHoldOverlay.tsx`, `DesktopWindow.ts`, `ipc.ts` and is the upstream branch's later refinement, not extra fork work. Upstream then shipped #9141 (hold-to-quit no longer gets stuck) on the same files. 10 upstream commits on touched paths since merge-base. + +Merge-tree: conflicts in `QuitHold.test.ts`, `QuitHold.ts`, `QuitHoldOverlay.tsx`. + +Recommendation: **drop** (superseded by merged #9076 + #9141). + +## t3code/fix-mobile-thread-scrolling + +Merge-base: `d35c71d1b` (2026-09-01, feat(web): add pull request list filters #8809). Author: Julius Marminge. + +Commits ahead (7), 7 files touched: + +| sha | date | subject | +| --------- | ---------- | ---------------------------------------------------------------- | +| cc3fb6765 | 2026-09-01 | fix(mobile): keep thread scroll bounds current after animations | +| ec32e23d4 | 2026-09-01 | Delete patches/@legendapp__list@3.3.5.patch | +| 479f910c7 | 2026-09-01 | fix(mobile): restore list patch and remove scroll test harness | +| fa0926df4 | 2026-09-01 | fix(mobile): preserve position during first disclosure expansion | +| 2ea9edb02 | 2026-09-01 | fix(mobile): prevent overlapping disclosure animations | +| 53bdfed40 | 2026-09-01 | fix(mobile): scope disclosure state to its environment | +| eb9067349 | 2026-09-01 | fix(mobile): sequence disclosure row animations | + +Files: `apps/mobile/src/features/threads/ThreadDetailScreen.tsx`, `ThreadFeed.tsx`, `thread-feed-live-follow.test.ts`, `thread-feed-live-follow.ts`, `thread-work-log.tsx`, `patches/@legendapp__list@3.3.5.patch`, `pnpm-lock.yaml`. + +Cherry: all `+`. Upstream: PR #9013 (juliusmarminge, head `pingdotgg:t3code/fix-mobile-thread-scrolling`) MERGED 2026-09-01 as `261380f91` (+253/-164 vs snapshot +203/-145). Residual vs squash is only `ThreadFeed.tsx` and `thread-work-log.tsx` — the upstream branch's last revisions. 7 later upstream commits on these paths (#9106, #9146, #9140, #9120, #9023, #8936). + +Merge-tree: conflicts in `ThreadFeed.tsx`, `thread-work-log.tsx`. + +Recommendation: **drop** (superseded by merged #9013). + +## t3code/explain-chat-media-paths + +Merge-base: `73776d4e5` (2026-09-01, test: remove static presentation snapshots #9008). Author: Julius Marminge. + +Commits ahead (10), 68 files touched: + +| sha | date | subject | +| --------- | ---------- | ----------------------------------------------------------------- | +| 62e35711c | 2026-09-01 | fix(media): preview host files and stream videos across clients | +| e67d3b15b | 2026-09-01 | fix(media): add video icons and paused previews | +| 1dc4c2c77 | 2026-09-01 | fix(media): secure file streams and renew preview capabilities | +| b45ff7661 | 2026-09-01 | fix(media): validate stream bounds and resolve network-path links | +| f42882d2e | 2026-09-01 | fix(media): keep preview states and error context consistent | +| bde2af5bf | 2026-09-01 | fix(media): avoid weak validators on mutable video streams | +| af0ccf353 | 2026-09-01 | test(media): remove low-value rendering and wiring tests | +| d84a53190 | 2026-09-01 | fix(media): open workspace media links in the file viewer | +| ef04cfe0b | 2026-09-01 | fix(mobile): preserve UNC hosts in markdown file links | +| d9368266e | 2026-09-01 | fix(server): describe media file failures | + +Files span `apps/server/src/assets/*`, `apps/server/src/http.ts`, `apps/web/src/components/{ChatMarkdown,ChatView,chat/*,files/*,media/*}`, `apps/mobile/src/{components,features/files,lib}/*`, `apps/mobile/modules/t3-markdown-text/*`, `packages/{client-runtime,contracts,shared}/src/*`, `docs/internals/environment-auth.md`, `docs/user/composer.md`, `pnpm-lock.yaml`. + +Cherry: all `+`. Upstream: PR #9023 (juliusmarminge, head `pingdotgg:t3code/explain-chat-media-paths`) MERGED 2026-09-01 as `beae2147a` (82 files, +4144/-682 vs snapshot 68 files, +2992/-492). The fork branch is an earlier revision; residual vs squash is 19 files of later upstream refinement (`ChatMarkdown.tsx`, `ChatView.tsx`, `ExpandedImageDialog.tsx`, `FilePreviewPanel.tsx`, …). Two snapshot files no longer exist on main (`ExpandedImageDialog.test.tsx`, `media/OpenOriginalMediaLink.tsx`) — removed by upstream before or after merge. 24 later upstream commits on touched paths (#9140, #9143, #9119, #9146, …). + +Merge-tree: 25 conflicting files (mix of content and add/add), including `AssetAccess.ts`, `ChatMarkdown.tsx`, `FilePreviewPanel.tsx`, `packages/contracts/src/assets.ts`, `packages/shared/src/filePreview.ts`, all new mobile media components. + +Recommendation: **drop** (superseded by merged #9023). + +## t3code/audit-app-project-command + +Merge-base: `e4f7b14fa` (2026-08-30, chore: add Windows setup script to t3.json #8814). Author: Theo Browne. + +Commits ahead (4), 26 files touched: + +| sha | date | subject | +| --------- | ---------- | ---------------------------------------------------------- | +| f5f18e277 | 2026-08-30 | feat(cli): open projects in the running desktop app | +| a4a86ca90 | 2026-08-30 | fix(cli): handle desktop discovery and renderer reconnects | +| 750530abc | 2026-08-30 | fix(cli): preserve structured desktop app failures | +| 956227883 | 2026-08-31 | test(cli): cover unsupported desktop app platforms | + +Files: `apps/desktop/src/app/DesktopApp*.ts` (activation + broker), `apps/desktop/src/ipc/*`, `main.ts`, `preload.ts`, `apps/server/src/bin.ts`, `apps/server/src/cli/app.ts` (+test), `apps/web/src/desktopAppActivation.ts` (+test), `components/desktop/DesktopAppActivationCoordinator.tsx`, `hooks/useHandleNewThread.ts`, `routes/__root.tsx`, `state/entities.ts`, `docs/user/install.md`, `packages/contracts/src/desktopAppActivation.ts`, `index.ts`, `ipc.ts`, `packages/shared/src/desktopAppControl.ts` (+test), `packages/shared/package.json`. + +Cherry: all `+`. Upstream: PR #8824 (t3dotgg, head `pingdotgg:t3code/audit-app-project-command`) MERGED 2026-09-01 as `04efa7907` (26 files, +1890/-3 — same shape as the snapshot). Residual vs squash: 4 files (`preload.ts`, `useHandleNewThread.ts`, `ipc.ts`, `packages/shared/package.json`), i.e. the upstream branch's final tidy-up. 12 later upstream commits on touched paths. + +Merge-tree: **clean** (rc=0) — but merging it would re-introduce an older revision of already-merged code. + +Recommendation: **drop** (superseded by merged #8824). + +## t3code/audit-effect-usage + +Merge-base: `4e00471d1` (2026-08-22, fix(server): stop completed Codex threads from staying stuck on working #7937). Author: Theo Browne. + +Commits ahead (2), 51 files touched: + +| sha | date | subject | +| --------- | ---------- | ------------------------------------------------------------------ | +| b5ff7b95a | 2026-08-22 | fix(effect): prevent data loss, provider hangs, and runtime stalls | +| 7a49fc9b5 | 2026-08-22 | fix(effect): address provider and worker review findings | + +Files: broad sweep across `apps/server/src/orchestration/Layers/*` (engine, projection pipeline, reactors, ingestion), `apps/server/src/persistence/*` (Sqlite, NodeSqliteClient, ProjectionState), `apps/server/src/provider/Layers/{CodexSessionRuntime,OpenCodeAdapter}.ts`, `relay/AgentAwarenessRelay.ts`, `serverActivation.ts`, `packages/effect-acp/*`, `packages/effect-codex-app-server/*`, `packages/shared/src/{DrainableWorker,KeyedCoalescingWorker}.ts`, `packages/contracts/src/{baseSchemas,orchestration}.ts`, plus desktop/mobile/web touches. + +Cherry: all `+`. Upstream: the `pingdotgg:t3code/audit-effect-usage` head was used for PR #7941 (t3dotgg, "fix(server): keep attachments until the command commits") MERGED 2026-09-02 as `716069f40` — a trimmed 13-file, +368/-38 subset (orchestration engine/pipeline/reactor + startup reconcile tests + `docs/internals/overview.md`). Its commits (`ceae5b6ee`, `1f8ac9b50`) are not on the fork snapshot, so the snapshot predates the rework. The remaining areas were heavily reworked upstream independently: 40 later upstream commits on touched paths, including #8992 (bound replay payloads), #9152 (incremental projection), #8187 (idle CPU / provider event leaks), #9005 (OpenCode child sessions), #8480 (OpenCode approvals), #8605 (Codex input buffering), #7538 (replay un-applied events), #5195 (stale Codex approvals). `packages/contracts/src/baseSchemas.test.ts` no longer exists on main. + +Merge-tree: 11 conflicting files (`OrchestrationEngine.ts` + test, `ProjectionPipeline.ts` (Layers + Services), `ProviderCommandReactor.ts`, `ThreadDeletionReactor.ts`, `OpenCodeAdapter.ts` + test, `effect-acp/protocol.ts`, `effect-codex-app-server/protocol.ts` + test). + +Recommendation: **drop**. This is a maintainer's own audit draft; what upstream wanted from it shipped as #7941 and the rest was superseded by two weeks of targeted fixes on the same code. Not the fork's work and not worth resurrecting. + +## t3code/remote-mac-app-updates + +Merge-base: `5304f3e9d` (2026-08-14, chore(mobile): bump app version to 1.0.4). Author: Theo Browne. + +Commits ahead (5), 31 files touched: + +| sha | date | subject | +| --------- | ---------- | ------------------------------------------------------------------------------------------- | +| 7d233d93a | 2026-08-13 | feat(desktop): update the desktop app on remote Macs from the Update button | +| f6022f33d | 2026-08-13 | review: catchTags convention, atomic subscribe snapshot, release in-flight guard on install | +| efb25b159 | 2026-08-13 | review: install only from downloaded status, report install failures via errorContext | +| 4b35ac856 | 2026-08-13 | review: re-check stale up-to-date and error states on remote requests | +| 159021e95 | 2026-08-13 | review: detect install failure by state transition, not lingering errorContext | + +Files: `apps/desktop/src/updates/{DesktopRemoteUpdates,DesktopUpdates,remoteUpdateFlow,updatesTestHarness}.ts` (+tests), `apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts`, `apps/desktop/src/app/DesktopApp.ts`, `apps/server/src/desktopUpdate/DesktopAppUpdate.ts` (+test), `apps/server/src/cloud/selfUpdate.ts`, `environment/ServerEnvironment.ts`, `resourceTelemetry/DesktopTelemetryReceiver.ts`, `server.ts`, `apps/web/src/components/{ChatView,ServerUpdateAction}.tsx`, `settings/ConnectionsSettings.tsx`, `versionSkew.ts`, `packages/contracts/src/{environment,resourceTelemetry}.ts`, `docs/internals/server-updates.md`, `docs/user/updating.md`. + +Cherry: all `+`. Upstream: PR #6554 (t3dotgg, head `pingdotgg:t3code/remote-mac-app-updates`) MERGED 2026-09-02 as `b2f25d390` (41 files, +3596/-344 vs snapshot 31 files, +1757/-214). The snapshot is from 2026-08-13; the upstream branch was rebased and roughly doubled before merge. 81 later upstream commits on touched paths (`ChatView.tsx` alone drifted by ~1.6k lines). + +Merge-tree: 21 conflicting files, including add/add on every new `updates/*` and `desktopUpdate/*` file. + +Recommendation: **drop** (superseded by merged #6554). + +## t3code/review-high-priority-feedback + +Merge-base: `41ebf22ee` (2026-08-04, fix(web): clear main branch lint warnings #5384). Author: Theo Browne. + +Commits ahead (10), 42 files touched: + +| sha | date | subject | +| --------- | ---------- | ------------------------------------------------------- | +| 4e4cb0726 | 2026-08-04 | fix: harden remote server lifecycle | +| e71933771 | 2026-08-04 | fix(server): preserve startup reconciliation safeguards | +| d2cb3d016 | 2026-08-04 | fix(client): refresh server metadata on reconnect | +| 34e6f78fe | 2026-08-04 | fix(client): recover when cached metadata is stale | +| 9f812dd83 | 2026-08-04 | fix: address remote lifecycle review feedback | +| 33ce2d286 | 2026-08-04 | fix(relay): make endpoint lock transaction-scoped | +| f2fd69d8d | 2026-08-04 | fix(relay): coordinate endpoint lifecycle with leases | +| d58dba29c | 2026-08-04 | fix(web): point compatibility recovery at older side | +| 28966761e | 2026-08-04 | fix: address latest review feedback | +| 21ef91a08 | 2026-08-04 | fix: address post-rebase review feedback | + +Files: `apps/server/src/cli/{pair,service}.ts` (+tests), `cloud/{bootService,http,serviceProtocol}.ts`, `server.ts`, `serverRuntimeStartup.ts`, `startupAccess.ts`, `apps/web/src/connection/platform.ts`, `versionSkew.ts`, `components/ChatView.tsx`, `infra/relay/**` (new Postgres migration `20260803232218_add_relay_environment_lifecycle_leases`, `db.ts`, `EnvironmentLinker.ts`, `ManagedEndpointProvider.ts`, `http/Api.ts`, `persistence/schema.ts`), `packages/client-runtime/src/{authorization,connection,rpc}/*`, `packages/contracts/src/relay.ts`, `packages/tailscale/src/tailscale.ts`, `docs/user/background-service.md`. + +Cherry: all `+`. Upstream: the `pingdotgg:t3code/review-high-priority-feedback` head was reused for PR #5302 (t3dotgg, "fix(server): prevent accidental service downgrades") MERGED 2026-09-02 as `0e77fbd3d` — a single-commit, 6-file, +482/-71 change. The 42-file "harden remote server lifecycle" work on this snapshot was abandoned in favour of that narrow fix; the relay lease migration and `infra/relay/src/db.test.ts` never landed on main (missing there). 117 later upstream commits on touched paths, including #9178 (refresh relay credentials before expiry), #8085 (isolate remote web session cookies), #8367 (halve server config bootstrap traffic) — the areas this branch touched have all been rewritten since. + +Merge-tree: 16 conflicting files (`cli/pair.test.ts`, `cli/service.ts` + test, `cloud/bootService.ts`, `cloud/http.ts` + test, `server.ts`, `serverRuntimeStartup.ts`, `ChatView.tsx`, `versionSkew.ts` + test, `docs/user/background-service.md`, `infra/relay/src/http/Api.test.ts`, `client-runtime/authorization/layer.test.ts`, `rpc/session.ts` + test). + +Recommendation: **drop** (superseded; narrowed to merged #5302, the rest is a month stale against rewritten code). + +--- + +## Archive log + +Tags were created as `archive/` on `origin/` heads, pushed with `git push origin 'refs/tags/archive/*'`, verified via `git ls-remote --tags origin 'archive/*'`, and only then were the remote branches deleted. + +| tag | commit | pushed | verified on origin | branch deleted | +| -------------------------------------------- | --------- | ------ | ------------------ | -------------- | +| archive/feat-claude-fable-5-1 | 267b0905a | yes | yes | yes | +| archive/fix-desktop-build-prerequisites | 98d04853a | yes | yes | yes | +| archive/t3code-add-quit-double-click-mode | cd5bcf049 | yes | yes | yes | +| archive/t3code-fix-mobile-thread-scrolling | eb9067349 | yes | yes | yes | +| archive/t3code-explain-chat-media-paths | d9368266e | yes | yes | yes | +| archive/t3code-audit-app-project-command | 956227883 | yes | yes | yes | +| archive/t3code-audit-effect-usage | 7a49fc9b5 | yes | yes | yes | +| archive/t3code-remote-mac-app-updates | 159021e95 | yes | yes | yes | +| archive/t3code-review-high-priority-feedback | 21ef91a08 | yes | yes | yes | + +No failures. After deletion `origin` still carries 933 other heads, almost all of them mirrored upstream work branches of the same kind (e.g. `codex/*`, `t3code/*`); they were out of scope for this pass but are the same class of stale snapshot and are candidates for a follow-up sweep. diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 44c7627a4daf..f090c70ee22b 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -11,17 +11,10 @@ import { RelayPublicDomainLabelTooLongError, relayPublicDomainForStage, relayResourceNameForStage, - relayStageSlug, } from "./deploymentConfig.ts"; const isRelayPublicDomainLabelTooLongError = Schema.is(RelayPublicDomainLabelTooLongError); -describe("relayStageSlug", () => { - it("matches Alchemy physical-name sanitization for default developer stages", () => { - expect(relayStageSlug("dev_julius")).toBe("dev-julius"); - }); -}); - describe("relayPublicDomainForStage", () => { it("uses the canonical relay hostname for production", () => { expect(relayPublicDomainForStage("prod", ".example.com.")).toBe("relay.example.com"); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index fe9d37b29988..565e16422dd2 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -56,7 +56,7 @@ function appendDnsSafeSuffix(prefix: string, suffix: string): string { * Alchemy's physical-name helper sanitizes resource names after adding the * stage. Keep custom domains and runtime-created resources aligned with it. */ -export function relayStageSlug(stage: string): string { +function relayStageSlug(stage: string): string { return stage .toLowerCase() .replaceAll(/[^a-z0-9-]/g, "-") diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 000000000000..c6ba319da5b1 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,84 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + // Exported types are part of our contracts even before they have consumers. + "rules": { "types": "off", "nsTypes": "off" }, + // These executables are supplied by the OS or installed separately from npm. + "ignoreBinaries": ["eas", "file", "mkfifo", "pkill", "pkg-config", "plutil", "sips"], + "workspaces": { + ".": { + // Keep vendored reference repositories outside the root project. + "project": ["*.{js,mjs,cjs,ts,mts,cts}", ".github/**/*.cjs"], + "entry": [".github/scripts/thread-transfer-report.cjs"], + "vite": { "entry": [".github/**/*.test.cjs"] }, + "vitest": { "entry": [".github/**/*.test.cjs"] }, + }, + "scripts": { + // Knip loads its preprocessor through a CLI option, not a source import. + "entry": ["knip-schemas.ts"], + }, + "apps/server": { + // Vite+ pack entries and the launcher used by installed background services. + "entry": [ + "src/bin.ts!", + "src/service-launcher.ts!", + "scripts/cli.ts", + "src/provider/testFixtures/*.mjs", + ], + // Native msgpack acceleration and the Vite+ web build prerequisite. + "ignoreDependencies": ["msgpackr-extract", "@t3tools/web"], + }, + "apps/desktop": { + // Electron loads these bundles by filename rather than importing them. + "entry": [ + "src/main.ts!", + "src/preload.ts!", + "src/preview-pick-preload.ts!", + "src/preview-pip-preload.ts!", + "src/preview/Annotation.css!", + ], + // The injected runtime resolves Playwright by string; release tooling runs + // electron-builder from this workspace, outside its package scripts. + "ignoreDependencies": ["playwright-core", "electron-builder"], + }, + "apps/web": { + // Worktree setup invokes this directly from t3.json. + "entry": ["scripts/warm-dep-cache.ts"], + // UI component modules are copied and adapted as cohesive sets. Keep their + // named subcomponents even before they have callers; the file audit still + // reports an entire component module when nothing imports it. + "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, + }, + "apps/mobile": { + // Expo loads local config plugins by string; Metro handles platform variants. + "entry": ["index.ts!", "plugins/*.cjs"], + // Fonts are configured as asset paths; Expo autolinks the native packages. + "ignoreDependencies": [ + "@expo-google-fonts/dm-sans", + "@t3tools/mobile-review-diff-native", + "@t3tools/mobile-terminal-native", + ], + }, + "apps/mobile/modules/t3-markdown-text": { + // React Native codegen discovers component specifications by filename. + "entry": ["src/*NativeComponent.ts!"], + }, + "infra/relay": { + "entry": ["alchemy.run.ts!", "src/persistence/schema.ts!"], + }, + "packages/*": { + // These workspace packages are private to this monorepo. + "includeEntryExports": true, + }, + "packages/effect-acp": { + "includeEntryExports": true, + "entry": ["test/fixtures/*.ts"], + // Generated upstream protocol definitions are retained as a complete set. + "ignoreIssues": { "src/_generated/**": ["exports", "types"] }, + }, + "packages/effect-codex-app-server": { + "includeEntryExports": true, + "entry": ["test/fixtures/*.ts"], + "ignoreIssues": { "src/_generated/**": ["exports", "types"] }, + }, + }, +} diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index 78ba5a271cd3..6137544ea0e2 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -8,7 +8,7 @@ use sysinfo::{ MINIMUM_CPU_UPDATE_INTERVAL, Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind, }; -const PROTOCOL_VERSION: u32 = 2; +const PROTOCOL_VERSION: u32 = 3; const MIN_SAMPLE_INTERVAL_MS: u64 = 250; const MAX_SAMPLE_INTERVAL_MS: u64 = 60_000; const PROCESS_START_TIME_PRECISION_MS: u64 = 1_000; @@ -66,6 +66,10 @@ enum Command { version: u32, request_id: String, }, + ProcessTable { + version: u32, + request_id: String, + }, ReadHistory { version: u32, request_id: String, @@ -84,6 +88,7 @@ impl Command { | Self::SetSampleInterval { version, .. } | Self::SetStreaming { version, .. } | Self::SampleNow { version, .. } + | Self::ProcessTable { version, .. } | Self::ReadHistory { version, .. } | Self::Shutdown { version } => *version, } @@ -146,6 +151,24 @@ struct ProcessSample { io_semantics: IoSemantics, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessTableEntry { + pid: u32, + ppid: u32, + name: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProcessTableEvent<'a> { + version: u32, + #[serde(rename = "type")] + event_type: &'static str, + request_id: &'a str, + processes: Vec, +} + impl ProcessSample { fn estimated_history_bytes(&self) -> usize { std::mem::size_of::() @@ -351,6 +374,41 @@ impl Collector { self.cpu_baseline_refreshed_at = Some(Instant::now()); } + fn process_table(&self) -> Vec { + // Use a dedicated System so this refresh cannot reset the CPU + // baseline tracked by self.system for snapshots. + let mut process_table_system = System::new(); + process_table_system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing().without_tasks(), + ); + let mut processes = process_table_system + .processes() + .iter() + .filter_map(|(pid, process)| { + let pid = pid.as_u32(); + // Pid 0 is the kernel idle process on some platforms. The + // processTable contract requires positive pids, and one zero + // would fail the whole event decode on the server, so drop it + // here. It can never be a terminal descendant. + if pid == 0 { + return None; + } + Some(ProcessTableEntry { + pid, + ppid: process.parent().map(Pid::as_u32).unwrap_or(0), + name: truncate_utf8( + process.name().to_string_lossy().into_owned(), + MAX_PROCESS_NAME_BYTES, + ), + }) + }) + .collect::>(); + processes.sort_by_key(|process| process.pid); + processes + } + fn sample(&mut self, config: &CollectorConfig, request_id: Option) -> SnapshotEvent { if let Some(delay) = remaining_cpu_measurement_delay(self.cpu_baseline_refreshed_at.take(), Instant::now()) @@ -871,6 +929,15 @@ fn main() -> io::Result<()> { )?; } } + Command::ProcessTable { request_id, .. } => { + let event = ProcessTableEvent { + version: PROTOCOL_VERSION, + event_type: "processTable", + request_id: &request_id, + processes: collector.process_table(), + }; + write_event(&mut writer, &event)?; + } Command::ReadHistory { request_id, window_ms, @@ -981,7 +1048,7 @@ mod tests { #[test] fn decodes_protocol_commands() { let configure = serde_json::from_str::( - r#"{"version":2,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#, + r#"{"version":3,"type":"configure","rootPid":42,"sampleIntervalMs":1000,"externalProcesses":[{"pid":7}]}"#, ) .expect("configure command"); @@ -1001,7 +1068,7 @@ mod tests { } let read_history = serde_json::from_str::( - r#"{"version":2,"type":"readHistory","requestId":"history-1","windowMs":60000}"#, + r#"{"version":3,"type":"readHistory","requestId":"history-1","windowMs":60000}"#, ) .expect("read history command"); assert!(matches!( @@ -1012,6 +1079,15 @@ mod tests { .. } if request_id == "history-1" )); + + let process_table = serde_json::from_str::( + r#"{"version":3,"type":"processTable","requestId":"processes-1"}"#, + ) + .expect("process table command"); + assert!(matches!( + process_table, + Command::ProcessTable { request_id, .. } if request_id == "processes-1" + )); } #[test] diff --git a/package.json b/package.json index 46913a435a8a..4e5aca36d135 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "typecheck": "vp run -r --concurrency-limit 2 typecheck", "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", + "knip": "knip --preprocessor ./scripts/knip-schemas.ts", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", @@ -44,11 +47,10 @@ "sync:repos": "node scripts/sync-reference-repos.ts" }, "devDependencies": { - "@babel/plugin-transform-react-jsx": "7.28.6", "@effect/tsgo": "catalog:", - "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "knip": "6.34.0", "vite-plus": "catalog:" }, "engines": { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..54acd80bff44 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,18 @@ "private": true, "type": "module", "exports": { + "./load-balancing": { + "types": "./src/load-balancing.ts", + "default": "./src/load-balancing.ts" + }, + "./project-favicon-cache": { + "types": "./src/projectFaviconCache.ts", + "default": "./src/projectFaviconCache.ts" + }, + "./pending-requests": { + "types": "./src/pendingRequests.ts", + "default": "./src/pendingRequests.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" @@ -47,6 +59,10 @@ "types": "./src/codexMarkdownDirectives.ts", "default": "./src/codexMarkdownDirectives.ts" }, + "./fork": { + "types": "./src/fork/index.ts", + "default": "./src/fork/index.ts" + }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" @@ -147,6 +163,10 @@ "types": "./src/state/runtime.ts", "default": "./src/state/runtime.ts" }, + "./state/usage": { + "types": "./src/state/usage.ts", + "default": "./src/state/usage.ts" + }, "./state/server": { "types": "./src/state/server.ts", "default": "./src/state/server.ts" @@ -217,6 +237,7 @@ "test": "vp test run" }, "dependencies": { + "@q1code/core": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:", diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts index d232aea0d30a..05acbc1829e7 100644 --- a/packages/client-runtime/src/authorization/index.ts +++ b/packages/client-runtime/src/authorization/index.ts @@ -2,6 +2,5 @@ export * from "./remote.ts"; export { type AuthorizedRemoteEnvironment, type AuthorizedRemoteHttpEnvironment, - RemoteEnvironmentAuthorization, } from "./service.ts"; export * as TokenStore from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 398a592e499d..538c0aa114a3 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -16,7 +16,6 @@ import { } from "../rpc/http.ts"; export { - RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthInvalidJsonError, RemoteEnvironmentAuthTimeoutError, RemoteEnvironmentAuthUndeclaredStatusError, diff --git a/packages/client-runtime/src/codexArtifactTemplates.ts b/packages/client-runtime/src/codexArtifactTemplates.ts index ad46a7d6c2b0..052a60f11d58 100644 --- a/packages/client-runtime/src/codexArtifactTemplates.ts +++ b/packages/client-runtime/src/codexArtifactTemplates.ts @@ -1,4 +1,4 @@ -export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ +const CODEX_ARTIFACT_TEMPLATE_KINDS = [ "document", "presentation", "spreadsheet", @@ -13,7 +13,7 @@ export const CODEX_ARTIFACT_TEMPLATE_KINDS = [ export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; -export const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; +const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; export type CodexArtifactTemplateGalleryKind = (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; @@ -26,7 +26,7 @@ export interface CodexArtifactTemplate { readonly skillName: string; } -export const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { +const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = { document: "Document template", presentation: "Presentation template", spreadsheet: "Spreadsheet template", diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts index 6b40680ce35c..9bf9dbdb2409 100644 --- a/packages/client-runtime/src/connection/connectivity.ts +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -13,7 +13,7 @@ export class Connectivity extends Context.Service< } >()("@t3tools/client-runtime/connection/connectivity") {} -export const make = (service: Connectivity["Service"]) => Connectivity.of(service); +const make = (service: Connectivity["Service"]) => Connectivity.of(service); export const layer = (service: Connectivity["Service"]) => Layer.succeed(Connectivity, make(service)); diff --git a/packages/client-runtime/src/connection/credentialStore.ts b/packages/client-runtime/src/connection/credentialStore.ts index 0107bc91fb10..a25970a15f0f 100644 --- a/packages/client-runtime/src/connection/credentialStore.ts +++ b/packages/client-runtime/src/connection/credentialStore.ts @@ -1,6 +1,5 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import type { ConnectionCredential } from "./catalog.ts"; @@ -22,6 +21,3 @@ export class ConnectionCredentialStore extends Context.Service< export const make = (service: ConnectionCredentialStore["Service"]) => ConnectionCredentialStore.of(service); - -export const layer = (service: ConnectionCredentialStore["Service"]) => - Layer.succeed(ConnectionCredentialStore, make(service)); diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index f29f913dd548..ceec663c1bb5 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -36,6 +36,7 @@ export class ConnectionDriver extends Context.Service< } >()("@t3tools/client-runtime/connection/driver/ConnectionDriver") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const resolver = yield* ConnectionResolver.ConnectionResolver; const sessions = yield* RpcSession.RpcSessionFactory; diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 53a041bbf307..127a6288fa1b 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -1,11 +1,7 @@ export * from "./catalog.ts"; export * as Connectivity from "./connectivity.ts"; export * as CredentialStore from "./credentialStore.ts"; -export { - ConnectionDriver, - type ConnectionDriverProgress, - type EnvironmentConnectionLease, -} from "./driver.ts"; +export { type ConnectionDriverProgress, type EnvironmentConnectionLease } from "./driver.ts"; export * from "./errors.ts"; export * as Connection from "./layer.ts"; export * from "./model.ts"; @@ -14,12 +10,6 @@ export { ConnectionOnboarding, type PairingConnectionInput, type SshConnectionInput, - prepareBearerConnectionUpdate, - preparePairingRegistration, - prepareSshRegistration, - registerPairingConnection, - registerSshConnection, - updateBearerConnection, } from "./onboarding.ts"; export * from "./presentation.ts"; export * as ProfileStore from "./profileStore.ts"; @@ -28,6 +18,5 @@ export { EnvironmentRegistry, PlatformEnvironmentRemovalError, } from "./registry.ts"; -export { ConnectionResolver } from "./resolver.ts"; export { EnvironmentSupervisor, type EnvironmentSupervisorOptions } from "./supervisor.ts"; export * as Wakeups from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..3bc0e56dca82 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -118,7 +118,7 @@ export const preparePairingRegistration = Effect.fn( }); }); -export const registerPairingConnection = Effect.fn( +const registerPairingConnection = Effect.fn( "clientRuntime.connection.onboarding.registerPairingConnection", )(function* (input: PairingConnectionInput) { const registration = yield* preparePairingRegistration(input); @@ -130,7 +130,7 @@ export const registerPairingConnection = Effect.fn( const isBearerCredential = Schema.is(BearerConnectionCredential); const isBearerProfile = Schema.is(BearerConnectionProfile); -export const updateBearerConnection = Effect.fn( +const updateBearerConnection = Effect.fn( "clientRuntime.connection.onboarding.updateBearerConnection", )(function* (input: BearerConnectionUpdateInput) { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; @@ -233,7 +233,7 @@ export const prepareSshRegistration = Effect.fn( }); }); -export const registerSshConnection = Effect.fn( +const registerSshConnection = Effect.fn( "clientRuntime.connection.onboarding.registerSshConnection", )(function* (input: SshConnectionInput) { const registration = yield* prepareSshRegistration(input); @@ -242,6 +242,7 @@ export const registerSshConnection = Effect.fn( return registration.target.environmentId; }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; const presentation = yield* ClientCapabilities.ClientPresentation; diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e13638a2b41f..80ce8a374a9c 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -10,7 +10,6 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, - connectionPhaseMessage, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -119,10 +118,6 @@ describe("connection presentation", () => { }); }); - it("gives offline status precedence in global messaging", () => { - expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); - }); - it("combines reconnect progress with the latest failure", () => { const connection = { phase: "reconnecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 168443deceb4..4093167d333c 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,7 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; +import type { SupervisorConnectionState } from "./model.ts"; export type EnvironmentConnectionPhase = | "available" @@ -105,25 +105,3 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri : null; } } - -export function connectionPhaseMessage( - phase: EnvironmentConnectionPhase, - label: string, - networkStatus: NetworkStatus, -): string { - if (networkStatus === "offline" || phase === "offline") { - return "You are offline"; - } - switch (phase) { - case "available": - return "Available"; - case "connecting": - return `Connecting to ${label}...`; - case "reconnecting": - return `Reconnecting to ${label}...`; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} diff --git a/packages/client-runtime/src/connection/profileStore.ts b/packages/client-runtime/src/connection/profileStore.ts index 3432a7fe16e2..bc8d08f25f7d 100644 --- a/packages/client-runtime/src/connection/profileStore.ts +++ b/packages/client-runtime/src/connection/profileStore.ts @@ -1,6 +1,5 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import type * as Option from "effect/Option"; import type { ConnectionProfile } from "./catalog.ts"; @@ -19,6 +18,3 @@ export class ConnectionProfileStore extends Context.Service< export const make = (service: ConnectionProfileStore["Service"]) => ConnectionProfileStore.of(service); - -export const layer = (service: ConnectionProfileStore["Service"]) => - Layer.succeed(ConnectionProfileStore, make(service)); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 6907c43d6037..563afe62ce5b 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -125,6 +125,7 @@ interface EnvironmentServiceScope { readonly scope: Scope.Closeable; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registryScope = yield* Scope.Scope; const storage = yield* Persistence.ConnectionTargetStore; diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index cbffca7b29e9..885529249a62 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -218,6 +218,7 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const primary = yield* makePrimaryBroker(); const bearer = yield* makeBearerBroker(); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 0246a21397f4..45ef02292056 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -5,7 +5,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -798,14 +797,3 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( retryNow, }); }); - -export const layer = ( - entry: ConnectionCatalogEntry, - options?: EnvironmentSupervisorOptions, -): Layer.Layer< - EnvironmentSupervisor, - never, - | Connectivity.Connectivity - | ConnectionDriver.ConnectionDriver - | ConnectionWakeups.ConnectionWakeups -> => Layer.effect(EnvironmentSupervisor, make(entry, options)); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 8573a49c1474..721b941645c0 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -27,7 +27,7 @@ export class ConnectionWakeups extends Context.Service< } >()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} -export const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); +const make = (service: ConnectionWakeups["Service"]) => ConnectionWakeups.of(service); export const layer = (service: ConnectionWakeups["Service"]) => Layer.succeed(ConnectionWakeups, make(service)); diff --git a/packages/client-runtime/src/environment/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts index 66bbb1df7e91..032be152fdbc 100644 --- a/packages/client-runtime/src/environment/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -6,7 +6,6 @@ import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, - scopedRefKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -40,8 +39,6 @@ describe("scoped refs", () => { const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); it("builds stable scoped project and thread keys", () => { - expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); - expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); }); diff --git a/packages/client-runtime/src/environment/scoped.ts b/packages/client-runtime/src/environment/scoped.ts index 354c548c02de..7894c7ba5329 100644 --- a/packages/client-runtime/src/environment/scoped.ts +++ b/packages/client-runtime/src/environment/scoped.ts @@ -22,7 +22,7 @@ export function scopeThreadRef( return { environmentId, threadId }; } -export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { +function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { const localId = "projectId" in ref ? ref.projectId : ref.threadId; return `${ref.environmentId}:${localId}`; } diff --git a/packages/client-runtime/src/fork/forkFlags.ts b/packages/client-runtime/src/fork/forkFlags.ts new file mode 100644 index 000000000000..8477aab4726b --- /dev/null +++ b/packages/client-runtime/src/fork/forkFlags.ts @@ -0,0 +1,14 @@ +/** + * Client-side read of the fork feature flags the server publishes through + * `ExecutionEnvironmentCapabilities.forkFlags`. Upstream servers omit the key, + * so every flag reads as its registry default against them. + */ +import { FORK_FLAGS, type ForkFlagKey } from "@q1code/core/flags"; +import type { ExecutionEnvironmentCapabilities } from "@t3tools/contracts"; + +export type { ForkFlagKey } from "@q1code/core/flags"; + +export const readForkFlag = ( + capabilities: Pick | null | undefined, + key: ForkFlagKey, +): boolean => capabilities?.forkFlags?.[key] ?? FORK_FLAGS[key].default; diff --git a/packages/client-runtime/src/fork/index.ts b/packages/client-runtime/src/fork/index.ts new file mode 100644 index 000000000000..9e00dbb92e8d --- /dev/null +++ b/packages/client-runtime/src/fork/index.ts @@ -0,0 +1,2 @@ +export * from "./forkFlags.ts"; +export * from "./prismClient.ts"; diff --git a/packages/client-runtime/src/fork/prismClient.test.ts b/packages/client-runtime/src/fork/prismClient.test.ts new file mode 100644 index 000000000000..2abbccee3612 --- /dev/null +++ b/packages/client-runtime/src/fork/prismClient.test.ts @@ -0,0 +1,270 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { ManagedRelayDpopSigner, type ManagedRelayDpopProofInput } from "../relay/managedRelay.ts"; +import { + PrimaryConnectionTarget, + RelayConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { + deletePrismAccount, + getPrismStatus, + listPrismAccounts, + patchPrismAccount, +} from "./prismClient.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test/base", + wsBaseUrl: "wss://environment.example.test", +}); + +const prepared = (httpAuthorization: PreparedConnection["httpAuthorization"]) => + ({ + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization, + target: TARGET, + }) satisfies PreparedConnection; + +const capture = (respond: (url: string, init: RequestInit) => Response) => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const fetchFn = ((request, init) => { + const url = String(request); + calls.push({ url, init: init ?? {} }); + return Promise.resolve(respond(url, init ?? {})); + }) satisfies typeof fetch; + return { calls, layer: remoteHttpClientLayer(fetchFn) }; +}; + +describe("prismClient", () => { + it.effect("reads status with the bearer credential and decodes the body", () => + Effect.gen(function* () { + const server = capture(() => + Response.json({ state: "ready", port: 8317, version: "7.2.147", role: "primary" }), + ); + const status = yield* getPrismStatus({ + prepared: prepared({ _tag: "Bearer", token: "token-1" }), + signer: Option.none(), + }).pipe(Effect.provide(server.layer)); + expect(status).toEqual({ state: "ready", port: 8317, version: "7.2.147", role: "primary" }); + expect(server.calls[0]?.url).toBe("https://environment.example.test/api/fork/prism/status"); + expect(new Headers(server.calls[0]?.init.headers).get("authorization")).toBe( + "Bearer token-1", + ); + }), + ); + + it.effect("maps the 503 into PrismUnavailableError with reason and state", () => + Effect.gen(function* () { + const server = capture(() => + Response.json( + { _tag: "PrismUnavailableError", reason: "sidecar-not-ready", state: "starting" }, + { status: 503 }, + ), + ); + const error = yield* listPrismAccounts({ + prepared: prepared(null), + signer: Option.none(), + }).pipe(Effect.provide(server.layer), Effect.flip); + expect(error._tag).toBe("PrismUnavailableError"); + expect(error._tag === "PrismUnavailableError" && error.reason).toBe("sidecar-not-ready"); + expect(error._tag === "PrismUnavailableError" && error.state).toBe("starting"); + // Primary/local connections send the session cookie. + expect(server.calls[0]?.init.credentials).toBe("include"); + }), + ); + + it.effect("encodes account ids into the path and sends JSON patches", () => + Effect.gen(function* () { + const server = capture((url) => + url.endsWith("/accounts/codex-a%40example.com.json") + ? Response.json({ + id: "codex-a@example.com.json", + provider: "codex", + label: "a@example.com", + disabled: true, + updatedAt: "2026-09-02T10:00:00.000Z", + }) + : Response.json({ _tag: "PrismNotFoundError", id: "x" }, { status: 404 }), + ); + const account = yield* patchPrismAccount({ + prepared: prepared(null), + signer: Option.none(), + id: "codex-a@example.com.json", + patch: { disabled: true }, + }).pipe(Effect.provide(server.layer)); + expect(account.disabled).toBe(true); + expect(server.calls[0]?.init.method).toBe("PATCH"); + const body = server.calls[0]?.init.body; + expect(typeof body === "string" ? body : new TextDecoder().decode(body as Uint8Array)).toBe( + '{"disabled":true}', + ); + + const missing = yield* deletePrismAccount({ + prepared: prepared(null), + signer: Option.none(), + id: "gone.json", + }).pipe(Effect.provide(server.layer), Effect.flip); + expect(missing._tag).toBe("PrismNotFoundError"); + }), + ); + + it.effect("passes environment auth errors through as their typed classes", () => + Effect.gen(function* () { + const server = capture(() => + Response.json( + { + _tag: "EnvironmentScopeRequiredError", + code: "insufficient_scope", + requiredScope: "access:write", + traceId: "trace-1", + }, + { status: 403 }, + ), + ); + const error = yield* deletePrismAccount({ + prepared: prepared(null), + signer: Option.none(), + id: "a.json", + }).pipe(Effect.provide(server.layer), Effect.flip); + expect(error._tag).toBe("EnvironmentScopeRequiredError"); + }), + ); +}); + +describe("Prism relay credential renewal", () => { + const relay = { + ...prepared({ _tag: "Dpop", accessToken: "stale-token", expiresAtEpochMs: 0 }), + target: new RelayConnectionTarget({ environmentId: TARGET.environmentId, label: TARGET.label }), + } satisfies PreparedConnection; + + const harness = (respond: (url: string, init: RequestInit) => Response) => { + const server = capture(respond); + const proofs: ManagedRelayDpopProofInput[] = []; + const rejected: (string | undefined)[] = []; + const authorization = RemoteEnvironmentAuthorization.of({ + authorizeBearer: () => Effect.die("Unexpected bearer preparation"), + authorizeDpop: () => Effect.die("HTTP renewal must not reconnect the socket"), + authorizeDpopHttp: (input) => + Effect.sync(() => { + rejected.push(input.rejectedAccessToken); + const renewed = input.rejectedAccessToken !== undefined; + return { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: renewed ? "https://renewed.example.test" : "https://current.example.test", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: renewed ? "renewed-token" : "current-token", + expiresAtEpochMs: 3_600_000, + }, + }; + }), + }); + const signer = ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("test-thumbprint"), + createProof: (input) => + Effect.sync(() => { + proofs.push(input); + return `proof-${proofs.length}`; + }), + }); + return { + ...server, + proofs, + rejected, + authorization, + input: { prepared: relay, signer: Option.some(signer) }, + }; + }; + const unauthorized = () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "test-trace", + }, + { status: 401 }, + ); + + it.effect("uses the runtime's current relay credential and origin without reconnecting", () => + Effect.gen(function* () { + const h = harness(() => Response.json({ state: "ready", port: 8317, role: "primary" })); + const result = yield* getPrismStatus(h.input).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + ); + expect(result.state).toBe("ready"); + expect(h.calls[0]?.url).toBe("https://current.example.test/api/fork/prism/status"); + expect(new Headers(h.calls[0]?.init.headers).get("authorization")).toBe("DPoP current-token"); + expect(h.proofs[0]).toMatchObject({ + url: h.calls[0]?.url, + method: "GET", + accessToken: "current-token", + }); + expect(h.rejected).toEqual([undefined]); + }), + ); + + it.effect( + "renews a rejected mutation once and signs its encoded path on the renewed origin", + () => + Effect.gen(function* () { + const h = harness((url) => + url.startsWith("https://current.") + ? unauthorized() + : Response.json({ + id: "codex-a@example.com.json", + provider: "codex", + label: "test account", + disabled: true, + updatedAt: "2026-09-05T00:00:00.000Z", + }), + ); + const result = yield* patchPrismAccount({ + ...h.input, + id: "codex-a@example.com.json", + patch: { disabled: true }, + }).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + ); + expect(result.disabled).toBe(true); + expect(h.rejected).toEqual([undefined, "current-token"]); + expect(h.calls.map((call) => call.url)).toEqual([ + "https://current.example.test/api/fork/prism/accounts/codex-a%40example.com.json", + "https://renewed.example.test/api/fork/prism/accounts/codex-a%40example.com.json", + ]); + expect(h.proofs.map((proof) => proof.url)).toEqual(h.calls.map((call) => call.url)); + expect(h.proofs.map((proof) => proof.method)).toEqual(["PATCH", "PATCH"]); + expect(new Headers(h.calls[1]?.init.headers).get("authorization")).toBe( + "DPoP renewed-token", + ); + expect(h.calls[1]?.init.body).toEqual(h.calls[0]?.init.body); + }), + ); + + it.effect("returns persistent authentication failure after one renewal", () => + Effect.gen(function* () { + const h = harness(unauthorized); + const error = yield* listPrismAccounts(h.input).pipe( + Effect.provideService(RemoteEnvironmentAuthorization, h.authorization), + Effect.provide(h.layer), + Effect.flip, + ); + expect(error._tag).toBe("EnvironmentAuthInvalidError"); + expect(h.calls).toHaveLength(2); + expect(h.rejected).toEqual([undefined, "current-token"]); + }), + ); +}); diff --git a/packages/client-runtime/src/fork/prismClient.ts b/packages/client-runtime/src/fork/prismClient.ts new file mode 100644 index 000000000000..641f0ebbee70 --- /dev/null +++ b/packages/client-runtime/src/fork/prismClient.ts @@ -0,0 +1,295 @@ +/** + * Typed client for the q1code accounts API (`/api/fork/prism/...`). This is + * the contract the Accounts UI codes against; no React here. + * + * Every function takes the same `prepared` connection the other environment + * HTTP helpers take (`PreparedConnection` from the connection supervisor) plus + * the optional DPoP `signer` for relay connections, and needs an + * `HttpClient.HttpClient` in context (`remoteHttpClientLayer(fetch)` or the + * app runtime's). Results are decoded `@q1code/core/prismApi` types. + * + * Failures are `PrismClientError`: + * - `PrismUnavailableError` (HTTP 503): flag off, sidecar not ready, or + * sync not configured. Read `reason` and `state`; the UI should show the + * sidecar state instead of an error toast. + * - `PrismUpstreamError` (502): the sidecar refused; `message` is its text. + * - `PrismNotFoundError` (404): unknown account or login session. + * - `PrismConfigError` (500): the sidecar took the change but `fork.json` + * could not be written, so it will not survive a restart. + * - `PrismSyncFailedError` (500): a sync export or push failed. + * - `EnvironmentAuthInvalidError` (401) / `EnvironmentScopeRequiredError` + * (403) and the transport errors every environment request can raise. + * + * Reads need `orchestration:read`; `patch`, `delete`, `startLogin`, + * `cancelLogin`, `completeLogin`, `setRouting`, and `restart` need + * `access:write`. `restartPrism` restarts the sidecar (or re-probes an + * external proxy) and answers with the status right after; poll + * `getPrismStatus` until `ready` or `failed`. With the flag off it is a 503. + * + * Login flow: `startPrismLogin` -> open `authUrl` (show `userCode` for + * device flows) -> poll `getPrismLoginStatus` until `completed` (then + * `accountId` names the new account) or `failed`. When the browser that + * finished the OAuth redirect is not on the server machine, the user pastes + * the redirect URL into `completePrismLogin`. `cancelPrismLogin` stops a + * pending flow. + */ +import { + type PrismAccount, + type PrismAccountId, + type PrismAccountPatch, + type PrismConfigError, + PrismHttpApi, + type PrismLoginProvider, + type PrismLoginStarted, + type PrismLoginStatus, + type PrismNotFoundError, + type PrismRouting, + type PrismStatus, + type PrismSyncFailedError, + type PrismSyncStatus, + type PrismUnavailableError, + type PrismUpstreamError, + type PrismUsage, +} from "@q1code/core/prismApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import type { HttpClient, HttpMethod } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "../state/environmentHttpAuth.ts"; + +export type { + PrismAccount, + PrismAccountId, + PrismAccountPatch, + PrismLoginProvider, + PrismLoginStarted, + PrismLoginStatus, + PrismRouting, + PrismStatus, + PrismSyncStatus, + PrismUsage, +} from "@q1code/core/prismApi"; + +export interface PrismClientInput { + readonly prepared: PreparedConnection; + /** Only needed for relay (DPoP) connections; pass `Option.none()` otherwise. */ + readonly signer: Option.Option; + readonly remoteAuthorization?: Option.Option; + readonly timeoutMs?: number; +} + +type DeclaredError = + | PrismUnavailableError + | PrismUpstreamError + | PrismNotFoundError + | PrismConfigError + | PrismSyncFailedError; + +export type PrismClientError = DeclaredError | RemoteEnvironmentRequestError; + +const DEFAULT_TIMEOUT_MS = 15_000; + +/** Errors the API declares, which the generated client decodes into instances and we pass through. */ +const DECLARED_TAGS = new Set([ + "PrismUnavailableError", + "PrismUpstreamError", + "PrismNotFoundError", + "PrismConfigError", + "PrismSyncFailedError", +]); + +const isDeclaredError = (error: unknown): error is DeclaredError => + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" && + DECLARED_TAGS.has(error._tag); + +type Api = HttpApiClient.ForApi; + +type Outcome = + | { readonly _tag: "ok"; readonly value: A } + | { readonly _tag: "declared"; readonly error: DeclaredError }; + +/** + * One authenticated call: build the request URL for the DPoP proof exactly the + * way the client will send it, attach credentials, run with the shared + * timeout/transport error mapping, and let the fork's own typed errors through. + */ +const call = ( + input: PrismClientInput, + method: HttpMethod.HttpMethod, + endpoint: string & keyof Api["prism"], + request: ( + client: Api, + headers: { readonly authorization?: string; readonly dpop?: string }, + ) => Effect.Effect, + params?: Record, +): Effect.Effect => + Effect.gen(function* () { + const remoteAuthorization = + input.remoteAuthorization ?? (yield* Effect.serviceOption(RemoteEnvironmentAuthorization)); + // Upstream may refresh the relay origin without replacing the prepared socket. + // Build the fork client from the same origin used for each request's proof. + let currentBaseUrl = input.prepared.httpBaseUrl; + const outcome = yield* executeAuthenticatedEnvironmentHttpRequest({ + ...input, + remoteAuthorization, + method, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + url: (httpBaseUrl) => { + const baseUrl = new URL(httpBaseUrl); + baseUrl.pathname = "/"; + baseUrl.search = ""; + baseUrl.hash = ""; + currentBaseUrl = baseUrl.toString(); + const urls = HttpApiClient.urlBuilder(PrismHttpApi, { baseUrl: currentBaseUrl }); + const buildUrl = ( + urls.prism as unknown as Record URL> + )[endpoint]!; + return String(buildUrl(params === undefined ? undefined : { params })); + }, + request: ({ headers }) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(PrismHttpApi, { baseUrl: currentBaseUrl }); + return yield* request(client, headers).pipe( + Effect.map((value): Outcome => ({ _tag: "ok", value })), + Effect.catch((error) => + isDeclaredError(error) + ? Effect.succeed>({ _tag: "declared", error }) + : Effect.fail(error), + ), + ); + }), + }); + if (outcome._tag === "ok") return outcome.value; + return yield* outcome.error; + }); + +export const getPrismStatus = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "GET", "status", (client, headers) => client.prism.status({ headers })); + +/** Turn the Limits-view publication of Prism's accounts on or off; answers with the status (`usageSource` reflects the new value). */ +export const setPrismUsageSource = ( + input: PrismClientInput & { readonly enabled: boolean }, +): Effect.Effect => + call(input, "PUT", "setUsageSource", (client, headers) => + client.prism.setUsageSource({ headers, payload: { enabled: input.enabled } }), + ); + +export const restartPrism = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "POST", "restart", (client, headers) => client.prism.restart({ headers })); + +export const listPrismAccounts = ( + input: PrismClientInput, +): Effect.Effect, PrismClientError, HttpClient.HttpClient> => + call(input, "GET", "listAccounts", (client, headers) => + client.prism.listAccounts({ headers }).pipe(Effect.map((result) => result.accounts)), + ); + +export const startPrismLogin = ( + input: PrismClientInput & { readonly provider: PrismLoginProvider }, +): Effect.Effect => + call(input, "POST", "startLogin", (client, headers) => + client.prism.startLogin({ headers, payload: { provider: input.provider } }), + ); + +export const getPrismLoginStatus = ( + input: PrismClientInput & { readonly sessionId: string }, +): Effect.Effect => + call( + input, + "GET", + "loginStatus", + (client, headers) => + client.prism.loginStatus({ headers, params: { sessionId: input.sessionId } }), + { sessionId: input.sessionId }, + ); + +/** For redirect flows finished in a browser off the server box: hand over the pasted redirect URL. */ +export const completePrismLogin = ( + input: PrismClientInput & { readonly sessionId: string; readonly redirectUrl: string }, +): Effect.Effect => + call( + input, + "POST", + "loginCallback", + (client, headers) => + client.prism.loginCallback({ + headers, + params: { sessionId: input.sessionId }, + payload: { redirectUrl: input.redirectUrl }, + }), + { sessionId: input.sessionId }, + ); + +export const cancelPrismLogin = ( + input: PrismClientInput & { readonly sessionId: string }, +): Effect.Effect => + call( + input, + "DELETE", + "cancelLogin", + (client, headers) => + client.prism.cancelLogin({ headers, params: { sessionId: input.sessionId } }), + { sessionId: input.sessionId }, + ); + +export const patchPrismAccount = ( + input: PrismClientInput & { + readonly id: PrismAccountId; + readonly patch: PrismAccountPatch; + }, +): Effect.Effect => + call( + input, + "PATCH", + "patchAccount", + (client, headers) => + client.prism.patchAccount({ headers, params: { id: input.id }, payload: input.patch }), + { id: input.id }, + ); + +export const deletePrismAccount = ( + input: PrismClientInput & { readonly id: PrismAccountId }, +): Effect.Effect => + call( + input, + "DELETE", + "deleteAccount", + (client, headers) => + client.prism.deleteAccount({ headers, params: { id: input.id } }).pipe(Effect.asVoid), + { id: input.id }, + ); + +export const getPrismRouting = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "GET", "getRouting", (client, headers) => client.prism.getRouting({ headers })); + +export const setPrismRouting = ( + input: PrismClientInput & { readonly strategy: PrismRoutingStrategy }, +): Effect.Effect => + call(input, "PUT", "setRouting", (client, headers) => + client.prism.setRouting({ headers, payload: { strategy: input.strategy } }), + ); + +export const getPrismUsage = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "GET", "getUsage", (client, headers) => client.prism.getUsage({ headers })); + +export const getPrismSyncStatus = ( + input: PrismClientInput, +): Effect.Effect => + call(input, "GET", "syncStatus", (client, headers) => client.prism.syncStatus({ headers })); diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts new file mode 100644 index 000000000000..0b938c6c090e --- /dev/null +++ b/packages/client-runtime/src/load-balancing.ts @@ -0,0 +1,40 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; + +/** Callers supply only connected machines hosting the project and selected provider. */ +export function chooseLoadBalancedEnvironment( + candidates: ReadonlyArray<{ + environmentId: string; + resources: HostResourcesSnapshot | null; + /** Client receipt time avoids comparing clocks on different machines. */ + receivedAt?: number; + weight: number; + }>, + now: number, +): string | null { + let selected: string | null = null; + let bestScore = 0; + for (const { environmentId, resources, receivedAt, weight } of candidates) { + const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; + if ( + !resources || + !Number.isFinite(weight) || + weight <= 0 || + now - sampledAt > 15_000 || + sampledAt > now + 5_000 || + resources.cpuUtilization === null || + resources.cpuUtilization >= 0.95 || + resources.totalMemoryBytes <= 0 || + resources.cpuCount <= 0 + ) { + continue; + } + const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; + if (memoryAvailable <= 0.05) continue; + const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; + if (score > bestScore) { + selected = environmentId; + bestScore = score; + } + } + return selected; +} diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 42cd7a35e473..4aea947b87d6 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, - isConventionalFilePosition, parseFileUrlHref, parseMarkdownFileLink, splitFilePathPosition, @@ -28,15 +27,6 @@ describe("inlineCodeFilePathCandidate", () => { }); }); -describe("isConventionalFilePosition", () => { - it("distinguishes extensionless file locations from labels and ports", () => { - expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true); - expect(isConventionalFilePosition("Makefile")).toBe(false); - expect(isConventionalFilePosition("TODO:12")).toBe(false); - expect(isConventionalFilePosition("port:3000")).toBe(false); - }); -}); - describe("parseFileUrlHref", () => { it.each([ ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 2e455655d005..29a337e49c42 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -15,7 +15,6 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -152,14 +151,6 @@ function looksLikeHostname(segment: string, hasPosition: boolean): boolean { return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } -/** Recognizes conventional extensionless filenames with an explicit line position. */ -export function isConventionalFilePosition(path: string): boolean { - return ( - BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) && - EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, "")) - ); -} - /** * Picks path-shaped inline code for the client's markdown file-link resolver. * It does not resolve paths or turn plain prose and fenced code into links. diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 36bc6a7b296f..5cc17586471d 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, + reorderActiveThread, settleThread, stopThreadSession, unsettleThread, @@ -172,4 +173,24 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("sends an active order key without changing activity timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + yield* reorderActiveThread({ + commandId: CommandId.make("reorder-command"), + threadId: ThreadId.make("thread-1"), + orderKey: "mf", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(dispatched).toEqual([ + { + type: "thread.active.reorder", + commandId: "reorder-command", + threadId: "thread-1", + orderKey: "mf", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..9bf75c838a99 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; +export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf }); }); +export const reorderActiveThread: (input: ReorderActiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderActiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.active.reorder", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/pendingRequests.test.ts b/packages/client-runtime/src/pendingRequests.test.ts new file mode 100644 index 000000000000..6ef4579044c2 --- /dev/null +++ b/packages/client-runtime/src/pendingRequests.test.ts @@ -0,0 +1,497 @@ +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { derivePendingRequests } from "./pendingRequests.ts"; + +let nextActivityId = 0; + +function makeActivity(overrides: { + id?: string; + createdAt?: string; + kind?: string; + summary?: string; + tone?: OrchestrationThreadActivity["tone"]; + payload?: Record; + turnId?: string; + sequence?: number; +}): OrchestrationThreadActivity { + return { + id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), + createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", + kind: overrides.kind ?? "tool.started", + summary: overrides.summary ?? "Tool call", + tone: overrides.tone ?? "tool", + payload: overrides.payload ?? {}, + turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null, + ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), + }; +} + +describe("pending approvals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + kind: "approval.requested", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingRequests([requested]).approvals).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + kind: "approval.requested", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingRequests([activity]).approvals).toEqual([]); + }, + ); + + it("tracks open approvals and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-1", + requestKind: "command", + detail: "bun run lint", + }, + }), + makeActivity({ + id: "approval-close", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "approval.resolved", + summary: "Approval resolved", + tone: "info", + payload: { requestId: "req-2" }, + }), + makeActivity({ + id: "approval-closed-request", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "approval.requested", + summary: "File-change approval requested", + tone: "approval", + payload: { requestId: "req-2", requestType: "unknown" }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-1", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "bun run lint", + }, + ]); + }); + + it("maps canonical requestType payloads into pending approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-request-type", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-request-type", + requestType: "command_execution_approval", + detail: "pwd", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-request-type", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "pwd", + }, + ]); + }); + + it("keeps app access approvals and persistence choices from remote activities", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ]; + const activities = [ + makeActivity({ + kind: "approval.requested", + summary: "App access approval requested", + tone: "approval", + payload: { + requestId: "req-safari", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-safari", + requestKind: "mcp-elicitation", + createdAt: "2026-02-23T00:00:00.000Z", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + ]); + }); + + it("derives dynamic tool requests as actionable generic approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-dynamic-tool", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + payload: { + requestId: "req-dynamic-tool", + requestType: "dynamic_tool_call", + detail: "Search the web", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-dynamic-tool", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "Search the web", + }, + ]); + }); + + it("clears stale pending approvals when provider reports unknown pending request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-stale-1", + requestType: "unknown", + }, + }), + makeActivity({ + id: "approval-failed-stale", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + tone: "error", + payload: { + requestId: "req-stale-1", + detail: "Unknown pending permission request: req-stale-1", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([]); + }); + + it("clears stale pending approvals when the backend marks them stale after restart", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-stale-restart", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-stale-restart-1", + requestKind: "command", + }, + }), + makeActivity({ + id: "approval-failed-stale-restart", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + tone: "error", + payload: { + requestId: "req-stale-restart-1", + detail: + "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([]); + }); +}); + +describe("pending questions", () => { + it("preserves native answer keys while ignoring malformed options", () => { + const question = { + id: " Which path?\n", + header: " Path ", + question: " Which path?\n", + options: [{ label: " Keep spaces ", description: "", value: " native\t" }], + multiSelect: false, + }; + const requested = makeActivity({ + kind: "user-input.requested", + payload: { + requestId: "native-question", + questions: [null, { ...question, options: [...question.options, { label: 42 }] }], + }, + }); + + expect(derivePendingRequests([requested]).userInputs[0]?.questions).toEqual([question]); + }); + + it("keeps free-text questions without suggested answers", () => { + const question = { + id: "0", + header: "Question", + question: "What should it be named?", + options: [], + allowCustomAnswer: true, + multiSelect: false, + }; + const activities = [ + makeActivity({ + id: "async-question", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "async-1", responseMode: "message", questions: [question] }, + }), + ]; + expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]); + }); + + it("preserves native choice values and the custom-answer restriction", () => { + const question = { + id: "interaction-result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, + }; + const activities = [ + makeActivity({ + id: "native-user-input", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "req-native-choice", questions: [question] }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]); + }); + + it("tracks open structured prompts and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: true, + }, + ], + }, + }), + makeActivity({ + id: "user-input-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + payload: { + requestId: "req-user-input-2", + answers: { + sandbox_mode: "workspace-write", + }, + }, + }), + makeActivity({ + id: "user-input-open-2", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-2", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [ + { + label: "yes", + description: "Continue execution", + }, + ], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs).toEqual([ + { + requestId: "req-user-input-1", + createdAt: "2026-02-23T00:00:01.000Z", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: true, + }, + ], + }, + ]); + }); + + it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-stale-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: false, + }, + ], + }, + }), + makeActivity({ + id: "user-input-failed-stale", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-stale-1", + detail: + "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", + }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs).toEqual([]); + }); +}); + +describe.each(["approval", "user-input"])("%s request completion", (requestKind) => { + const requested = makeActivity({ + id: `${requestKind}-requested`, + kind: `${requestKind}.requested`, + sequence: 42, + payload: { + requestId: "request-1", + requestKind: "command", + questions: [{ id: "answer", header: "Answer", question: "Continue?", options: [] }], + }, + }); + + it.each([`${requestKind}.resolved`, `provider.${requestKind}.respond.failed`])( + "keeps %s final across reordered and repeated activities", + (kind) => { + const closed = makeActivity({ + id: `${requestKind}-closed`, + kind, + createdAt: "2026-02-23T00:00:01.000Z", + payload: { + requestId: "request-1", + detail: `Unknown pending ${requestKind} request: request-1`, + }, + }); + const replayedRequest = { ...requested, id: EventId.make("replayed-request"), sequence: 43 }; + + for (const activities of [ + [requested, closed, replayedRequest], + [closed, requested, replayedRequest], + ]) { + expect(derivePendingRequests(activities)).toEqual({ approvals: [], userInputs: [] }); + } + }, + ); + + it("keeps a failed reply retryable unless the text names a stale request", () => { + const failed = makeActivity({ + kind: `provider.${requestKind}.respond.failed`, + payload: { requestId: "request-1", detail: "Provider adapter request failed: timeout" }, + }); + const pending = derivePendingRequests([requested, failed]); + expect( + [...pending.approvals, ...pending.userInputs].map((request) => request.requestId), + ).toEqual(["request-1"]); + + const retried = makeActivity({ + kind: `${requestKind}.resolved`, + payload: { requestId: "request-1" }, + }); + expect(derivePendingRequests([requested, failed, retried, failed])).toEqual({ + approvals: [], + userInputs: [], + }); + }); +}); diff --git a/packages/client-runtime/src/pendingRequests.ts b/packages/client-runtime/src/pendingRequests.ts new file mode 100644 index 000000000000..a94c49514e5d --- /dev/null +++ b/packages/client-runtime/src/pendingRequests.ts @@ -0,0 +1,189 @@ +import { + ApprovalRequestId, + type OrchestrationThreadActivity, + ProviderApprovalOption, + ProviderRequestKind, + UserInputQuestion, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +export interface PendingApproval { + readonly requestId: ApprovalRequestId; + readonly requestKind: ProviderRequestKind; + readonly createdAt: string; + readonly detail?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; +} + +export interface PendingUserInput { + readonly requestId: ApprovalRequestId; + readonly createdAt: string; + readonly questions: ReadonlyArray; +} + +const isRequestId = Schema.is(ApprovalRequestId); +const isProviderRequestKind = Schema.is(ProviderRequestKind); +const isProviderApprovalOption = Schema.is(ProviderApprovalOption); +const QuestionOption = Schema.Struct({ + ...UserInputQuestion.fields.options.value.fields, + label: Schema.String, +}); +const isQuestionOption = Schema.is(QuestionOption); +// Native question IDs and option labels can be answer keys. Do not trim them. +const decodeQuestion = Schema.decodeUnknownOption( + Schema.Struct({ + ...UserInputQuestion.fields, + id: Schema.String, + header: Schema.String, + question: Schema.String, + options: Schema.Array(QuestionOption), + }), +); + +/** Older activities use native request types instead of a request kind. */ +export function requestKindFromRequestType(requestType: unknown): ProviderRequestKind | null { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + case "dynamic_tool_call": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; + default: + return null; + } +} + +function parseQuestions(value: unknown): UserInputQuestion[] { + if (!Array.isArray(value)) return []; + return value.flatMap((question) => { + if (!Predicate.isObject(question) || !Array.isArray(question.options)) return []; + const options = question.options.filter(isQuestionOption); + if (options.length === 0 && question.allowCustomAnswer === false) return []; + const parsed = decodeQuestion({ + id: question.id, + header: question.header, + question: question.question, + options, + multiSelect: question.multiSelect === true, + ...(typeof question.allowCustomAnswer === "boolean" + ? { allowCustomAnswer: question.allowCustomAnswer } + : {}), + }); + return Option.isSome(parsed) ? [parsed.value] : []; + }); +} + +const requestActivityKinds = new Set([ + "approval.requested", + "approval.resolved", + "provider.approval.respond.failed", + "user-input.requested", + "user-input.resolved", + "provider.user-input.respond.failed", +]); + +// The server reports a stale or unknown request through the failure text. +// A failed reply with any other text stays open so the user can retry. +const staleRequestFailureDetails = { + "provider.approval.respond.failed": [ + "stale pending approval request", + "unknown pending approval request", + "unknown pending permission request", + "unknown pending codex approval request", + ], + "provider.user-input.respond.failed": [ + "stale pending user-input request", + "unknown pending user-input request", + "unknown pending user input request", + "unknown pending codex user input request", + ], +} as const; + +function isStaleRequestFailure( + kind: keyof typeof staleRequestFailureDetails, + payload: Record, +): boolean { + const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : ""; + return staleRequestFailureDetails[kind].some((fragment) => detail.includes(fragment)); +} + +/** Reduces request state once for web, desktop, and mobile. Layout stays with each client. */ +export function derivePendingRequests(activities: ReadonlyArray) { + const approvals = new Map(); + const userInputs = new Map(); + const closedApprovals = new Set(); + const closedUserInputs = new Set(); + + // Request IDs are unique. A terminal event stays final even when provider + // sequences and server-generated activities arrive in a different order. + for (const activity of activities) { + if (!requestActivityKinds.has(activity.kind)) continue; + const payload = Predicate.isObject(activity.payload) ? activity.payload : undefined; + if (!payload || !isRequestId(payload.requestId)) continue; + const requestId = payload.requestId; + + if (activity.kind === "approval.requested") { + if ( + closedApprovals.has(requestId) || + payload.requestType === "tool_user_input" || + payload.requestType === "auth_tokens_refresh" + ) { + continue; + } + const requestKind = isProviderRequestKind(payload.requestKind) + ? payload.requestKind + : requestKindFromRequestType(payload.requestType); + const options = Array.isArray(payload.options) + ? payload.options.filter(isProviderApprovalOption) + : []; + approvals.set(requestId, { + requestId, + // Older OpenCode approvals do not always include a recognized kind. + requestKind: requestKind ?? "command", + createdAt: activity.createdAt, + ...(typeof payload.detail === "string" && payload.detail ? { detail: payload.detail } : {}), + ...(typeof payload.appName === "string" && payload.appName + ? { appName: payload.appName } + : {}), + ...(options.length > 0 ? { options } : {}), + }); + } else if (activity.kind === "user-input.requested") { + if (closedUserInputs.has(requestId)) continue; + const questions = parseQuestions(payload.questions); + if (questions.length === 0) continue; + userInputs.set(requestId, { requestId, createdAt: activity.createdAt, questions }); + } else if ( + activity.kind === "approval.resolved" || + (activity.kind === "provider.approval.respond.failed" && + isStaleRequestFailure(activity.kind, payload)) + ) { + closedApprovals.add(requestId); + approvals.delete(requestId); + } else if ( + activity.kind === "user-input.resolved" || + (activity.kind === "provider.user-input.respond.failed" && + isStaleRequestFailure(activity.kind, payload)) + ) { + closedUserInputs.add(requestId); + userInputs.delete(requestId); + } + } + + const byCreatedAt = ( + left: { readonly createdAt: string }, + right: { readonly createdAt: string }, + ) => left.createdAt.localeCompare(right.createdAt); + return { + approvals: [...approvals.values()].sort(byCreatedAt), + userInputs: [...userInputs.values()].sort(byCreatedAt), + }; +} diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts new file mode 100644 index 000000000000..980ccf495165 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_CACHE_MAX_BYTES, + PROJECT_FAVICON_CACHE_MAX_ENTRIES, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_MAX_SOURCE_BYTES, + type ProjectFaviconEntry, + type ProjectFaviconStorage, +} from "./projectFaviconCache.ts"; + +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; +const image = "data:image/png;base64,aWNvbg=="; +const replacement = "data:image/png;base64,bmV3"; +const signal = () => new AbortController().signal; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + const records = new Map(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + records.delete(key); + }, + }; + return { + storage, + load, + records, + cache: createProjectFaviconCache({ storage, load }), + }; +} + +describe("persistent project favicon cache", () => { + it("restores image bytes in a fresh client before any remote response", async () => { + const { cache, storage, load } = fixture(); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + const reloaded = createProjectFaviconCache({ storage, load }); + await reloaded.hydrate(); + expect(reloaded.peek(target)).toBe(image); + expect(await reloaded.resolve(target, null, signal())).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("reuses the image when signed URLs or connection origins change", async () => { + const { cache, load } = fixture(); + await cache.resolve(target, url, signal()); + expect( + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), + ).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("keeps the old image during refresh and failures, then persists its replacement", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + load.mockImplementationOnce(() => next.promise); + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + expect(cache.peek(target)).toBe(image); + next.resolve(replacement); + expect(await refreshing).toBe(replacement); + load.mockRejectedValueOnce(new Error("offline")); + expect(await cache.resolve(target, url, signal())).toBe(replacement); + await cache.flush(); + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( + replacement, + ); + }); + + it("persists confirmed removal and ignores an aborted older download", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const controller = new AbortController(); + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); + await started.promise; + controller.abort(); + expect( + await cache.resolve( + target, + "https://remote.test/api/assets/token/project-favicon-missing", + signal(), + ), + ).toBeNull(); + next.resolve(replacement); + await pending; + await cache.flush(); + expect( + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), + ).toBeNull(); + }); + + it("isolates environments, workspaces, and icon selections", async () => { + const { cache } = fixture(); + await cache.resolve(target, url, signal()); + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); + }); + + it.each([ + { + scope: "one environment", + clear: (cache: ReturnType["cache"]) => + cache.clearEnvironment(target.environmentId), + remaining: 1, + }, + { + scope: "every environment", + clear: (cache: ReturnType["cache"]) => cache.clearAll(), + remaining: 0, + }, + ])( + "does not restore images for $scope removed during a download", + async ({ clear, remaining }) => { + const { cache, load, records } = fixture(); + const other = { ...target, environmentId: EnvironmentId.make("other") }; + await cache.resolve(other, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await clear(cache); + next.resolve(image); + await pending; + await cache.flush(); + expect(cache.peek(target)).toBeNull(); + expect(records.size).toBe(remaining); + }, + ); + + it("discards a download that starts while the environment is being cleared", async () => { + const records = new Map(); + const removal = deferred(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + await removal.promise; + records.delete(key); + }, + }; + const cache = createProjectFaviconCache({ storage, load }); + await cache.resolve(target, url, signal()); + await cache.flush(); + const clearing = cache.clearEnvironment(target.environmentId); + await Promise.resolve(); + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + removal.resolve(); + await clearing; + expect(records.size).toBe(0); + expect(cache.peek(target)).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + expect(await late).toBe(image); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bounds individual images, total bytes, and entry count in storage", async () => { + const { cache, load, records } = fixture(); + load.mockResolvedValueOnce( + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + expect(await cache.resolve(target, url, signal())).toBe(url); + expect(cache.peek(target)).toBeNull(); + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; + load.mockResolvedValue(large); + for (let i = 0; i < 40; i++) { + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); + } + await cache.flush(); + expect( + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); + load.mockResolvedValue(image); + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); + } + await cache.flush(); + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); + }); + + it("skips corrupt records and tolerates unavailable storage", async () => { + const corrupt = createProjectFaviconCache({ + storage: { + list: async () => [ + { ...target, faviconPath: null, revision: "r", dataUrl: image }, + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, + "garbage", + ], + put: async () => {}, + remove: async () => {}, + }, + load: async () => replacement, + }); + await corrupt.hydrate(); + expect(corrupt.peek(target)).toBe(image); + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); + + const unavailable = createProjectFaviconCache({ + storage: { + list: async () => { + throw new Error("storage unavailable"); + }, + put: async () => { + throw new Error("quota exceeded"); + }, + remove: async () => { + throw new Error("quota exceeded"); + }, + }, + load: async () => image, + }); + expect(await unavailable.resolve(target, url, signal())).toBe(image); + await unavailable.flush(); + expect(unavailable.peek(target)).toBe(image); + }); +}); + +describe("project favicon image loader", () => { + const svg = + ''; + const svgBase64 = btoa(svg); + + function loader(response: Response, downscale = vi.fn(async () => replacement)) { + return { + downscale, + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), + }; + } + + it("inlines small icons exactly as served without rasterizing", async () => { + const { load, downscale } = loader( + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), + ); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + expect(downscale).not.toHaveBeenCalled(); + }); + + it("falls back to the file extension when the response has no image type", async () => { + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + }); + + it("downscales large bitmaps and refuses large vector icons", async () => { + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( + replacement, + ); + expect(bitmap.downscale).toHaveBeenCalledWith( + expect.objectContaining({ mimeType: "image/png", bytes }), + expect.any(AbortSignal), + ); + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); + expect(vector.downscale).not.toHaveBeenCalled(); + }); + + it("stops reading a response that exceeds the source limit", async () => { + let pulled = 0; + const chunk = new Uint8Array(1024 * 1024); + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(chunk); + }, + }); + const { load, downscale } = loader( + new Response(stream, { headers: { "content-type": "image/png" } }), + ); + await expect(load(url, signal())).rejects.toThrow("too large"); + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); + expect(downscale).not.toHaveBeenCalled(); + const declared = loader( + new Response("x", { + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, + }), + ); + await expect(declared.load(url, signal())).rejects.toThrow("too large"); + }); + + it("rejects failed responses and non-image payloads", async () => { + const failed = loader(new Response("nope", { status: 404 })); + await expect(failed.load(url, signal())).rejects.toThrow("404"); + const html = loader(new Response("", { headers: { "content-type": "text/html" } })); + await expect( + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), + ).rejects.toThrow("no image type"); + }); +}); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts new file mode 100644 index 000000000000..53fd58372190 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -0,0 +1,263 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { mediaMimeType } from "@t3tools/shared/filePreview"; +import { + getProjectFaviconCacheKey, + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; + +export interface ProjectFaviconTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +} + +const ImageDataUrl = Schema.String.check( + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), + Schema.isPattern( + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, + ), +); +const Entry = Schema.Struct({ + environmentId: EnvironmentId, + cwd: Schema.String, + faviconPath: Schema.NullOr(Schema.String), + revision: Schema.String, + dataUrl: ImageDataUrl, +}); +export type ProjectFaviconEntry = typeof Entry.Type; +const decodeEntry = Schema.decodeUnknownOption(Entry); +const isImageDataUrl = Schema.is(ImageDataUrl); + +function keyFor(target: ProjectFaviconTarget) { + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); +} + +export interface ProjectFaviconStorage { + /** Every persisted record; entries that fail validation are ignored. */ + readonly list: () => Promise>; + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; +} + +async function readBounded(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length")); + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Array = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Project icon is too large to decode."); + chunks.push(value); + } + } finally { + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs + * and small bitmaps are stored exactly as served. Larger bitmaps go through the + * platform downscaler; larger SVGs stay remote because rasterizing them without + * intrinsic dimensions is unreliable. + */ +export function createProjectFaviconImageLoader(input: { + readonly fetch?: typeof fetch; + readonly downscale: ( + image: { + readonly url: string; + readonly mimeType: string; + readonly bytes: Uint8Array; + }, + signal: AbortSignal, + ) => Promise; +}) { + const fetchImpl = input.fetch ?? globalThis.fetch; + return async (url: string, signal: AbortSignal): Promise => { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); + const contentType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); + if (!mimeType) throw new Error("Project icon has no image type."); + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); + signal.throwIfAborted(); + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; + if (isImageDataUrl(dataUrl)) return dataUrl; + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); + return input.downscale({ url, mimeType, bytes }, signal); + }; +} + +/** Stores small, self-contained images so startup never needs an old signed URL. */ +export function createProjectFaviconCache(input: { + readonly storage: ProjectFaviconStorage; + readonly load: (url: string, signal: AbortSignal) => Promise; +}) { + const entries = new Map(); + const environmentRevisions = new Map(); + let generation = 0; + let hydration: Promise | undefined; + let clearing: Promise | undefined; + const pending = new Set>(); + + const persist = (operation: () => Promise) => { + const task: Promise = operation() + .catch(() => { + // Keep the in-memory image if local storage is full or unavailable. + }) + .finally(() => pending.delete(task)); + pending.add(task); + }; + + const remove = (key: string) => { + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + persist(() => input.storage.remove(key, entry)); + }; + + const trim = () => { + let bytes = 0; + for (const entry of entries.values()) bytes += entry.dataUrl.length; + while ( + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES + ) { + const oldest = entries.entries().next().value; + if (!oldest) break; + bytes -= oldest[1].dataUrl.length; + remove(oldest[0]); + } + }; + + const hydrate = () => + (hydration ??= (async () => { + try { + for (const record of await input.storage.list()) { + const entry = decodeEntry(record); + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); + } + trim(); + } catch { + // A missing, corrupt, or unavailable cache must not prevent startup. + } + })()); + + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; + + const resolve = async ( + target: ProjectFaviconTarget, + url: string | null, + signal: AbortSignal, + ): Promise => { + await clearing; + const startGeneration = generation; + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; + await hydrate(); + if (signal.aborted || url === null) return peek(target); + const key = keyFor(target); + if (isProjectFaviconFallbackUrl(url)) { + remove(key); + return null; + } + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (cached.revision === revision) return cached.dataUrl; + } + try { + const dataUrl = await input.load(url, signal); + if ( + signal.aborted || + startGeneration !== generation || + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) { + return peek(target); + } + if (isImageDataUrl(dataUrl)) { + const entry = { + environmentId: target.environmentId, + cwd: target.cwd, + faviconPath: target.faviconPath || null, + revision, + dataUrl, + }; + entries.set(key, entry); + persist(() => input.storage.put(key, entry)); + trim(); + return dataUrl; + } + } catch { + // An outage or failed decode leaves the last successful image visible. + } + return peek(target) ?? url; + }; + + const flush = async () => { + await Promise.all(pending); + }; + + // A download that started before the clear sees the revision change and is discarded; + // one that starts during the clear waits for it, so it cannot repopulate storage. + const clear = async (environmentId?: EnvironmentId) => { + if (environmentId === undefined) generation += 1; + else + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + const previous = clearing; + const task = (async () => { + await previous; + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + })().finally(() => { + if (clearing === task) clearing = undefined; + }); + clearing = task; + await task; + }; + + return { + hydrate, + peek, + resolve, + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), + clearAll: () => clear(), + flush, + }; +} + +export type ProjectFaviconCache = ReturnType; diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 855bb2654edb..8a8eae1d6cd9 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -102,6 +102,7 @@ function relayAccountId(clerkToken: string): Option.Option { } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { const relay = yield* ManagedRelay.ManagedRelayClient; const session = yield* ClientCapabilities.CloudSession; diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index a9364752103d..b9a53e5ce8b8 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -11,7 +11,7 @@ export const DPOP_UNKNOWN_HINT = export const DPOP_RETRY_HINT = "Hint: Try again. If the problem continues, copy the trace ID."; -export function dpopFailureHint(reason: DpopFailureReason | undefined): string { +function dpopFailureHint(reason: DpopFailureReason | undefined): string { if (reason === "time_window") return DPOP_CLOCK_HINT; if (reason === undefined) return DPOP_UNKNOWN_HINT; return DPOP_RETRY_HINT; diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index d9f81c356e22..70c43631dab2 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -429,6 +429,7 @@ function disabledManagedRelayClient(relayUrl: string): ManagedRelayClient["Servi }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ManagedRelayClient.make")(function* ( options: ManagedRelayClientLayerOptions, ) { diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 8c93ec136d60..5c8b1a957c32 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -25,7 +25,6 @@ import { managedRelaySessionAtom, readManagedRelaySnapshotState, setManagedRelaySession, - waitForManagedRelayClerkToken, } from "./managedRelayState.ts"; let registry = AtomRegistry.make(); @@ -118,17 +117,6 @@ function clerkToken(expiresAtSeconds: number): string { describe("createManagedRelayQueryManager", () => { afterEach(resetRegistry); - it.effect("waits for the current cloud session before reading its token", () => - Effect.gen(function* () { - const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); - - setSession(); - - expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); - }), - ); - it.effect("deregisters an environment through the current Clerk session", () => Effect.gen(function* () { const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 6eb1fb6c6760..cb6d5983d394 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -192,36 +192,6 @@ function readSessionClerkToken( ); } -export const waitForManagedRelayClerkToken = Effect.fn( - "clientRuntime.managedRelaySession.waitForClerkToken", -)(function* (registry: AtomRegistry.AtomRegistry) { - return yield* Effect.callback((resume) => { - let unsubscribe: (() => void) | undefined; - let completed = false; - const readCurrentSession = () => { - if (completed) { - return true; - } - const session = registry.get(managedRelaySessionAtom); - if (!session) { - return false; - } - completed = true; - unsubscribe?.(); - resume(readSessionClerkToken(session)); - return true; - }; - - if (readCurrentSession()) { - return; - } - - unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); - readCurrentSession(); - return Effect.sync(() => unsubscribe?.()); - }); -}); - /** Removes an environment from the signed-in account without contacting that environment. */ export const deregisterManagedRelayEnvironment = Effect.fn( "clientRuntime.managedRelaySession.deregisterEnvironment", diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 4e6baba8bef4..9e4e8a600d55 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -3,10 +3,12 @@ import { EnvironmentId, type RelayClientInstallProgressEvent, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -27,7 +29,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + request, + runStream, + subscribe, + subscribeDynamicWithSession, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -221,6 +229,72 @@ describe("environment RPC", () => { }), ); + it.effect("keeps the producer session on an old value buffered across a session switch", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const secondSubscribed = yield* Deferred.make(); + const firstValueBlocked = yield* Deferred.make(); + const releaseFirstValue = yield* Deferred.make(); + const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent; + const bufferedFirstValue = { + source: "first", + index: 2, + } as unknown as ServerLifecycleStreamEvent; + const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent; + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.make(secondValue)), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const { activeSession, supervisor } = yield* makeHarness(); + + const resultFiber = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + () => Effect.succeed({}), + ).pipe( + Stream.mapEffect(([producerSession, value]) => + value === firstValue + ? Deferred.succeed(firstValueBlocked, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstValue)), + Effect.as([producerSession, value] as const), + ) + : Effect.succeed([producerSession, value] as const), + ), + Stream.take(3), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* SubscriptionRef.set(activeSession, Option.some(firstSession)); + yield* Deferred.await(firstSubscribed); + yield* Deferred.await(firstValueBlocked); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + yield* Deferred.await(secondSubscribed); + yield* Deferred.succeed(releaseFirstValue, undefined); + + const result = yield* Fiber.join(resultFiber); + expect(result).toEqual([ + [firstSession, firstValue], + [firstSession, bufferedFirstValue], + [secondSession, secondValue], + ]); + }), + ); + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => Effect.gen(function* () { const subscriptions: string[] = []; @@ -391,36 +465,112 @@ describe("environment RPC", () => { }), ); - it.effect("does not classify subscription defects as expected failures", () => + it.effect.each(["input", "stream"] as const)( + "does not classify %s subscription defects as expected failures", + (where) => + Effect.gen(function* () { + const defect = new Error("subscription invariant failed"); + let expectedFailureCount = 0; + let inputs = 0; + let streams = 0; + const observedDefects: unknown[] = []; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => { + streams += 1; + return where === "stream" ? Stream.die(defect) : Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const exit = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeTerminalEvents, + () => + Effect.sync(() => { + inputs += 1; + }).pipe(Effect.andThen(where === "input" ? Effect.die(defect) : Effect.succeed({}))), + { + onDefect: (cause) => + Effect.sync(() => { + observedDefects.push(Cause.squash(cause)); + }), + onExpectedFailure: () => + Effect.sync(() => { + expectedFailureCount += 1; + }), + retryExpectedFailureAfter: "250 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + expect(inputs).toBe(1); + expect(streams).toBe(where === "input" ? 0 : 1); + expect(expectedFailureCount).toBe(0); + expect(observedDefects).toEqual([defect]); + }), + ); + + it.effect("reports an initializer defect once after an expected failure retries", () => Effect.gen(function* () { - const defect = new Error("subscription invariant failed"); - let expectedFailureCount = 0; + const defect = new Error("Synthetic retry initializer defect"); + const expectedFailure = yield* Deferred.make(); + const observations: string[] = []; + const observedDefects: unknown[] = []; + let inputs = 0; const client = { - [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), + [WS_METHODS.subscribeTerminalEvents]: () => { + observations.push("stream"); + return Stream.fail(new Error("subscription not ready")); + }, } as unknown as WsRpcProtocolClient; const { activeSession, supervisor } = yield* makeHarness(); - yield* SubscriptionRef.set(activeSession, Option.some(session(client))); - const exit = yield* subscribe( + const fiber = yield* subscribeDynamicWithSession( WS_METHODS.subscribeTerminalEvents, - {}, + () => + Effect.sync(() => { + inputs += 1; + observations.push(`input ${inputs}`); + return inputs; + }).pipe( + Effect.flatMap((attempt) => (attempt === 1 ? Effect.succeed({}) : Effect.die(defect))), + ), { - onExpectedFailure: () => + onDefect: (cause) => Effect.sync(() => { - expectedFailureCount += 1; + observations.push("defect"); + observedDefects.push(Cause.squash(cause)); }), + onExpectedFailure: () => + Effect.sync(() => { + observations.push("expected failure"); + }).pipe(Effect.andThen(Deferred.succeed(expectedFailure, undefined)), Effect.asVoid), + retryExpectedFailureAfter: "250 millis", }, ).pipe( Stream.runDrain, Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.exit, + Effect.forkChild, ); - + yield* Deferred.await(expectedFailure); + yield* TestClock.adjust("250 millis"); + const exit = yield* Fiber.join(fiber); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); } - expect(expectedFailureCount).toBe(0); + expect(observations).toEqual(["input 1", "stream", "expected failure", "input 2", "defect"]); + expect(observedDefects).toEqual([defect]); }), ); }); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d68d2b2d531..bc13d429ac96 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -171,6 +171,10 @@ export function runStream( } interface SubscriptionOptions { + /** Reports protocol or programming defects without changing their recovery policy. */ + readonly onDefect?: ( + cause: Cause.Cause>, + ) => Effect.Effect; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -178,15 +182,15 @@ interface SubscriptionOptions { readonly resubscribe?: Stream.Stream; } -export function subscribeDynamic( +function subscribeDynamicMapped( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, + mapStream: ( + session: RpcSession, + stream: Stream.Stream, EnvironmentRpcStreamFailure>, + ) => Stream.Stream>, options?: SubscriptionOptions, -): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure, - EnvironmentSupervisor -> { +): Stream.Stream, EnvironmentSupervisor> { return Stream.unwrap( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -216,10 +220,7 @@ export function subscribeDynamic( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; - const subscribeToSession = (): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - > => + const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { @@ -229,49 +230,62 @@ export function subscribeDynamic( method: tag, input, }); - return method(input).pipe( + return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); - } - if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { - const handled = Stream.fromEffect( - options.onExpectedFailure(cause), - ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), - ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), ); }), + ).pipe( + Stream.tapCause((cause) => + options?.onDefect !== undefined && + cause.reasons.some( + (reason) => + reason._tag === "Die" || + (reason._tag === "Fail" && + isRpcClientError(reason.error) && + reason.error.reason._tag === "RpcClientDefect"), + ) + ? options.onDefect(cause) + : Effect.void, + ), + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( + Stream.drain, + ), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), ), ); return subscribeToSession(); @@ -287,6 +301,36 @@ export function subscribeDynamic( ); } +export function subscribeDynamic( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); +} + +/** Tags each value before `switchMap` can buffer it across a session change. */ +export function subscribeDynamicWithSession( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + readonly [session: RpcSession, value: EnvironmentRpcStreamValue], + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped( + tag, + makeInput, + (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), + options, + ); +} + export function subscribe( tag: TTag, input: EnvironmentRpcInput, @@ -298,8 +342,3 @@ export function subscribe( > { return subscribeDynamic(tag, () => Effect.succeed(input), options); } - -export const config = Effect.gen(function* () { - const session = yield* currentSession(); - return yield* session.initialConfig; -}).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 76608388f0ae..d5b1a6858f41 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export { type RpcSession, RpcSessionFactory } from "./session.ts"; +export { type RpcSession } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index aeef14968ff8..3e353f6be4df 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -57,6 +57,8 @@ export interface RpcSession { export interface RpcSessionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -145,6 +147,7 @@ function mapSessionRpcError( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("RpcSessionFactory.make")(function* ( options: RpcSessionOptions = {}, ) { @@ -152,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const serverConfigInput: ServerConfigSubscriptionInput = { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { @@ -369,5 +373,3 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( export const layerWithOptions = (options: RpcSessionOptions) => Layer.effect(RpcSessionFactory, make(options)); - -export const layer = layerWithOptions({}); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index d75e82281382..1cbc970df928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrls({ environmentId, resources })); }); }); + +describe("project favicon URL cache", () => { + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { + const image = "data:image/png;base64,aWNvbg=="; + const replacement = "data:image/png;base64,bmV3"; + const records = new Map(); + const storage = { + list: async () => [...records.values()], + put: async (key: string, entry: unknown) => { + records.set(key, entry); + }, + remove: async (key: string) => { + records.delete(key); + }, + }; + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); + await previousCache.resolve( + target, + "https://remote.test/api/assets/old/v1-icon.png", + new AbortController().signal, + ); + await previousCache.flush(); + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); + await cache.hydrate(); + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make>(Option.none()); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + imageCache: cache, + })(target); + const unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBe(image); + let unsubscribe = () => {}; + const refreshed = new Promise((resolve) => { + unsubscribe = registry.subscribe(favicon, (value) => { + if (value === replacement) resolve(); + }); + }); + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); + registry.set( + result, + AsyncResult.success({ + relativeUrl: "/api/assets/new/v2-icon.png", + expiresAt: 4_000_000_000_000, + }), + ); + expect(registry.get(favicon)).toBe(image); + await refreshed; + unsubscribe(); + expect(registry.get(favicon)).toBe(replacement); + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("offline"))); + expect(registry.get(favicon)).toBe(replacement); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + let unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBeNull(); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-a/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + unmount(); + unmount = registry.mount(favicon); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(result, AsyncResult.initial()); + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-b/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); + + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-c/project-favicon-missing", + }), + ); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + registry.set(connection, Option.none()); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token/icon.svg", + }), + ); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), + }); + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const unmount = registry.mount(favicon(target)); + try { + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect( + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), + ).toBeNull(); + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( + "https://remote.test/api/assets/token/icon.svg", + ); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index f2f82def3dd7..b8646d911cc2 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -1,13 +1,21 @@ import { type AssetCreateUrlResult, + type AssetImageDimensions, AssetResource, EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -60,6 +68,8 @@ export type AssetUrlState = readonly url: string; /** The host path the server chose to serve, when it differs from what was asked for. */ readonly sourcePath?: string; + /** Pixel size from the image header, when the server could read one. */ + readonly imageDimensions?: AssetImageDimensions; }; export function assetUrlStateFromResult( @@ -74,6 +84,9 @@ export function assetUrlStateFromResult( _tag: "Success", url, ...(result.value.sourcePath !== undefined ? { sourcePath: result.value.sourcePath } : {}), + ...(result.value.imageDimensions !== undefined + ? { imageDimensions: result.value.imageDimensions } + : {}), }; } @@ -112,3 +125,53 @@ export function createAssetEnvironmentAtoms( }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), }; } + +/** + * Keeps project icons visible while their environment reconnects. Each resource + * owns its last resolved URL, including a confirmed missing-icon response. + */ +export function createProjectFaviconUrlAtomFamily(input: { + readonly imageCache?: ProjectFaviconCache; + readonly createUrl: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Atom.Atom>; + readonly preparedConnection: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; +}) { + const decodeKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), + ); + const family = Atom.family((key: string) => { + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; + const request = input.createUrl({ environmentId, input: { resource } }); + const resolvedUrl = Atom.make((get): string | null => { + const result = get(request); + const connection = get(input.preparedConnection(environmentId)); + const state = assetUrlStateFromResult( + result, + Option.isSome(connection) ? connection.value.httpBaseUrl : null, + ); + return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + const cache = input.imageCache; + if (!cache) return resolvedUrl; + + const target = { environmentId, cwd, faviconPath: path }; + const image = Atom.make((get) => { + get(request); + const url = get(resolvedUrl); + return Effect.promise((signal) => cache.resolve(target, url, signal)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + + return Atom.make((get): string | null => { + const result = get(image); + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + }); + return (target: ProjectFaviconTarget) => + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); +} diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts index 074b89627af3..504dabb91b34 100644 --- a/packages/client-runtime/src/state/auth.ts +++ b/packages/client-runtime/src/state/auth.ts @@ -61,7 +61,7 @@ export function applyAuthAccessStreamEvent( } } -export function projectAuthAccessSnapshot( +function projectAuthAccessSnapshot( current: AuthAccessSnapshot, event: AuthAccessStreamEvent, ): readonly [AuthAccessSnapshot, ReadonlyArray] { diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 6dfa5001a486..a81739db1b7e 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -20,7 +20,7 @@ export interface EnvironmentCatalogState { readonly entries: ReadonlyMap; } -export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ +const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ isReady: false, entries: new Map(), }); diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d02f63c0b69a..b8d2aef40697 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -208,6 +208,8 @@ describe("environment entity projections", () => { title: "Cached thread", branch: "stale-branch", worktreePath: "/repo/stale-worktree", + activeOrderKey: "t", + unsettledAt: "2026-03-09T10:00:00.000Z", deletedAt: null, messages, proposedPlans: [], @@ -220,6 +222,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }; const merged = mergeEnvironmentThread(detail, shell); @@ -228,6 +232,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }); expect(merged?.messages).toBe(messages); }); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index cfc42f560715..019b52ddd359 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -27,7 +27,7 @@ export interface EnvironmentHttpAuthHeaders { * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads * from the fiber context at request time. */ -export const withEnvironmentCredentials = ( +const withEnvironmentCredentials = ( authorization: PreparedHttpAuthorization | null, request: Effect.Effect, ): Effect.Effect => @@ -46,7 +46,7 @@ export const withEnvironmentCredentials = ( * for relay/DPoP connections, so bearer/primary connections work even when no * signer is available. */ -export const buildEnvironmentAuthHeaders = ( +const buildEnvironmentAuthHeaders = ( authorization: PreparedHttpAuthorization | null, method: HttpMethod.HttpMethod, url: string, diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index 436db17110f1..d3571e65d2e6 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -1,10 +1,8 @@ import type { GitRunStackedActionInput, - GitRunStackedActionResult, GitStackedAction, VcsStatusResult, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -44,44 +42,6 @@ export type GitActionRequestInput = Pick< "action" | "commitMessage" | "featureBranch" | "filePaths" >; -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - pushTarget?: string; - featureBranch?: boolean; - shouldPushBeforePr?: boolean; -}): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - const prStages = [ - "Preparing PR...", - "Generating PR content...", - "Creating GitHub pull request...", - ]; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, ...prStages]; -} - export function buildMenuItems( gitStatus: VcsStatusResult | null, isBusy: boolean, @@ -396,45 +356,3 @@ export function resolveDefaultBranchActionDialogCopy(input: { continueLabel: "Push & create PR", }; } - -export function resolveThreadBranchUpdate( - result: GitRunStackedActionResult, -): { branch: string } | null { - if (result.branch.status !== "created" || !result.branch.name) { - return null; - } - - return { - branch: result.branch.name, - }; -} - -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.refName === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.refName) { - return null; - } - - if ( - input.threadBranch !== null && - input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) - ) { - return null; - } - - return { - branch: input.gitStatus.refName, - }; -} diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 94d213b257b6..4884c3b99bbc 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; +import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -9,6 +10,70 @@ import { } from "./projectGrouping.ts"; const environmentId = EnvironmentId.make("environment"); + +describe("load balancing shared project machines", () => { + const now = 100_000; + const resources = { + sampledAt: now, + cpuUtilization: 0.2, + cpuCount: 8, + availableMemoryBytes: 8_000, + totalMemoryBytes: 16_000, + }; + + it("compares three machines using free capacity and preference", () => { + const candidates = [ + { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, + { environmentId: "idle", resources, weight: 1 }, + { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, + ]; + expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); + expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); + }); + + it("rejects stale, unknown, excluded and saturated machines", () => { + expect( + chooseLoadBalancedEnvironment( + [ + { + environmentId: "stale", + resources: { ...resources, sampledAt: now - 15_001 }, + weight: 1, + }, + { environmentId: "unknown", resources: null, weight: 1 }, + { + environmentId: "no-cpu-sample", + resources: { ...resources, cpuUtilization: null }, + weight: 1, + }, + { environmentId: "excluded", resources, weight: 0 }, + { + environmentId: "cpu-full", + resources: { ...resources, cpuUtilization: 0.95 }, + weight: 1, + }, + { + environmentId: "memory-full", + resources: { ...resources, availableMemoryBytes: 100 }, + weight: 1, + }, + ], + now, + ), + ).toBeNull(); + }); + + it("uses client receipt time when host clocks differ", () => { + const candidate = { + environmentId: "different-clock", + resources: { ...resources, sampledAt: now + 60_000 }, + receivedAt: now, + weight: 1, + }; + expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); + expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); + }); +}); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", locator: { diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 43785d85dbbf..ce5c984214fd 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -152,19 +152,6 @@ export function deriveLogicalProjectKeyFromSettings( }); } -export function deriveLogicalProjectKeyFromRef( - projectRef: ScopedProjectRef, - project: - | Pick - | null - | undefined, - options?: { - readonly groupingMode?: SidebarProjectGroupingMode; - }, -): string { - return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef); -} - export function deriveProjectGroupLabel(input: { readonly representative: Pick; readonly members: ReadonlyArray>; diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index d0da18a7226a..bf90faf2188a 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -29,11 +29,12 @@ export { pullRequestDiffLoaderLayer, } from "./pullRequestDiffHttp.ts"; +/** @public Required to name the error in consumers' inferred pull request results. */ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( "EnvironmentHttpConnectionNotReadyError", )<{ readonly message: string }> {} -export const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; +const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; function createPullRequestRefreshAtomFamily( runtime: Atom.AtomRuntime, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 3f12f44d5f4c..ca4ac5ac911d 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -683,6 +683,24 @@ describe("executeAtomQuery", () => { registry.dispose(); }); + + it("settles when its caller aborts a waiting query", async () => { + const registry = AtomRegistry.make(); + const controller = new AbortController(); + const resultPromise = executeAtomQuery(registry, Atom.make(Effect.never), { + reportDefect: false, + signal: controller.signal, + }); + + controller.abort(); + + const result = await resultPromise; + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasInterruptsOnly(result.cause)).toBe(true); + } + registry.dispose(); + }); }); describe("runtime command runner", () => { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 56489a4ba668..3e61909ee711 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -336,6 +336,8 @@ export interface AtomQueryOptions extends AtomCommandOptions { * verification flows where a cached failure must not satisfy a retry. */ readonly refresh?: boolean; + /** Interrupt the query wait when its caller no longer wants the result. */ + readonly signal?: AbortSignal; } export async function executeAtomQuery( @@ -362,7 +364,11 @@ export async function executeAtomQuery( }); }), ); - return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); + return executeAtomCommand( + () => Effect.runPromiseExit(query, { signal: options.signal }), + options, + reporter, + ); } export function createRuntimeCommand( @@ -390,31 +396,6 @@ export function createRuntimeCommand( }; } -export function createRuntimeStreamCommand( - runtime: Atom.AtomRuntime, - options: { - readonly label: string; - readonly execute: (input: W, registry: AtomRegistry.AtomRegistry) => Stream.Stream; - readonly scheduler?: AtomCommandScheduler; - readonly concurrency?: AtomCommandConcurrency; - }, -): AtomCommand { - const scheduler = options.scheduler ?? createAtomCommandScheduler(); - const concurrency = options.concurrency ?? { mode: "parallel" as const }; - return { - label: options.label, - run: (registry, input) => - settleAtomCommandResult(() => - scheduler.schedule(registry, concurrency, input, () => { - const atom = runtime - .atom(options.execute(input, registry)) - .pipe(Atom.withLabel(options.label)); - return executeAtomQuery(registry, atom, { reportDefect: false, reportFailure: false }); - }), - ), - }; -} - export function reportAtomCommandResult( result: AtomCommandResult, options: AtomCommandOptions = {}, @@ -462,7 +443,7 @@ function parseEnvironmentRpcKey(key: string): { }; } -export function runInEnvironment( +function runInEnvironment( environmentId: EnvironmentIdType, effect: Effect.Effect, ): Effect.Effect< diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 6567726a4809..878f8c902f91 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,8 +7,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyServerWelcomeEvent, + makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, - projectServerWelcome, resolveServerConfigValue, + resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, @@ -494,25 +496,227 @@ describe("server state projection", () => { expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); }); - it("retains welcome when a ready event follows in the same stream chunk", () => { + it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { + const firstSession = session({} as WsRpcProtocolClient); + const secondSession = session({} as WsRpcProtocolClient); const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], cwd: "/repo", projectName: "repo", } as ServerLifecycleWelcomePayload; - const [afterWelcome] = projectServerWelcome(Option.none(), { + const initial = { + currentSession: firstSession, + welcomeSession: firstSession, + welcome: null, + }; + const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { type: "welcome", payload: welcome, }); - const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { type: "ready", payload: {}, }); + const afterSwitch = { ...afterReady, currentSession: secondSession }; + const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { + type: "welcome", + payload: { ...welcome, cwd: "/stale" }, + }); - expect(Option.getOrThrow(afterReady)).toBe(welcome); - expect(emitted).toEqual([]); + expect(afterReady).toBe(afterWelcome); + expect(resolveServerWelcomeState(afterReady)).toBe(welcome); + expect(afterBufferedOldWelcome).toBe(afterSwitch); + expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); }); + it.effect("checks the authoritative session before accepting a buffered welcome", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(firstEvents)), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const staleWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/stale", + projectName: "stale", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + + // Model the point after the ref changed but before either subscriber + // processed its publication. + supervisorSession.value = Option.some(secondSession); + const handled = yield* SubscriptionRef.changes(state).pipe( + Stream.filter( + (value) => value.currentSession === secondSession || value.welcome === staleWelcome, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); + + const next = yield* Fiber.join(handled); + expect(next.currentSession).toBe(secondSession); + expect(resolveServerWelcomeState(next)).toBeNull(); + }), + ); + }), + ); + + it.effect("reads the authoritative session after waiting for the welcome state lock", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const thirdSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + const changed = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value.currentSession !== firstSession), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + + yield* state.semaphore.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + supervisorSession.value = Option.some(thirdSession); + }), + ); + + expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); + }), + ); + }), + ); + + it.effect("clears a welcome until the reconnected session sends its own", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const secondEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const firstWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/first", + projectName: "first", + } as ServerLifecycleWelcomePayload; + const secondWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/second", + projectName: "second", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const nextResolved = ( + predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, + ) => + SubscriptionRef.changes(state).pipe( + Stream.map(resolveServerWelcomeState), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const first = yield* nextResolved((value) => value === firstWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); + expect(yield* Fiber.join(first)).toBe(firstWelcome); + + const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + expect(yield* Fiber.join(cleared)).toBeNull(); + expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); + + const second = yield* nextResolved((value) => value === secondWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); + expect(yield* Fiber.join(second)).toBe(secondWelcome); + }), + ); + }), + ); + it("prefers an active session config over cache until a live event arrives", () => { const config = (source: string, serverVersion: string) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7ba62a681481..911ee1bd85c6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -40,8 +41,10 @@ import { request, runStream, subscribe, + subscribeDynamicWithSession, type EnvironmentRpcInput, } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, @@ -356,6 +359,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; } export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( @@ -423,6 +427,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf yield* subscribe(WS_METHODS.subscribeServerConfig, { ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -453,7 +458,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf }, ); -export function serverConfigStateChanges( +function serverConfigStateChanges( environmentId: EnvironmentId, subscription: ServerConfigSubscriptionOptions, ) { @@ -476,21 +481,119 @@ export function serverConfigStateChanges( ); } -export function projectServerWelcome( - current: Option.Option, +export function applyServerWelcomeEvent( + current: EnvironmentServerWelcomeState, + session: RpcSession, event: { readonly type: "welcome" | "ready"; readonly payload: unknown; }, -): readonly [ - Option.Option, - ReadonlyArray, -] { - if (event.type !== "welcome") { - return [current, []]; - } - const welcome = event.payload as ServerLifecycleWelcomePayload; - return [Option.some(welcome), [welcome]]; +): EnvironmentServerWelcomeState { + return event.type === "welcome" && current.currentSession === session + ? { + ...current, + welcomeSession: session, + welcome: event.payload as ServerLifecycleWelcomePayload, + } + : current; +} + +export interface EnvironmentServerWelcomeState { + readonly currentSession: RpcSession | null; + readonly welcomeSession: RpcSession | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +export function resolveServerWelcomeState( + state: EnvironmentServerWelcomeState, +): ServerLifecycleWelcomePayload | null { + return state.currentSession === state.welcomeSession ? state.welcome : null; +} + +export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); + const state = yield* SubscriptionRef.make({ + currentSession: initialSession, + welcomeSession: null, + welcome: null, + }); + + const updateWithCurrentSession = Effect.fn( + "EnvironmentServerWelcomeState.updateWithCurrentSession", + )(function* ( + update: ( + current: EnvironmentServerWelcomeState, + currentSession: RpcSession | null, + ) => EnvironmentServerWelcomeState, + ) { + return yield* SubscriptionRef.modifyEffect(state, (current) => + SubscriptionRef.get(supervisor.session).pipe( + Effect.map( + (latestSession) => + [undefined, update(current, Option.getOrNull(latestSession))] as const, + ), + ), + ); + }); + + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.runForEach(() => + updateWithCurrentSession((current, currentSession) => ({ + ...current, + currentSession, + })), + ), + Effect.forkScoped, + ); + + yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { + yield* updateWithCurrentSession((current, currentSession) => + currentSession === session + ? { + ...current, + currentSession, + welcomeSession: session, + welcome: null, + } + : { ...current, currentSession }, + ); + return {}; + }), + ).pipe( + Stream.runForEach(([session, event]) => + updateWithCurrentSession((current, currentSession) => + applyServerWelcomeEvent( + { + ...current, + currentSession, + }, + session, + event, + ), + ), + ), + Effect.forkScoped, + ); + + return state; + }, +); + +function serverWelcomeStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerWelcomeState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), + ), + ), + ), + ); } export function resolveServerConfigValue( @@ -521,6 +624,7 @@ export function createServerEnvironmentAtoms( readonly environmentThemes?: boolean; /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -536,6 +640,7 @@ export function createServerEnvironmentAtoms( serverConfigStateChanges(environmentId, { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), ) .pipe( @@ -833,6 +938,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), + ), + ); + const welcomeFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const result = get(welcomeStateFamily(environmentId)); + if (result._tag !== "Success") return result; + return result.value === null + ? AsyncResult.initial(result.waiting) + : AsyncResult.success(result.value, result); + }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), + ); + const welcome = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => welcomeFamily(target.environmentId); return { configValueAtom, @@ -893,6 +1019,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, @@ -916,14 +1048,7 @@ export function createServerEnvironmentAtoms( refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), }), configProjection, - welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:welcome", - tag: WS_METHODS.subscribeServerLifecycle, - transform: (stream) => - stream.pipe( - Stream.mapAccum(Option.none, projectServerWelcome), - ), - }), + welcome, consumeResetCredit: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:consume-reset-credit", tag: WS_METHODS.providerConsumeResetCredit, diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8c46a9f33579..712138aab4c9 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, @@ -11,6 +12,7 @@ import { const primaryId = EnvironmentId.make("env-primary"); const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); +const restartCapabilities = { threadRestartContinuation: true }; describe("supportsSharedSettingsSync", () => { it("accepts only connected servers that advertise the shared-settings capability", () => { @@ -40,17 +42,30 @@ describe("splitSharedServerPatch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, + }); + expect(sharedPatch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); describe("pickSharedServerSettings", () => { it("returns only the shared keys", () => { - expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ - "defaultThreadEnvMode", + expect( + Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), + ).toEqual([ + "continueThreadsAfterServerUpdate", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -59,9 +74,106 @@ describe("pickSharedServerSettings", () => { }); }); +describe("filterSharedServerPatch", () => { + it.each([true, false])("preserves supported restart preference %s", (enabled) => { + const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; + expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); + }); + + it.each([undefined, {}, { threadRestartContinuation: false }])( + "omits only the unsupported restart preference with capabilities %j", + (capabilities) => { + expect( + filterSharedServerPatch( + { continueThreadsAfterServerUpdate: true, sidebarAutoSettleAfterDays: 7 }, + capabilities, + ), + ).toEqual({ sidebarAutoSettleAfterDays: 7 }); + expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, capabilities)).not.toHaveProperty( + "continueThreadsAfterServerUpdate", + ); + }, + ); +}); + describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + it.each([true, false])( + "detects remote restart continuation drift when the preference is %s", + (enabled) => { + const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled }; + const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled }; + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: remoteSettings, + capabilities: restartCapabilities, + }; + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [environment], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [ + { + ...environment, + settings: Object.assign( + {}, + remoteSettings, + pickSharedServerSettings(settings, restartCapabilities), + ), + }, + ], + }), + ).toEqual([]); + }, + ); + + it.each([ + [undefined, restartCapabilities], + [restartCapabilities, undefined], + [undefined, undefined], + ])( + "ignores restart drift unless both servers support it (%j, %j)", + (primaryCapabilities, capabilities) => { + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + capabilities, + settings: { ...primarySettings, continueThreadsAfterServerUpdate: true }, + }; + const input = { + primaryEnvironmentId: primaryId, + primarySettings, + primaryCapabilities, + environments: [environment], + }; + expect(findSharedSettingsMismatches(input)).toEqual([]); + expect( + findSharedSettingsMismatches({ + ...input, + environments: [ + { + ...environment, + settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 }, + }, + ], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }, + ); + it("lists sync-eligible environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, @@ -99,7 +211,12 @@ describe("findSharedSettingsMismatches", () => { environmentId: boxId, label: "Remote Box", syncEligible: true, - settings: { ...primarySettings, enableAgentBrowserAccess: false }, + settings: { + ...primarySettings, + enableAgentBrowserAccess: false, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + }, }, ], }); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index f3236ef2035a..0fd691a64bcd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -20,10 +20,10 @@ import * as Struct from "effect/Struct"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; /** Server keys that hold a user preference rather than machine config. */ -export const SHARED_SERVER_SETTING_KEYS = [ +const SHARED_SERVER_SETTING_KEYS = [ + "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", ] as const satisfies ReadonlyArray; @@ -52,15 +52,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { }; } -/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ -export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { - return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); +/** Omit restart recovery on servers that cannot persist its preference. */ +export function filterSharedServerPatch( + patch: ServerSettingsPatch, + capabilities: Pick | undefined, +): ServerSettingsPatch { + return capabilities?.threadRestartContinuation === true + ? patch + : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); +} + +/** The shared subset supported by one environment. */ +export function pickSharedServerSettings( + settings: ServerSettings, + capabilities?: Pick, +): ServerSettingsPatch { + return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities); } /** * Whether an environment can participate in shared-settings sync right now. - * Auto-settlement is the newest feature backed by a shared key, so a server - * advertising `threadAutoSettlement` can hold every shared key. + * Auto-settlement establishes baseline support; newer preferences are filtered separately. */ export function supportsSharedSettingsSync(environment: { readonly connection: { readonly phase: EnvironmentConnectionPhase }; @@ -81,12 +93,15 @@ export interface SharedSettingsEnvironment { readonly label: string; readonly syncEligible: boolean; readonly settings: ServerSettings | null; + readonly capabilities?: + | Pick + | undefined; } /** * Shared-settings sync targets whose values differ from the primary * environment's. Other environments are skipped: nothing can be read from or - * written to them, or their server cannot hold every shared key. With no + * written to them, or their server lacks baseline shared-settings support. With no * primary settings loaded there is nothing to compare against, so nothing is * reported. Callers must pass the real loaded settings, never a default * fallback, or "apply to all" would push defaults over real values. @@ -94,12 +109,18 @@ export interface SharedSettingsEnvironment { export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; + readonly primaryCapabilities?: + | Pick + | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { if (input.primaryEnvironmentId === null || input.primarySettings === null) { return []; } - const expected = pickSharedServerSettings(input.primarySettings); + const primarySettings = pickSharedServerSettings( + input.primarySettings, + input.primaryCapabilities, + ); return input.environments.flatMap((environment) => { if ( environment.environmentId === input.primaryEnvironmentId || @@ -108,7 +129,11 @@ export function findSharedSettingsMismatches(input: { ) { return []; } - const actual = pickSharedServerSettings(environment.settings); + const expected = filterSharedServerPatch(primarySettings, environment.capabilities); + const actual = filterSharedServerPatch( + pickSharedServerSettings(environment.settings, environment.capabilities), + input.primaryCapabilities, + ); return Equal.equals(actual, expected) ? [] : [{ environmentId: environment.environmentId, label: environment.label }]; diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index c150bbb75b8c..69799bbd1168 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -269,7 +269,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") return state; }); -export function shellStateChanges(environmentId: EnvironmentId) { +function shellStateChanges(environmentId: EnvironmentId) { return followStreamInEnvironment( environmentId, Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index e7f6965123b9..d366d7f0d4ee 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -5,10 +5,6 @@ import { foldSubagentActivities, formatSubagentModelLabel, formatSubagentTokenCount, - isAgentAttributedToolActivity, - isSubagentActivityKind, - isTimelineBypassActivity, - workflowCardMembers, } from "./subagentRuntime.ts"; let sequence = 0; @@ -557,64 +553,6 @@ describe("deriveAgentPanelModel", () => { }); }); -describe("workflowCardMembers", () => { - it("orders by urgency (failed, running, waiting) and reports overflow", () => { - const roster = fold([ - activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }), - ...[..."abcdefghij"].map((letter, index) => - activity("task.progress", { - taskId: `wf-1:wf:${index}`, - title: `agent-${letter}`, - status: index === 3 ? "failed" : index < 3 ? "completed" : "running", - ...(index === 3 ? { error: "died" } : {}), - parentAgentId: "wf-1", - agentIndex: index, - phaseIndex: 0, - phaseTitle: "Work", - }), - ), - ]); - const model = deriveAgentPanelModel({ agents: roster }); - const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8); - expect(visible).toHaveLength(8); - expect(overflow).toBe(2); - expect(visible[0]!.status).toBe("failed"); - expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2); - }); -}); - -describe("timeline predicates", () => { - it("recognizes subagent activity kinds as fold input", () => { - for (const kind of [ - "task.started", - "task.progress", - "task.updated", - "task.completed", - "tool.progress", - ]) { - expect(isSubagentActivityKind(kind)).toBe(true); - } - expect(isSubagentActivityKind("tool.completed")).toBe(false); - }); - - it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => { - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe( - true, - ); - expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false); - expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe( - false, - ); - }); - - it("timelineBypass rows never render in the parent chat", () => { - expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe( - true, - ); - expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false); - }); -}); - describe("formatSubagentTokenCount", () => { it("formats plain counters", () => { expect(formatSubagentTokenCount(950)).toBe("950"); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 532cda20d050..e441de32db48 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -861,61 +861,6 @@ export function deriveAgentPanelModel({ }; } -/** - * Members ordered by urgency for the capped inline workflow card: running and - * failed first, then waiting, then most recently updated. - */ -export function workflowCardMembers( - group: AgentPanelWorkflowGroup, - limit: number, -): { readonly visible: ReadonlyArray; readonly overflow: number } { - const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers]; - const urgency = (agent: RuntimeSubagent): number => { - if (agent.status === "failed") return 0; - if (agent.status === "running") return 1; - if (agent.status === "waiting") return 2; - return 3; - }; - const ordered = all - .slice() - .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt)); - return { - visible: ordered.slice(0, limit), - overflow: Math.max(0, ordered.length - limit), - }; -} - -/** Kinds the timeline should not render as generic rows (fold input only). */ -export function isSubagentActivityKind(kind: string): boolean { - return ( - kind === "task.started" || - kind === "task.progress" || - kind === "task.updated" || - kind === "task.completed" || - kind === "tool.progress" - ); -} - -/** - * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in - * the Agents surface, not the parent chat. Unattributed rows must stay. - */ -export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - const payload = activity.payload as Record; - return typeof payload.agentId === "string" && payload.agentId.trim().length > 0; -} - -/** Timeline-bypassing synthesized rows (Codex children, workflow members). */ -export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean { - if (typeof activity.payload !== "object" || activity.payload === null) { - return false; - } - return (activity.payload as Record).timelineBypass === true; -} - /** * Compact model chip text: strips vendor prefixes/date-or-context suffixes * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" → diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index 55cc4eef28f6..b1ef6500d414 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -96,7 +96,7 @@ export function nextTerminalAttachSeedState(): TerminalBufferState { }; } -export function terminalBufferStateFromSnapshot( +function terminalBufferStateFromSnapshot( snapshot: TerminalSessionSnapshot, maxBufferBytes: number, current: TerminalBufferState = EMPTY_TERMINAL_BUFFER_STATE, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c540644289df..83881f7ec15f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -19,6 +19,7 @@ import { type SetThreadRuntimeModeInput, type PinThreadInput, type ReorderPinnedThreadInput, + type ReorderActiveThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -39,6 +40,7 @@ import { setThreadRuntimeMode, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, startThreadTurn, @@ -63,6 +65,7 @@ export type { SetThreadRuntimeModeInput, PinThreadInput, ReorderPinnedThreadInput, + ReorderActiveThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderActive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-active", + execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0233cee0e22e..379985b71243 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -58,6 +58,8 @@ export function mergeEnvironmentThread( archivedAt: shell.archivedAt, settledOverride: shell.settledOverride, settledAt: shell.settledAt, + unsettledAt: shell.unsettledAt, + activeOrderKey: shell.activeOrderKey, snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, diff --git a/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts index 14ce5185f4ad..cd66961a597f 100644 --- a/packages/client-runtime/src/state/threadFeedback.test.ts +++ b/packages/client-runtime/src/state/threadFeedback.test.ts @@ -4,7 +4,7 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { - codexFeedbackMessage, + codexFeedbackNotice, parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, @@ -40,7 +40,7 @@ describe("submitCodexFeedback", () => { createdAt: "2026-08-23T00:00:00.000Z", } as const; - it("shows the command and clears the draft before the upload finishes", async () => { + it("reports upload progress and clears the draft before the upload finishes", async () => { let draft: string = submission.command; let finishUpload: | ((result: ReturnType>) => void) @@ -66,14 +66,10 @@ describe("submitCodexFeedback", () => { expect(draft).toBe(""); expect(states).toEqual([{ ...submission, status: "uploading" }]); - expect(codexFeedbackMessage(states[0]!)).toMatchObject({ - id: submission.id, - role: "user", - text: submission.command, + expect(codexFeedbackNotice(states[0]!)).toEqual({ + title: "Sending feedback to OpenAI...", + description: undefined, }); - expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe( - "Sending feedback to OpenAI...", - ); draft = "Keep this newer message."; finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" })); @@ -85,7 +81,7 @@ describe("submitCodexFeedback", () => { status: "sent", feedbackId: "codex-thread-1", }); - expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1"); + expect(codexFeedbackNotice(states.at(-1)!)?.description).toContain("codex-thread-1"); }); it("records a failed upload without losing its user-facing error", async () => { @@ -105,6 +101,7 @@ describe("submitCodexFeedback", () => { status: "failed", errorMessage: "Upload rejected.", }); + expect(codexFeedbackNotice(states.at(-1)!)?.description).toBe("Upload rejected."); }); it("marks interruptions without reporting them as upload failures", async () => { @@ -119,6 +116,7 @@ describe("submitCodexFeedback", () => { }); expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" }); + expect(codexFeedbackNotice(states.at(-1)!)).toBeNull(); }); it("lets another feedback submission finish while the first remains in flight", async () => { diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts index 29abb2689310..1b02a8982a16 100644 --- a/packages/client-runtime/src/state/threadFeedback.ts +++ b/packages/client-runtime/src/state/threadFeedback.ts @@ -1,8 +1,4 @@ -import { - MessageId, - type OrchestrationMessage, - type ProviderUploadFeedbackResult, -} from "@t3tools/contracts"; +import type { MessageId, ProviderUploadFeedbackResult } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -32,28 +28,20 @@ export function parseCodexFeedbackCommand(text: string): { readonly reason?: str return reason ? { reason } : {}; } -export function codexFeedbackMessage( - submission: CodexFeedbackSubmission, - role: "user" | "assistant" = "user", -): OrchestrationMessage { - const text = - role === "user" - ? submission.command - : submission.status === "sent" - ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\`` - : submission.status === "failed" - ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}` - : "Sending feedback to OpenAI..."; - - return { - id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`), - role, - text, - turnId: null, - streaming: false, - createdAt: submission.createdAt, - updatedAt: submission.createdAt, - }; +export function codexFeedbackNotice(submission: CodexFeedbackSubmission) { + switch (submission.status) { + case "interrupted": + return null; + case "uploading": + return { title: "Sending feedback to OpenAI...", description: undefined }; + case "sent": + return { + title: "Feedback sent to OpenAI", + description: `Thread ID: ${submission.feedbackId}`, + }; + case "failed": + return { title: "Could not send feedback to OpenAI", description: submission.errorMessage }; + } } export async function submitCodexFeedback(input: { diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 401980997663..38afce2d5cb7 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => { describe("thread.settled / thread.unsettled", () => { it("sets the settled override and timestamp", () => { const settledAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: settledAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.settled", - payload: { - threadId: ThreadId.make("thread-1"), - settledAt, - updatedAt: settledAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.settledOverride).toBe("settled"); expect(result.thread.settledAt).toBe(settledAt); + expect(result.thread.activeOrderKey).toBeNull(); } }); @@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => { describe("thread.pinned / thread.unpinned", () => { it("sets pinnedAt", () => { const pinnedAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: pinnedAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.pinned", - payload: { - threadId: ThreadId.make("thread-1"), - pinnedAt, - updatedAt: pinnedAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: pinnedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt, + updatedAt: pinnedAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.pinnedAt).toBe(pinnedAt); + expect(result.thread.activeOrderKey).toBe("m"); } }); @@ -281,75 +289,121 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.meta-updated", () => { + it.each(["f", null] as const)( + "updates the active key to %s without activity", + (activeOrderKey) => { + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.meta-updated", + payload: { + threadId: baseThread.id, + activeOrderKey, + updatedAt: baseThread.updatedAt, + }, + }, + ); + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activeOrderKey).toBe(activeOrderKey); + expect(result.thread.updatedAt).toBe(baseThread.updatedAt); + } + }, + ); + it("patches title and branch", () => { - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: "2026-04-01T05:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - title: "Updated Title", - branch: "feature/demo", - updatedAt: "2026-04-01T05:00:00.000Z", + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + title: "Updated Title", + branch: "feature/demo", + updatedAt: "2026-04-01T05:00:00.000Z", + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.title).toBe("Updated Title"); expect(result.thread.branch).toBe("feature/demo"); + expect(result.thread.activeOrderKey).toBe("m"); // Model selection should be unchanged since it wasn't in the payload expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } }); - it("sets and clears a linked pull request", () => { - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const linked = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: "2026-04-01T05:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - linkedPullRequest, - updatedAt: "2026-04-01T05:00:00.000Z", - }, - }); - - expect(linked.kind).toBe("updated"); - if (linked.kind !== "updated") return; - expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest); + it.each(["linkedPullRequest", "branchPullRequest"] as const)( + "sets and clears %s without changing the other link", + (field) => { + const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const otherField = + field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest"; + const otherPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const linked = applyThreadDetailEvent( + { ...baseThread, [otherField]: otherPullRequest }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + [field]: linkedPullRequest, + updatedAt: "2026-04-01T05:00:00.000Z", + }, + }, + ); - const cleared = applyThreadDetailEvent(linked.thread, { - ...baseEventFields, - sequence: 6, - occurredAt: "2026-04-01T06:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - linkedPullRequest: null, - updatedAt: "2026-04-01T06:00:00.000Z", - }, - }); + expect(linked.kind).toBe("updated"); + if (linked.kind !== "updated") return; + expect(linked.thread[field]).toEqual(linkedPullRequest); + expect(linked.thread[otherField]).toEqual(otherPullRequest); - expect(cleared.kind).toBe("updated"); - if (cleared.kind === "updated") { - expect(cleared.thread.linkedPullRequest).toBeNull(); - } - }); + const cleared = applyThreadDetailEvent(linked.thread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + [field]: null, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); + + expect(cleared.kind).toBe("updated"); + if (cleared.kind === "updated") { + expect(cleared.thread[field]).toBeNull(); + expect(cleared.thread[otherField]).toEqual(otherPullRequest); + } + }, + ); }); describe("thread.message-sent", () => { @@ -380,6 +434,40 @@ describe("applyThreadDetailEvent", () => { } }); + it("keeps imported replies turnless when delivered again", () => { + const event = { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported reply", + turnId: null, + streaming: false, + createdAt: "2026-03-01T06:00:00.000Z", + updatedAt: "2026-03-01T06:00:00.000Z", + }, + } as const; + + const imported = applyThreadDetailEvent(baseThread, event); + expect(imported.kind).toBe("updated"); + if (imported.kind !== "updated") return; + expect(imported.thread.latestTurn).toBeNull(); + expect(imported.thread.checkpoints).toBe(baseThread.checkpoints); + + const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 }); + expect(repeated.kind).toBe("updated"); + if (repeated.kind !== "updated") return; + expect(repeated.thread.messages).toEqual(imported.thread.messages); + expect(repeated.thread.latestTurn).toBeNull(); + expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints); + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -1129,36 +1217,149 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.turn-diff-completed", () => { - it("adds a checkpoint and updates latestTurn", () => { - const result = applyThreadDetailEvent(baseThread, { + it.each([null, "interrupted"] as const)( + "adds a checkpoint without replacing a %s turn outcome", + (previousState) => { + const result = applyThreadDetailEvent( + { + ...baseThread, + latestTurn: + previousState === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: previousState, + requestedAt: "2026-04-01T11:00:00.000Z", + startedAt: "2026-04-01T11:00:00.000Z", + completedAt: "2026-04-01T12:00:00.000Z", + assistantMessageId: null, + }, + }, + { + ...baseEventFields, + sequence: 13, + occurredAt: "2026-04-01T12:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.turn-diff-completed", + payload: { + threadId: ThreadId.make("thread-1"), + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-3"), + completedAt: "2026-04-01T12:00:00.000Z", + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.checkpoints).toHaveLength(1); + expect(result.thread.latestTurn?.turnId).toBe("turn-1"); + expect(result.thread.latestTurn?.state).toBe(previousState ?? "completed"); + } + }, + ); + }); + + describe("thread.reverted", () => { + it("keeps imported history and removes the first live prompt at checkpoint zero", () => { + const threadWithImportedHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("import:codex:session-1:000000"), + role: "user", + text: "Imported prompt", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }, + { + id: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported answer", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:01:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }, + { + id: MessageId.make("live-user-message"), + role: "user", + text: "New work", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithImportedHistory, { ...baseEventFields, - sequence: 13, - occurredAt: "2026-04-01T12:00:00.000Z", + sequence: 14, + occurredAt: "2026-04-01T02:00:00.000Z", aggregateKind: "thread", aggregateId: ThreadId.make("thread-1"), - type: "thread.turn-diff-completed", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("msg-3"), - completedAt: "2026-04-01T12:00:00.000Z", - }, + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, }); expect(result.kind).toBe("updated"); if (result.kind === "updated") { - expect(result.thread.checkpoints).toHaveLength(1); - expect(result.thread.latestTurn?.turnId).toBe("turn-1"); - expect(result.thread.latestTurn?.state).toBe("completed"); + expect(result.thread.messages.map((message) => message.text)).toEqual([ + "Imported prompt", + "Imported answer", + ]); + } + }); + + it("fallback-retains the earliest absolute timestamp across offsets", () => { + const threadWithOffsetMessages: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("earlier-by-offset"), + role: "user", + text: "Earlier", + turnId: null, + streaming: false, + createdAt: "2026-04-01T10:30:00.000+02:00", + updatedAt: "2026-04-01T10:30:00.000+02:00", + }, + { + id: MessageId.make("later-in-utc"), + role: "user", + text: "Later", + turnId: null, + streaming: false, + createdAt: "2026-04-01T09:00:00.000Z", + updatedAt: "2026-04-01T09:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithOffsetMessages, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T10:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]); } }); - }); - describe("thread.reverted", () => { it("filters entities to retained turns", () => { const threadWithData: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 690c74bdea0a..a3481fdc729c 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,8 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -95,6 +97,7 @@ export function applyThreadDetailEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + branchPullRequest: null, latestTurn: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -102,6 +105,7 @@ export function applyThreadDetailEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -141,6 +145,7 @@ export function applyThreadDetailEvent( settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }, }; @@ -238,6 +243,12 @@ export function applyThreadDetailEvent( ...(event.payload.linkedPullRequest !== undefined ? { linkedPullRequest: event.payload.linkedPullRequest } : {}), + ...(event.payload.branchPullRequest !== undefined + ? { branchPullRequest: event.payload.branchPullRequest } + : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; @@ -521,7 +532,10 @@ export function applyThreadDetailEvent( (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, - state: checkpointStatusToTurnState(event.payload.status), + state: + thread.latestTurn?.state === "interrupted" + ? "interrupted" + : checkpointStatusToTurnState(event.payload.status), requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, completedAt: event.payload.completedAt, @@ -548,7 +562,11 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); + const messages = retainMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), @@ -741,16 +759,42 @@ function rebindCheckpointAssistantMessage( function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, + turnCount: number, ): OrchestrationMessage[] { - // Keep messages that belong to a retained turn, plus system messages and - // messages without a turn binding (pre-turn-0 user messages). - return Arr.filter(messages, (message) => { - if (message.role === "system") { - return true; + const retainedMessageIds = new Set(); + for (const message of messages) { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { + retainedMessageIds.add(message.id); + } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { + retainedMessageIds.add(message.id); } - if (message.turnId === null) { - return true; + } + + for (const role of ["user", "assistant"] as const) { + const retainedCount = messages.filter( + (message) => + message.role === role && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), + ).length; + const missingCount = Math.max(0, turnCount - retainedCount); + const fallbackMessages = messages + .filter( + (message) => + message.role === role && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), + ) + .slice(0, missingCount); + for (const message of fallbackMessages) { + retainedMessageIds.add(message.id); } - return retainedTurnIds.has(message.turnId); - }); + } + + return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); } diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index a51d4cfed093..d9a2c124ee7d 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, planPinnedMove, + planPinnedReorder, resolveSettledThreadTimestamp, + sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, sortThreads, type ThreadSortInput, @@ -108,6 +112,44 @@ describe("sortThreads", () => { }); }); +describe("planPinnedReorder with hidden rows", () => { + it("keeps hidden slots available when inserting between visible neighbors", () => { + const midpoint = pinOrderKeyBetween("f", "t")!; + const keysById = new Map([ + ["a", "f"], + ["b", "t"], + ["moved", "z"], + ["snoozed", midpoint], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["a", "moved", "b"], + keysById, + movedId: "moved", + }); + expect(assignments).toHaveLength(1); + const key = assignments[0]!.orderKey; + expect(key > "f" && key < "t").toBe(true); + expect(key).not.toBe(midpoint); + expect(assignments[0]!.id).toBe("moved"); + }); + + it("materializes keyless rows without overwriting hidden slots", () => { + const reserved = generateSpreadPinOrderKeys(6); + const keysById = new Map([ + ["a", null], + ["b", null], + ["c", null], + ...reserved.map((key, i) => [`hidden-${i}`, key] as const), + ]); + const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" }); + expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]); + const keys = assignments.map(({ orderKey }) => orderKey); + expect(keys).toEqual([...keys].sort()); + expect(new Set(keys).size).toBe(3); + expect(keys.every((key) => !reserved.includes(key))).toBe(true); + }); +}); + describe("planPinnedMove", () => { it("moves a thread up with a single key write", () => { const assignments = planPinnedMove({ @@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => { expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); }); }); + +describe("generateSpreadPinOrderKeys", () => { + it.each([0, 1, 650, 675, 676, 1_001, 2_000])( + "leaves unique, insertable keys for %i threads", + (count) => { + const keys = generateSpreadPinOrderKeys(count); + expect(keys).toHaveLength(count); + expect(new Set(keys).size).toBe(count); + expect([...keys].sort()).toEqual(keys); + for (let index = 0; index < keys.length; index += 1) { + const before = keys[index - 1] ?? null; + const after = keys[index]!; + expect(after).toMatch(/^[a-z]*[b-z]$/); + const between = pinOrderKeyBetween(before, after); + expect(between).not.toBeNull(); + expect(between! < after).toBe(true); + if (before !== null) expect(between! > before).toBe(true); + } + }, + ); +}); + +describe("sortActiveThreadsByOrderKey", () => { + it("keeps new and reopened threads ahead of the saved order", () => { + const sorted = sortActiveThreadsByOrderKey([ + { + id: "arranged-first", + createdAt: "2026-03-09T09:00:00.000Z", + activeOrderKey: "f", + }, + { + id: "new", + createdAt: "2026-03-09T11:00:00.000Z", + activeOrderKey: null, + }, + { + id: "arranged-last", + createdAt: "2026-03-09T12:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + activeOrderKey: "t", + }, + { + id: "reopened", + createdAt: "2026-03-01T09:00:00.000Z", + unsettledAt: "2026-03-09T12:00:00.000Z", + }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual([ + "reopened", + "new", + "arranged-first", + "arranged-last", + ]); + }); + + it("breaks equal order keys and timestamps by thread then environment", () => { + for (const activeOrderKey of [null, "m"]) { + const threads = [ + { id: "thread-b", environmentId: "env-a" }, + { id: "thread-a", environmentId: "env-b" }, + { id: "thread-a", environmentId: "env-a" }, + ].map((thread) => ({ + ...thread, + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey, + })); + expect( + sortActiveThreadsByOrderKey(threads).map( + (thread) => `${thread.id}:${thread.environmentId}`, + ), + ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]); + } + }); + + it("applies every move across a mixed keyless and keyed section", () => { + const threads = Array.from({ length: 6 }, (_, index) => ({ + id: String(index), + createdAt: `2026-03-09T0${6 - index}:00:00.000Z`, + activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!, + })); + const ids = threads.map((thread) => thread.id); + const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])); + for (const movedId of ids) { + for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) { + const desired = ids.filter((id) => id !== movedId); + desired.splice(targetIndex, 0, movedId); + const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId }); + const nextKeys = new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ); + const updated = threads.map((thread) => ({ + ...thread, + activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey, + })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired); + } + } + }); + + it("moves a keyless thread into the arranged run with one write", () => { + const assignments = planPinnedMove({ + orderedIds: ["new", "reopened", "first", "last"], + keysById: new Map([ + ["new", null], + ["reopened", null], + ["first", "f"], + ["last", "t"], + ]), + movedId: "reopened", + direction: "down", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("reopened"); + expect(assignments![0]!.orderKey > "f").toBe(true); + expect(assignments![0]!.orderKey < "t").toBe(true); + }); + + it("materializes a large active list without changing the requested order", () => { + const threads = Array.from({ length: 1_200 }, (_, index) => ({ + id: String(index), + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey: null as string | null, + })); + const orderedIds = threads.map((thread) => thread.id).toReversed(); + const assignments = planPinnedReorder({ + orderedIds, + movedId: orderedIds[0]!, + keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])), + }); + const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey])); + const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index e54343a45628..3a4a9d284a18 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -107,7 +107,7 @@ export function getThreadSortTimestamp( * top instead of sinking back to its creation-order slot. Shared by web and * mobile so both render the same order. Malformed timestamps sink to 0. */ -export function activeThreadAnchorTimestampMs(thread: { +function activeThreadAnchorTimestampMs(thread: { readonly createdAt: string; readonly unsettledAt?: string | null | undefined; }): number { @@ -205,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): return pinOrderMidpoint(a, b); } -/** Evenly spaced keys for rewriting a whole pinned section (used when a - drop lands next to keyless threads, so single-key insertion has nothing - to anchor on). Two base-26 digits give 675 slots — far beyond any real - pinned section — with monotonicity enforced as a belt-and-braces. */ +/** Evenly spaced keys for materializing an order. Wider keys keep a large + active list from exhausting the space between two-digit keys. */ export function generateSpreadPinOrderKeys(count: number): string[] { - const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; + let width = 2; + let space = PIN_ORDER_DIGITS.length ** width; + while (space <= (count + 1) * 2) { + width += 1; + space *= PIN_ORDER_DIGITS.length; + } const step = space / (count + 1); const keys: string[] = []; - let previous = 0; for (let i = 0; i < count; i += 1) { - let value = Math.max(Math.round(step * (i + 1)), previous + 1); + let value = Math.round(step * (i + 1)); // Skip values whose low digit is the minimum (a trailing "a" key). if (value % PIN_ORDER_DIGITS.length === 0) value += 1; - value = Math.min(value, space - 1); - previous = value; - keys.push( - PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + - PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), - ); + let key = ""; + for (let digit = 0; digit < width; digit += 1) { + key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key; + value = Math.floor(value / PIN_ORDER_DIGITS.length); + } + keys.push(key); } return keys; } @@ -233,15 +235,21 @@ export function generateSpreadPinOrderKeys(count: number): string[] { * sits between two keyed (or absent) neighbors, this is a single write to * the moved thread. When a neighbor is keyless (threads pinned before * reordering shipped), the whole section gets fresh spread keys — a - * one-time materialization; every move after that is single-write. + * one-time materialization; every move after that is single-write. Active + * reordering uses the same planner with activeOrderKey values. */ export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ readonly orderedIds: readonly string[]; + /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); const movedIndex = orderedIds.indexOf(movedId); if (movedIndex === -1) return []; const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; @@ -251,11 +259,14 @@ export function planPinnedReorder(input: { const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; if (beforeUsable && afterUsable) { - const key = pinOrderKeyBetween(beforeKey, afterKey); + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); if (key !== null) return [{ id: movedId, orderKey: key }]; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. - const keys = generateSpreadPinOrderKeys(orderedIds.length); + const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); return orderedIds.flatMap((id, index) => { const key = keys[index]!; return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; @@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey< return [...keyed, ...keyless]; } +/** New and reopened threads lead the active list. Arranged threads follow + their saved keys; activity leaves both groups in place. */ +export function sortActiveThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; + }, +>(threads: readonly T[]): T[] { + return [...threads].sort((left, right) => { + const leftKey = left.activeOrderKey; + const rightKey = right.activeOrderKey; + if (leftKey == null && rightKey != null) return -1; + if (leftKey != null && rightKey == null) return 1; + let order = 0; + if (leftKey != null && rightKey != null) { + order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + } else { + order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); + } + return ( + order || + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? "") + ); + }); +} + /** * planPinnedReorder specialized for mobile's Move up / Move down menu * actions: swap the moved thread with its displayed neighbor. Null when the diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index d7a415b2999d..54b6f9e73e97 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -11,6 +11,8 @@ import { type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +20,10 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; @@ -27,8 +32,10 @@ import { PrimaryConnectionTarget, type NetworkStatus, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { ConnectionWakeups, type ConnectionWakeup } from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -37,6 +44,7 @@ import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { createEnvironmentThreadStateAtoms, + makeEnvironmentThreadState, requestOlderThreadTurns, ThreadSnapshotLoader, type EnvironmentThreadState, @@ -53,7 +61,7 @@ const THREAD: OrchestrationThread = { id: THREAD_ID, projectId: ProjectId.make("project-1"), title: "Cached thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "ModelA" }, runtimeMode: "full-access", interactionMode: "default", branch: "main", @@ -73,12 +81,27 @@ const THREAD: OrchestrationThread = { }; const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD }; +const CONNECTED_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; + readonly connected?: boolean; + readonly httpNone?: boolean; + readonly initialLoad?: Effect.Effect>; + readonly stream?: Stream.Stream; }) { + const clock = yield* Clock.Clock; + const wakeups = yield* Queue.unbounded(); const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; - readonly events: Queue.Queue; + readonly events: Queue.Queue; readonly closed: Deferred.Deferred; }>(); const olderLoads = yield* Queue.unbounded<{ @@ -95,7 +118,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => Stream.unwrap( Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const closed = yield* Deferred.make(); yield* Effect.acquireRelease( Effect.sync(() => { @@ -108,7 +131,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }).pipe(Effect.andThen(Deferred.succeed(closed, undefined))), ); yield* Queue.offer(subscriptions, { afterSequence: input.afterSequence, events, closed }); - return Stream.fromQueue(events); + return options?.stream ?? Stream.fromQueue(events); }), ), } as unknown as WsRpcProtocolClient; @@ -123,10 +146,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? probe: Effect.void, closed: Effect.never, }; + const connectionState = yield* SubscriptionRef.make( + options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE, + ); + const sessionRef = yield* SubscriptionRef.make(Option.some(session)); const supervisor = EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), - session: yield* SubscriptionRef.make(Option.some(session)), + state: connectionState, + session: sessionRef, prepared: yield* SubscriptionRef.make>( Option.some({ environmentId: TARGET.environmentId, @@ -164,6 +191,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }); const runtime = Atom.runtime( Layer.mergeAll( + Layer.succeed(Clock.Clock, clock), + Layer.succeed(ConnectionWakeups, { changes: Stream.fromQueue(wakeups) }), Layer.succeed(EnvironmentRegistry, environmentRegistry), Layer.succeed( EnvironmentCacheStore, @@ -193,8 +222,12 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? if (window?.beforeCursor === undefined) { return Effect.sync(() => { httpLoads += 1; - return Option.some(snapshot); - }); + }).pipe( + Effect.andThen( + options?.initialLoad ?? + Effect.succeed(options?.httpNone ? Option.none() : Option.some(snapshot)), + ), + ); } return Effect.gen(function* () { const response = @@ -220,6 +253,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? const registry = yield* makeRegistry; return { + runtime, + supervisor, registry, makeRegistry, rawAtoms: raw, @@ -228,6 +263,10 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? ref, subscriptions, olderLoads, + connectionState, + session, + sessionRef, + wakeups, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -257,6 +296,311 @@ describe("createEnvironmentThreadStateAtoms", () => { vi.restoreAllMocks(); }); + it.effect("exposes snapshot loader defects before the RPC subscription starts", () => + Effect.gen(function* () { + const completed = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: true, + initialLoad: Effect.die( + new Error("SYNTHETIC_RAW_SNAPSHOT_DEFECT_SHOULD_NOT_REACH_THREAD_UI"), + ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), + }); + const unmount = h.registry.mount(h.stateAtom); + yield* Deferred.await(completed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.status).toBe("empty"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(Option.none()); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + unmount(); + }), + ); + + it.effect.each([ + { kind: "protocol", httpNone: true }, + { kind: "protocol", httpNone: false }, + { kind: "fatal", httpNone: true }, + { kind: "fatal", httpNone: false }, + ] as const)( + "retains a terminated $kind load diagnostic across connection updates (empty: $httpNone)", + ({ kind, httpNone }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + const error = new Error("SYNTHETIC_RAW_DEFECT_SHOULD_NOT_REACH_THREAD_UI"); + yield* Queue.failCause( + first.events, + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + ); + yield* Deferred.await(first.closed); + // The real finalizer has run; advancing the atom runtime's test clock + // also verifies that a defect does not enter the domain retry loop. + yield* TestClock.adjust("1 second"); + const failed = h.registry.get(h.stateAtom); + expect(failed.status).toBe(httpNone ? "empty" : "cached"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(httpNone ? Option.none() : Option.some(THREAD)); + expect(h.counts().opened).toBe(1); + expect(h.counts().active).toBe(0); + + // Session publication can precede connected, and a fatal child cannot + // restart just because its supervisor reconnects. + for (const connection of [ + AVAILABLE_CONNECTION_STATE, + { ...CONNECTED_STATE, phase: "connecting" as const }, + CONNECTED_STATE, + ]) { + yield* SubscriptionRef.set(h.connectionState, connection); + yield* TestClock.adjust("0 millis"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + } + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + if (kind === "fatal") { + yield* TestClock.adjust("1 second"); + expect(h.counts().opened).toBe(1); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + unmount(); + return; + } + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + expect(recovered.data).toEqual(Option.some(THREAD)); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retries a protocol failure on foreground without replacing the session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "incompatible snapshot", + cause: new Error("incompatible snapshot"), + }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("0 millis"); + expect(Option.isSome(h.registry.get(h.stateAtom).error)).toBe(true); + yield* Queue.offer(h.wakeups, "application-active"); + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("keeps transport loss nonterminal and recovers with a replacement session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006, closeReason: "connection lost" }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + status: "synchronizing", + error: Option.none(), + data: Option.none(), + }); + expect(h.counts().opened).toBe(1); + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + const next = yield* Queue.take(h.subscriptions); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retains ordinary domain error reporting and same-session retries", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail(first.events, new Error("thread not found yet")); + yield* Deferred.await(first.closed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.error).toEqual(Option.some("thread not found yet")); + yield* TestClock.adjust("250 millis"); + const next = yield* Queue.take(h.subscriptions); + expect(h.counts().opened).toBe(2); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect.each([ + { kind: "protocol", deleted: false }, + { kind: "fatal", deleted: false }, + { kind: "domain", deleted: false }, + { kind: "protocol", deleted: true }, + ] as const)( + "keeps buffered outcomes after a $kind failure (deleted: $deleted)", + ({ kind, deleted }) => + Effect.gen(function* () { + const burst = yield* Deferred.make(); + const error = new Error( + kind === "domain" + ? "buffered thread failure" + : "SYNTHETIC_BUFFERED_DEFECT_SHOULD_NOT_REACH_THREAD_UI", + ); + const items: OrchestrationThreadStreamItem[] = [ + { kind: "snapshot", snapshot: SNAPSHOT }, + { kind: "synchronized" }, + { + kind: "event", + event: { + eventId: EventId.make("buffered-event"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 8, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title: "Buffer drained", + updatedAt: THREAD.createdAt, + }, + }, + }, + ]; + if (deleted) { + items.push({ + kind: "event", + event: { + eventId: EventId.make("buffered-deletion"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 9, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { threadId: THREAD_ID, deletedAt: THREAD.createdAt }, + }, + }); + } + const failure = + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + kind === "domain" + ? error + : new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ); + const h = yield* makeHarness({ + connected: true, + httpNone: true, + stream: Stream.fromEffect(Deferred.await(burst)).pipe( + Stream.flatMap(() => Stream.fromIterable(items)), + Stream.concat(Stream.failCause(failure)), + ), + }); + yield* Effect.gen(function* () { + const state = yield* makeEnvironmentThreadState(THREAD_ID); + const initial = yield* Deferred.make(); + const drained = yield* Deferred.make(); + yield* SubscriptionRef.changes(state).pipe( + Stream.runForEach((value) => + Deferred.succeed(initial, undefined).pipe( + Effect.andThen( + ( + deleted + ? value.status === "deleted" + : Option.getOrNull(value.data)?.title === "Buffer drained" + ) + ? Deferred.succeed(drained, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(initial); + const subscription = yield* Queue.take(h.subscriptions); + yield* Deferred.succeed(burst, undefined); + yield* Deferred.await(subscription.closed); + yield* Deferred.await(drained); + const final = yield* SubscriptionRef.get(state); + if (deleted) { + expect(final.status).toBe("deleted"); + expect(final.data).toEqual(Option.none()); + expect(final.error).toEqual(Option.none()); + return; + } + expect(Option.getOrThrow(final.data).title).toBe("Buffer drained"); + expect(final.error).toEqual( + Option.some(kind === "domain" ? error.message : "Could not synchronize the thread."), + ); + expect(final.status).toBe("cached"); + }).pipe( + Effect.provideService(EnvironmentSupervisor, h.supervisor), + Effect.provide(h.registry.get(h.runtime.layer)), + Effect.scoped, + ); + }), + ); + it.effect("shares one live stream and closes it after the last detail consumer leaves", () => Effect.gen(function* () { const h = yield* makeHarness(); @@ -319,6 +663,75 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([ + { replayed: false, statuses: ["live"] }, + { replayed: true, statuses: ["live", "synchronizing", "live"] }, + ])( + "keeps a warm resume live until it replays events (replayed: $replayed)", + ({ replayed, statuses }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + const observed: Array = []; + const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), { + immediate: true, + }); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + if (replayed) { + yield* Queue.offer(next.events, { + kind: "snapshot", + snapshot: { snapshotSequence: 9, thread: { ...THREAD, title: "Replayed" } }, + }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing"); + } + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual( + statuses, + ); + stop(); + remount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("downgrades a warm resume when the connection dropped while away", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + yield* SubscriptionRef.set(h.sessionRef, Option.none()); + yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE); + const remount = h.registry.mount(h.stateAtom); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached"); + expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD); + expect(h.counts().opened).toBe(1); + + yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE); + yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session)); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data when the raw atom family's weak entry is collected", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a0055b6cab3c..83b85bf02f09 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -47,7 +47,7 @@ function statusWithoutLiveData(data: Option.Option): Enviro * observed threads stays around 100K gzipped while median threads load fully. */ export const INITIAL_THREAD_USER_TURN_LIMIT = 10; -export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; +const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; function pageStateFromSnapshot( page: OrchestrationThreadDetailPage | undefined, @@ -101,7 +101,7 @@ const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); * instance is shared with the sync `requestOlderThreadTurns` entry point so * the apps get working wiring without providing anything. */ -export class ThreadOlderTurnRequests extends Context.Reference( +class ThreadOlderTurnRequests extends Context.Reference( "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", { defaultValue: () => defaultOlderTurnRequestRegistry }, ) {} @@ -158,10 +158,18 @@ function matchesThreadSnapshot( currentPage.hasMore === page.hasMore; } +// A retained "live" state stays live: the cursor resume that follows only +// replays what the thread missed, and on servers that send the completion +// marker the first replayed event moves the status to "synchronizing" on its +// own. Downgrading here would flash a sync label on every return to a +// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, - status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data), + status: + value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) + ? value.status + : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), }; @@ -302,8 +310,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => - current.status === "deleted" + const setConnecting = SubscriptionRef.update(state, (current) => + current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -312,7 +320,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -333,14 +341,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); - const setStreamError = (cause: Cause.Cause) => + const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), + error: Option.some(message), })), ), ); @@ -354,8 +362,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), - status: waiting ? ("synchronizing" as const) : ("live" as const), - error: Option.none(), + // Buffered values from the failed attempt can still arrive after its error. + status: Option.isSome(current.error) + ? ("cached" as const) + : waiting + ? ("synchronizing" as const) + : ("live" as const), + error: current.error, page: page === "keep" ? current.page : page, })); // Active threads can update many times per second and retain large tool @@ -415,7 +428,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => - Option.isSome(current.data) && current.status !== "deleted" + Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); @@ -631,7 +644,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { case "synchronizing": - return setSynchronizing; + return setConnecting; case "disconnected": return setDisconnected; case "ready": @@ -647,7 +660,22 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); - yield* setSynchronizing; + // Only the first subscription after a warm live resume keeps the retained + // status. A replacement session or foreground resubscribe on the same scope + // may have missed events, so those show sync progress until confirmed. + const resumingLive = yield* Ref.make(initialState.status === "live"); + const markSynchronizing = Effect.gen(function* () { + if (yield* Ref.get(resumingLive)) return; + // Connection notifications do not establish that a terminated load restarted. + // Clear its diagnostic only when this subscription actually tries again. + yield* SubscriptionRef.update(state, (current) => + current.status === "deleted" + ? current + : { ...current, status: "synchronizing" as const, error: Option.none() }, + ); + }); + + yield* markSynchronizing; yield* Effect.forkScoped( subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -668,7 +696,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const supportsPagination = config.threadSnapshotPagination === true; yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); - yield* setSynchronizing; + yield* markSynchronizing; + yield* Ref.set(resumingLive, false); let current = yield* SubscriptionRef.get(state); // A windowed cache resuming against a server without pagination is a @@ -739,7 +768,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }; }), { - onExpectedFailure: setStreamError, + onDefect: () => setStreamError("Could not synchronize the thread."), + onExpectedFailure: (cause) => setStreamError(formatThreadError(cause)), retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, @@ -794,7 +824,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return state; }); -export function threadStateChanges( +function threadStateChanges( environmentId: EnvironmentIdType, threadId: ThreadIdType, resumeCache?: ThreadResumeCache, diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts new file mode 100644 index 000000000000..29f029c9d863 --- /dev/null +++ b/packages/client-runtime/src/state/usage.test.ts @@ -0,0 +1,184 @@ +import { + EnvironmentId, + UsageDay, + USAGE_CONTRACT_VERSION, + type UsageSummary, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentPresentation } from "../connection/presentation.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { refreshUsage } from "./usage.ts"; + +const input = { + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-05"), + timeZone: "UTC", +}; +const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 }; +const summary: UsageSummary = { + ...input, + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-05T12:00:00Z", + buckets: [], + sources: [], + pricing, + scanDurationMs: 1, +}; +const registries: AtomRegistry.AtomRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.dispose(); +}); + +function harness(ids = ["a"]) { + const registry = AtomRegistry.make(); + registries.push(registry); + const environments = ids.map((id) => { + const environmentId = EnvironmentId.make(id); + const rates = Promise.withResolvers< + AsyncResult.Success | AsyncResult.Failure + >(); + const scan = Promise.withResolvers(); + const scanStarted = Promise.withResolvers(); + const presentation = Atom.make({ + connection: { phase: "connected" }, + } as EnvironmentPresentation | null); + const query = Atom.make( + Effect.promise(() => { + scanStarted.resolve(); + return scan.promise; + }), + ); + return { environmentId, rates, scan, scanStarted, presentation, query }; + }); + function get(environmentId: EnvironmentId) { + const environment = environments.find((entry) => entry.environmentId === environmentId); + if (!environment) throw new Error(`Unknown environment: ${environmentId}`); + return environment; + } + const options = { + registry, + environmentIds: environments.map((entry) => entry.environmentId), + input, + server: { + usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => + get(environmentId).query, + refreshUsageRates: { + label: "test:rates", + run: ( + _registry: AtomRegistry.AtomRegistry, + { environmentId }: { environmentId: EnvironmentId }, + ) => get(environmentId).rates.promise, + }, + }, + presentations: { + presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, + }, + } satisfies Parameters[0]; + return { registry, environments, refresh: () => refreshUsage(options) }; +} + +describe("manual usage refresh", () => { + it.each(["success", "failure"])("waits for the rescan after a pricing %s", async (result) => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + expect(finished).toBe(false); + entry.rates.resolve( + result === "success" + ? AsyncResult.success(pricing) + : AsyncResult.fail(new Error("Pricing offline")), + ); + await entry.scanStarted.promise; + expect(finished).toBe(false); + entry.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when an environment disconnects during the rescan", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await entry.scanStarted.promise; + registry.set(entry.presentation, null); + await refreshing; + }); + + it("waits for healthy environments without waiting for a recovering environment", async () => { + const { registry, environments, refresh } = harness(["healthy", "recovering"]); + const [healthy, recovering] = environments; + registry.set(recovering!.presentation, null); + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + for (const entry of environments) entry.rates.resolve(AsyncResult.success(pricing)); + await healthy!.scanStarted.promise; + expect(finished).toBe(false); + healthy!.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when connected state has no usable RPC session", async () => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve( + AsyncResult.fail( + new EnvironmentRpcUnavailableError({ + environmentId: entry.environmentId, + message: "No session", + }), + ), + ); + await refreshing; + }); + + it("replaces a scan that started before pricing was refreshed", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let reads = 0; + const rescanned = Promise.withResolvers(); + const query = Atom.make( + Effect.promise(() => { + reads += 1; + if (reads > 1) { + rescanned.resolve(); + return Promise.resolve(summary); + } + return new Promise(() => {}); + }), + ); + entry.query = query; + const unmount = registry.mount(query); + expect(reads).toBe(1); + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await rescanned.promise; + await refreshing; + expect(reads).toBe(2); + unmount(); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts new file mode 100644 index 000000000000..10a565a0c24f --- /dev/null +++ b/packages/client-runtime/src/state/usage.ts @@ -0,0 +1,61 @@ +import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; +import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; +import type { createServerEnvironmentAtoms } from "./server.ts"; + +const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); + +/** Refresh pricing, then await each selected environment's rescan while it remains connected. */ +export async function refreshUsage({ + registry, + server, + presentations, + environmentIds, + input, +}: { + registry: AtomRegistry.AtomRegistry; + server: Pick< + ReturnType, + "usageSummary" | "refreshUsageRates" + >; + presentations: Pick, "presentationAtom">; + environmentIds: readonly EnvironmentId[]; + input: UsageSummaryInput; +}): Promise { + await Promise.all( + environmentIds.map(async (environmentId) => { + const query = server.usageSummary({ environmentId, input }); + const presentation = presentations.presentationAtom(environmentId); + const controller = new AbortController(); + const abortWhenDisconnected = () => { + if (registry.get(presentation)?.connection.phase !== "connected") controller.abort(); + }; + const unsubscribe = registry.subscribe(presentation, abortWhenDisconnected); + abortWhenDisconnected(); + try { + const ratesResult = await runAtomCommand( + registry, + server.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ); + const sessionUnavailable = + ratesResult._tag === "Failure" && + isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult)); + // Invalidate even on failure so reconnects cannot reuse the old summary. + registry.refresh(query); + if (sessionUnavailable || controller.signal.aborted) return; + await executeAtomQuery(registry, query, { + reportFailure: false, + signal: controller.signal, + }); + } finally { + unsubscribe(); + } + }), + ); +} diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a932e7398d04..6c93e6204dc8 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -33,7 +33,7 @@ const OFFLINE_BRANCH_LIST_LIMIT = 100; const VCS_REFS_IDLE_TTL_MS = 30_000; // Rows keep the last status they rendered, so the live stream only needs a // short grace period when virtualization or scrolling releases its consumer. -export const VCS_STATUS_IDLE_TTL_MS = 10_000; +const VCS_STATUS_IDLE_TTL_MS = 10_000; const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.seconds(30))), @@ -214,7 +214,7 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return Stream.concat(cachedRefs, refreshedRefs); }); -export function cachedVcsRefsChanges( +function cachedVcsRefsChanges( environmentId: EnvironmentId, input: VcsListRefsInput, expectedRevision: number, @@ -345,4 +345,3 @@ export function createVcsEnvironmentAtoms( export * from "./gitActions.ts"; export * from "./vcsAction.ts"; export * from "./vcsRef.ts"; -export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f0c3791e35b2..46dfd74e9726 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -163,14 +163,14 @@ const decodeVcsActionTargetKey = Schema.decodeUnknownSync( Schema.Tuple([EnvironmentId, Schema.String]), ); -export const vcsActionStateAtom = Atom.family((key: string) => { +const vcsActionStateAtom = Atom.family((key: string) => { return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel(`vcs-action:${key}`), ); }); -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( +const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.keepAlive, Atom.withLabel("vcs-action:null"), ); @@ -191,7 +191,7 @@ export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { } } -export function getVcsActionStateAtom(target: VcsActionTarget) { +function getVcsActionStateAtom(target: VcsActionTarget) { const key = getVcsActionTargetKey(target); return key === null ? EMPTY_VCS_ACTION_ATOM : vcsActionStateAtom(key); } @@ -217,7 +217,7 @@ export function beginVcsActionState( }; } -export function failVcsActionState( +function failVcsActionState( operation: VcsActionOperation, actionId: string, error: unknown, diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts deleted file mode 100644 index 0a301fa86f3c..000000000000 --- a/packages/client-runtime/src/state/vcsStatus.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} diff --git a/packages/client-runtime/src/voice-input/index.ts b/packages/client-runtime/src/voice-input/index.ts index c8c8da455ed1..2af9cf3e5ac4 100644 --- a/packages/client-runtime/src/voice-input/index.ts +++ b/packages/client-runtime/src/voice-input/index.ts @@ -1,7 +1,6 @@ export { VoiceInputController, VOICE_RECORDING_LIMIT_SECONDS, - resolveTranscriptCommit, voiceInputBlocksSubmission, voiceInputFreezesEditor, type VoiceDraftSnapshot, diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index f3febf5e756d..e056e92afe5c 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -12,8 +12,130 @@ import { toolGroupSummaryKind, type WorkLogPresentationEntry, workEntryViewedImagePath, + workEntryIndicatesToolFailure, + workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, } from "./presentation.js"; +describe("workEntryIndicatesToolFailure", () => { + const base = { + id: "w1", + createdAt: "2026-01-01T00:00:00.000Z", + label: "Read", + }; + + it("is true for error tone", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "error", + detail: "nothing special", + }), + ).toBe(true); + }); + + it("is true when lifecycle says failed even if detail is empty", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "failed", + }), + ).toBe(true); + }); + + it("detects file-not-found style tool output with completed lifecycle", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "File not found: C:\\foo\\nonexistent.ts", + }), + ).toBe(true); + }); + + it("detects glob no files and PowerShell command errors", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Glob", + tone: "tool", + detail: "No files found", + }), + ).toBe(true); + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Bash", + tone: "tool", + detail: + "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", + }), + ).toBe(true); + }); + + it("is false for successful completed tools", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "Found 3 matching files", + }), + ).toBe(false); + }); + + it("does not treat error text in a command as rendered failure", () => { + const entry = { + label: "Ran command", + tone: "tool", + toolLifecycleStatus: "completed", + command: 'rg "file not found"', + detail: "Found 3 matches", + } satisfies WorkLogPresentationEntry; + + expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); + // Older activities can store output in this field, so that path stays separate. + expect(workEntryIndicatesToolFailure(entry)).toBe(true); + expect(workEntryDisplayIndicatesToolFailure({ ...entry, detail: "File not found" })).toBe(true); + }); + + it("treats successful tool rows as success candidates", () => { + expect( + workEntryIndicatesToolSuccess({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "ok", + }), + ).toBe(true); + expect( + workEntryIndicatesToolSuccess({ + ...base, + tone: "tool", + toolLifecycleStatus: "inProgress", + detail: "…", + }), + ).toBe(false); + expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); + expect( + workEntryIndicatesToolSuccess({ ...base, tone: "tool", toolLifecycleStatus: "stopped" }), + ).toBe(false); + }); + + it("does not run heuristics on non-tool info rows", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Context compacted", + tone: "info", + detail: "File not found in conversation", + }), + ).toBe(false); + }); +}); + describe("summarizeToolGroup", () => { it.each(["command", "file-read", "file-change"])( "keeps %s approvals out of tool execution counts", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index d47f44566452..2e1ef3bbf003 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,6 +1,7 @@ import { isToolLifecycleItemType, type AssetResource, + type RuntimeItemStatus, type ThreadId, type ToolActivitySource, type ToolLifecycleItemType, @@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; } +export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped"; + export interface WorkLogPresentationEntry { readonly label: string; readonly toolTitle?: string; @@ -298,14 +301,101 @@ export function commandDetailRepeatsCommand(input: { ); } -function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { +export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; if (entry.command !== undefined && entry.command.trim().length > 0) return true; if (entry.requestKind !== undefined) return true; return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); } -export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { +/** Maps item and task status to the status shown on a work-log row. */ +export function extractWorkLogToolLifecycleStatus( + payloadValue: unknown, +): WorkLogToolLifecycleStatus | undefined { + const payload = asRecord(payloadValue); + switch (payload?.status) { + case "pending": + case "running": + case "waiting": + return "inProgress"; + case "cancelled": + case "interrupted": + return "stopped"; + case "idle": + // A batch becomes idle when its parent turn ends. Other idle tasks can resume. + return payload.taskType === "subagent_batch" ? "stopped" : undefined; + case "inProgress": + case "completed": + case "failed": + case "declined": + case "stopped": + return payload.status; + default: + return undefined; + } +} + +// Some providers report completion even when the output describes a failure. +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + normalized.includes("is not recognized as the name of a cmdlet") || + normalized.includes("a parameter cannot be found that matches parameter name") || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailureFromOutput( + entry: WorkLogPresentationEntry, + includeCommand: boolean, +): boolean { + if ( + entry.tone === "error" || + entry.toolLifecycleStatus === "failed" || + entry.toolLifecycleStatus === "declined" + ) { + return true; + } + if (!workLogEntryIsToolLike(entry)) return false; + const output = includeCommand + ? [entry.detail, entry.command].filter(Boolean).join("\n") + : (entry.detail ?? ""); + return output.length > 0 && toolDetailTextLooksLikeFailure(output); +} + +/** Includes legacy activities that stored error output in the command field. */ +export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, true); +} + +/** Checks rendered output without treating the user's command as an error. */ +export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, false); +} + +/** Decides whether the row can show a success marker. */ +export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry): boolean { + return ( + workLogEntryIsToolLike(entry) && + !workEntryIndicatesToolFailure(entry) && + entry.tone !== "thinking" && + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" + ); +} + +function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { return ( entry.itemType === "web_search" && /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..ffd90dd79db8 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** File identity saved with an imported session so bounded retries can skip unchanged history. */ +export const AgentSessionImportSource = Schema.Struct({ + provider: AgentSessionSource, + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + filePath: TrimmedNonEmptyString, + size: NonNegativeInt, + mtimeMs: Schema.NullOr(Schema.Number), + device: Schema.Number, + inode: Schema.NullOr(Schema.Number), + birthtimeMs: Schema.NullOr(Schema.Number), +}); +export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; + +/** Imported message ids retain their origin after event metadata is projected into SQLite. */ +export function isImportedAgentSessionMessageId(messageId: string): boolean { + return messageId.startsWith("import:"); +} + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, + truncated: Schema.optional(Schema.Boolean), +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export const AgentSessionImportInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), +}); +export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; + +export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectNotFoundError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' does not exist.`; + } +} + +export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectChangedError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; + } +} + +export const AgentSessionImportResult = Schema.Struct({ + importedCount: NonNegativeInt, + skippedCount: NonNegativeInt, +}); +export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 5b003374edf3..927dbbac0d7a 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -52,12 +52,20 @@ export const AssetCreateUrlInput = Schema.Struct({ }); export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; +export const AssetImageDimensions = Schema.Struct({ + width: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), + height: NonNegativeInt.check(Schema.isGreaterThanOrEqualTo(1)), +}); +export type AssetImageDimensions = typeof AssetImageDimensions.Type; + export const AssetCreateUrlResult = Schema.Struct({ relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), expiresAt: Schema.Number, sourcePath: Schema.optional( TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), ), + /** Pixel size read from the image header, so a client can reserve the exact box before the bytes arrive. */ + imageDimensions: Schema.optional(AssetImageDimensions), }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 2c7eb24821d3..e560bbadaed0 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; -export const BROWSER_IMPORT_SOURCE_IDS = [ +const BROWSER_IMPORT_SOURCE_IDS = [ "chrome", "edge", "brave", @@ -139,9 +139,7 @@ export const BrowserImportResult = Schema.Struct({ }); export type BrowserImportResult = typeof BrowserImportResult.Type; -export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< - Record -> = { +const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly> = { notInstalled: "Not installed on this machine.", needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index b7a7dd7c7307..4115b07d7aad 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -203,5 +203,3 @@ export const ExternalLauncherError = Schema.Union([ ExternalLauncherEditorSpawnError, ]); export type ExternalLauncherError = typeof ExternalLauncherError.Type; - -export const isExternalLauncherError = Schema.is(ExternalLauncherError); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 38347ebc92b9..b16f8f9c2c3e 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -21,13 +21,14 @@ export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; /** - * The curated set of machine shapes an environment can wear as its icon. + * The curated set of machine shapes and OS identities an environment can wear as its icon. * Servers detect one from the hardware they run on (`platform.machine`), and * the `environmentIcon` server setting lets a user pick one instead. */ export const ENVIRONMENT_MACHINE_KINDS = [ "server", "cloud", + "linux", "desktop", "laptop", "mac-mini", @@ -94,6 +95,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadSettlement: Schema.optionalKey(Schema.Boolean), /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), + /** Server persists the opt-in for continuing interrupted threads after restarts. */ + threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), @@ -113,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), + /** Server persists manual Active order through thread.active.reorder. */ + threadActiveReorder: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), @@ -143,6 +148,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ desktop servers whose app predates the remote trigger, where clients must keep telling the user to update the app on that machine. */ desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + forkFlags: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), // fork: base }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index a895697e36b0..80d2a7a25dab 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -408,13 +408,13 @@ export const AuthOtherClientSessionsRevokeResult = Schema.Struct({ }); export type AuthOtherClientSessionsRevokeResult = typeof AuthOtherClientSessionsRevokeResult.Type; -export class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( +class EnvironmentMetadataHttpApi extends HttpApiGroup.make("metadata").add( HttpApiEndpoint.get("descriptor", "/.well-known/t3/environment", { success: ExecutionEnvironmentDescriptor, }), ) {} -export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") +class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") .add( HttpApiEndpoint.get("session", "/api/auth/session", { headers: OptionalBearerHeaders, @@ -538,7 +538,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr ) {} /** Large, compressible pull-request payloads travel over HTTP rather than the RPC socket. */ -export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( +class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullRequests").add( HttpApiEndpoint.post("diff", "/api/pull-requests/diff", { headers: OptionalBearerHeaders, payload: PullRequestDiffInput, @@ -553,7 +553,7 @@ export class EnvironmentPullRequestsHttpApi extends HttpApiGroup.make("pullReque }).middleware(EnvironmentAuthenticatedAuth), ) {} -export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") +class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..345adcc7849c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ /** Optional for compatibility with older servers and providers. */ isDraft: Schema.optional(Schema.Boolean), /** - * Last provider-side activity (ISO). For a merged/closed change request - * this bounds when it reached that state, so clients can tell a PR that - * terminated during a thread's life from one that was already history - * when the thread was created. Optional for old servers and providers - * whose lookups do not report it. + * Last provider-side activity (ISO), including comments and metadata edits. + * This is not the time a change request closed or merged. Optional for old + * servers and providers whose lookups do not report it. */ updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9d7cfd30d628..74a1b4939f1a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 71b2547e532c..43e7645b3ebf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; -export const MAX_KEYBINDING_WHEN_LENGTH = 256; +const MAX_KEYBINDING_WHEN_LENGTH = 256; export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; @@ -34,7 +34,7 @@ export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ export type ModelPickerJumpKeybindingCommand = (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; -export const THREAD_KEYBINDING_COMMANDS = [ +const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", "thread.copyReference", @@ -44,7 +44,7 @@ export const THREAD_KEYBINDING_COMMANDS = [ ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; -export const MODEL_PICKER_KEYBINDING_COMMANDS = [ +const MODEL_PICKER_KEYBINDING_COMMANDS = [ "modelPicker.toggle", ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d7e33ebb713b..b3dbc4188c23 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; +import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; import { DEFAULT_PROVIDER_INTERACTION_MODE, @@ -783,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("decodes active reorder commands through client and orchestration boundaries", () => + Effect.gen(function* () { + const input = { + type: "thread.active.reorder", + commandId: "cmd-active-reorder", + threadId: "thread-1", + orderKey: "gm", + }; + const clientCommand = yield* decodeClientOrchestrationCommand(input); + const command = yield* decodeOrchestrationCommand(input); + for (const decoded of [clientCommand, command]) { + assert.strictEqual(decoded.type, "thread.active.reorder"); + if (decoded.type === "thread.active.reorder") { + assert.strictEqual(decoded.threadId, "thread-1"); + assert.strictEqual(decoded.orderKey, "gm"); + } + } + const emptyKey = yield* Effect.exit( + decodeClientOrchestrationCommand({ ...input, orderKey: " " }), + ); + assert.isTrue(Exit.isFailure(emptyKey)); + }), +); + +it.effect("decodes active placement on existing metadata events while accepting old payloads", () => + Effect.gen(function* () { + const payload = { threadId: "thread-1", updatedAt: "2026-01-01T00:00:00.000Z" }; + const oldPayload = yield* decodeThreadMetaUpdatedPayload(payload); + assert.strictEqual(oldPayload.activeOrderKey, undefined); + const resetPayload = yield* decodeThreadMetaUpdatedPayload({ + ...payload, + activeOrderKey: null, + }); + assert.strictEqual(resetPayload.activeOrderKey, null); + const event = yield* decodeOrchestrationEvent({ + type: "thread.meta-updated", + sequence: 1, + eventId: "event-active-reorder", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-active-reorder", + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { ...payload, activeOrderKey: "gm" }, + }); + assert.strictEqual(event.type, "thread.meta-updated"); + if (event.type === "thread.meta-updated") { + assert.strictEqual(event.payload.activeOrderKey, "gm"); + assert.strictEqual(event.payload.updatedAt, payload.updatedAt); + } + }), +); + it.effect("accepts a title regeneration intent in thread.meta.update", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationCommand({ @@ -837,6 +893,47 @@ it.effect("accepts an internal title regeneration completion", () => }), ); +it.effect("accepts pull request synchronization only as an internal command", () => + Effect.gen(function* () { + const pullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const command = { + type: "thread.pull-request.sync" as const, + commandId: CommandId.make("cmd-pull-request-sync"), + threadId: ThreadId.make("thread-1"), + projectId: pullRequest.projectId, + snapshotSequence: 12, + expected: { + workspaceRoot: "/workspace/project", + branch: "feature", + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + }, + branchPullRequest: pullRequest, + linkedPullRequest: pullRequest, + }; + + assert.deepStrictEqual(yield* decodeOrchestrationCommand(command), command); + assert.ok(yield* decodeClientOrchestrationCommand(command).pipe(Effect.flip)); + + const cleared = { ...command, branchPullRequest: null }; + assert.deepStrictEqual(yield* decodeOrchestrationCommand(cleared), cleared); + + const metadata = yield* decodeClientOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-forged-branch-pull-request", + threadId: "thread-1", + branchPullRequest: pullRequest, + }); + assert.isFalse("branchPullRequest" in metadata); + }), +); + it.effect("rejects an explicit title combined with title regeneration", () => Effect.gen(function* () { const result = yield* Effect.exit( @@ -1135,6 +1232,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("rejects thread history imports without messages", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.history.import", + commandId: "command-empty-history", + threadId: "thread-1", + messages: [], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17cadc6d1d7f..37f5476fecc8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -522,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({ // servers never need each other's threads to agree on the merged list. // Optional so payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Manual Active placement. Keyless threads retain their creation/re-entry + // order above the arranged run. Settling clears this slot. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -572,6 +576,7 @@ export const OrchestrationThreadShell = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -586,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -800,6 +806,7 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + historyImport: Schema.optional(Schema.Literal(true)), }); const ThreadDeleteCommand = Schema.Struct({ @@ -892,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({ orderKey: TrimmedNonEmptyString, }); +const ThreadActiveReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.active.reorder"), + commandId: CommandId, + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -1055,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1083,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1122,6 +1138,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ).check(Schema.isNonEmpty()), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1168,16 +1198,35 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadPullRequestSyncCommand = Schema.Struct({ + type: Schema.Literal("thread.pull-request.sync"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + snapshotSequence: NonNegativeInt, + expected: Schema.Struct({ + workspaceRoot: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + }), + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadPullRequestSyncCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1339,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, + // Order updates use this existing event so older clients can ignore the + // new field while continuing to decode the event stream. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), title: Schema.optional(TrimmedNonEmptyString), /** Intent marker consumed by the title-generation reactor. Keeping this on the existing event lets older clients safely ignore the new field. */ @@ -1351,6 +1403,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), updatedAt: IsoDateTime, }); @@ -1473,6 +1526,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + historyImport: Schema.optional(Schema.Boolean), origin: Schema.optional(OrchestrationClientOrigin), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 0d17f86e1942..af1baac74f9d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -103,7 +103,7 @@ const RuntimeErrorClass = Schema.Literals([ ]); export type RuntimeErrorClass = typeof RuntimeErrorClass.Type; -export const TOOL_LIFECYCLE_ITEM_TYPES = [ +const TOOL_LIFECYCLE_ITEM_TYPES = [ "command_execution", "file_change", "mcp_tool_call", diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0478b113d61d..554e68c1b200 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ outcome: ProviderConsumeResetCreditOutcome, }); export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; + +/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ +export const UsageLimitsReport = Schema.Struct({ + createdAt: IsoDateTime, + accounts: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + driver: ProviderDriverKind, + label: TrimmedNonEmptyString, + plan: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + sourceLabel: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + displayName: Schema.optional(Schema.String), + accentColor: Schema.optional(Schema.String), + limits: ServerProviderUsageLimits, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type UsageLimitsReport = typeof UsageLimitsReport.Type; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f766578bb1a3..812489a5fb9f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, }); export type PullRequestSummary = typeof PullRequestSummary.Type; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 37221262ebad..cac14af5c7c8 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -875,7 +875,7 @@ export const RelayHealthResponse = Schema.Struct({ }); export type RelayHealthResponse = typeof RelayHealthResponse.Type; -export const RelayHealthGroup = HttpApiGroup.make("health") +const RelayHealthGroup = HttpApiGroup.make("health") .add( HttpApiEndpoint.get("health", "/health", { success: RelayHealthResponse, @@ -884,7 +884,7 @@ export const RelayHealthGroup = HttpApiGroup.make("health") ) .annotate(OpenApi.Description, "Service health and readiness."); -export const RelayMetadataGroup = HttpApiGroup.make("metadata") +const RelayMetadataGroup = HttpApiGroup.make("metadata") .add( HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { success: RelayAuthorizationServerMetadata, @@ -946,7 +946,7 @@ export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( }, ).annotate(OpenApi.Summary, "Unregister a mobile device"); -export const RelayMobileGroup = HttpApiGroup.make("mobile") +const RelayMobileGroup = HttpApiGroup.make("mobile") .add( RelayRegisterDeviceEndpoint, RelayRegisterLiveActivityEndpoint, @@ -956,7 +956,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); -export const RelayClientGroup = HttpApiGroup.make("client") +const RelayClientGroup = HttpApiGroup.make("client") .add( HttpApiEndpoint.get("listEnvironments", "/v1/environments", { headers: RelayBearerRequestHeaders, @@ -1025,7 +1025,7 @@ export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( "Bootstrap endpoint. Send the DPoP proof JWT in the dpop header and the Clerk token in subject_token. The returned access token is bound to the proof key.", ); -export const RelayTokenGroup = HttpApiGroup.make("token") +const RelayTokenGroup = HttpApiGroup.make("token") .add(RelayExchangeDpopAccessTokenEndpoint) .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access."); @@ -1056,12 +1056,12 @@ export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( }, ).annotate(OpenApi.Summary, "Check environment status"); -export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") +const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.") .middleware(RelayDpopClientAuth); -export const RelayServerGroup = HttpApiGroup.make("server") +const RelayServerGroup = HttpApiGroup.make("server") .add( HttpApiEndpoint.post( "publishAgentActivity", diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 3ec1e4de3ef4..ee9d2b3ac258 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -4,7 +4,17 @@ import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchema import { HostPowerSnapshot } from "./background.ts"; import { DesktopUpdateStateSchema } from "./ipc.ts"; -export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; +export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; + +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", @@ -102,6 +112,13 @@ export const ResourceMonitorSampleNowCommand = Schema.Struct({ }); export type ResourceMonitorSampleNowCommand = typeof ResourceMonitorSampleNowCommand.Type; +export const ResourceMonitorProcessTableCommand = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("processTable"), + requestId: TrimmedNonEmptyString, +}); +export type ResourceMonitorProcessTableCommand = typeof ResourceMonitorProcessTableCommand.Type; + export const ResourceMonitorSetSampleIntervalCommand = Schema.Struct({ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), type: Schema.Literal("setSampleInterval"), @@ -137,6 +154,7 @@ export const ResourceMonitorCommand = Schema.Union([ ResourceMonitorSetSampleIntervalCommand, ResourceMonitorSetStreamingCommand, ResourceMonitorSampleNowCommand, + ResourceMonitorProcessTableCommand, ResourceMonitorReadHistoryCommand, ResourceMonitorShutdownCommand, ]); @@ -168,6 +186,21 @@ export const ResourceMonitorSnapshotEvent = Schema.Struct({ }); export type ResourceMonitorSnapshotEvent = typeof ResourceMonitorSnapshotEvent.Type; +export const ResourceMonitorProcessTableEntry = Schema.Struct({ + pid: PositiveInt, + ppid: NonNegativeInt, + name: Schema.String, +}); +export type ResourceMonitorProcessTableEntry = typeof ResourceMonitorProcessTableEntry.Type; + +export const ResourceMonitorProcessTableEvent = Schema.Struct({ + version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), + type: Schema.Literal("processTable"), + requestId: TrimmedNonEmptyString, + processes: Schema.Array(ResourceMonitorProcessTableEntry), +}); +export type ResourceMonitorProcessTableEvent = typeof ResourceMonitorProcessTableEvent.Type; + export const ResourceMonitorHistoryChunkEvent = Schema.Struct({ version: Schema.Literal(RESOURCE_MONITOR_PROTOCOL_VERSION), type: Schema.Literal("historyChunk"), @@ -189,6 +222,7 @@ export type ResourceMonitorErrorEvent = typeof ResourceMonitorErrorEvent.Type; export const ResourceMonitorEvent = Schema.Union([ ResourceMonitorHelloEvent, ResourceMonitorSnapshotEvent, + ResourceMonitorProcessTableEvent, ResourceMonitorHistoryChunkEvent, ResourceMonitorErrorEvent, ]); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7f2c2b6faa7..fb077193c202 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -28,6 +28,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionImportInput, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportResult, + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, @@ -201,6 +210,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -240,6 +250,8 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + agentSessionsScan: "agentSessions.scan", + agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", @@ -312,6 +324,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -367,31 +380,31 @@ export const WS_METHODS = { subscribeResourceTelemetry: "subscribeResourceTelemetry", } as const; -export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { +const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { payload: ServerUpsertKeybindingInput, success: ServerUpsertKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { +const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { payload: ServerRemoveKeybindingInput, success: ServerRemoveKeybindingResult, error: Schema.Union([KeybindingsConfigError, EnvironmentAuthorizationError]), }); -export const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { +const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { payload: Schema.Struct({}), success: Schema.Struct({}), error: EnvironmentAuthorizationError, }); -export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { +const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { +const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** * When supplied, only refresh this specific provider instance. When @@ -408,7 +421,7 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), }); -export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { +const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]), @@ -416,130 +429,130 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); -export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { +const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { payload: ProviderConsumeResetCreditInput, success: ProviderConsumeResetCreditResult, error: ProviderSetupRpcError, }); -export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { +const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { +const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { payload: ProviderAuthCompleteInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { +const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { payload: ProviderAuthCancelInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { +const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, }); -export const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { +const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { payload: ProviderSetupInput, success: ProviderAuthState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { +const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { +const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { payload: ProviderInstallCancelInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { +const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, stream: true, }); -export const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { +const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { payload: ProviderSetupInput, success: ProviderInstallState, error: ProviderSetupRpcError, }); -export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { +const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { payload: ServerSelfUpdateInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateServerWithProgressRpc = Rpc.make( - WS_METHODS.serverUpdateServerWithProgress, - { - payload: ServerSelfUpdateInput, - success: ServerSelfUpdateProgressEvent, - error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsServerUpdateServerWithProgressRpc = Rpc.make(WS_METHODS.serverUpdateServerWithProgress, { + payload: ServerSelfUpdateInput, + success: ServerSelfUpdateProgressEvent, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { +const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { payload: DesktopUpdateCommitInput, success: ServerSelfUpdateResult, error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); -export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { +const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { +const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch }), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); -export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { +const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { +const WsServerGetTraceDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetTraceDiagnostics, { payload: Schema.Struct({}), success: ServerTraceDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { +const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDiagnostics, { payload: Schema.Struct({}), success: ServerProcessDiagnosticsResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetProcessResourceHistoryRpc = Rpc.make( - WS_METHODS.serverGetProcessResourceHistory, - { - payload: ServerProcessResourceHistoryInput, - success: ServerProcessResourceHistoryResult, - error: EnvironmentAuthorizationError, - }, -); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); + +const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { + payload: ServerProcessResourceHistoryInput, + success: ServerProcessResourceHistoryResult, + error: EnvironmentAuthorizationError, +}); -export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( +const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( WS_METHODS.serverGetResourceTelemetryHistory, { payload: ResourceTelemetryHistoryInput, @@ -548,13 +561,13 @@ export const WsServerGetResourceTelemetryHistoryRpc = Rpc.make( }, ); -export const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { +const WsServerRetryResourceTelemetryRpc = Rpc.make(WS_METHODS.serverRetryResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetryRetryResult, error: EnvironmentAuthorizationError, }); -export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { +const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSummary, { payload: UsageSummaryInput, success: UsageSummary, error: Schema.Union([EnvironmentAuthorizationError, UsageReadError]), @@ -564,42 +577,42 @@ export const WsServerGetUsageSummaryRpc = Rpc.make(WS_METHODS.serverGetUsageSumm * Refetches the model rate table ahead of its daily TTL, so a model released * since the last fetch gets priced. The next usage summary uses the new table. */ -export const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { +const WsServerRefreshUsageRatesRpc = Rpc.make(WS_METHODS.serverRefreshUsageRates, { payload: Schema.Struct({}), success: UsagePricing, error: EnvironmentAuthorizationError, }); -export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { +const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, { payload: ServerSignalProcessInput, success: ServerSignalProcessResult, error: EnvironmentAuthorizationError, }); -export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { +const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, error: EnvironmentAuthorizationError, }); -export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { +const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { payload: Schema.Struct({}), success: RelayClientInstallProgressEventSchema, error: Schema.Union([RelayClientInstallFailedError, EnvironmentAuthorizationError]), stream: true, }); -export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { +const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { payload: ClientActivityReportInput, error: EnvironmentAuthorizationError, }); -export const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { +const WsServerReportHostPowerStateRpc = Rpc.make(WS_METHODS.serverReportHostPowerState, { payload: HostPowerSnapshot, error: EnvironmentAuthorizationError, }); -export const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { +const WsServerGetBackgroundPolicyRpc = Rpc.make(WS_METHODS.serverGetBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, @@ -611,7 +624,7 @@ const PullRequestRpcError = Schema.Union([ EnvironmentAuthorizationError, ]); -export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { +const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { payload: PullRequestListInput, success: PullRequestListResult, error: PullRequestRpcError, @@ -622,298 +635,300 @@ export const WsPullRequestsListRpc = Rpc.make(WS_METHODS.pullRequestsList, { * 40-60% of the listing read that answers everything else on the row, so the rows arrive first * and their stats a moment later. */ -export const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { +const WsPullRequestsListStatsRpc = Rpc.make(WS_METHODS.pullRequestsListStats, { payload: PullRequestListStatsInput, success: PullRequestListStatsResult, error: PullRequestRpcError, }); -export const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { +const WsPullRequestsSummaryRpc = Rpc.make(WS_METHODS.pullRequestsSummary, { payload: PullRequestRef, success: PullRequestSummary, error: PullRequestRpcError, }); -export const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { +const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, { payload: PullRequestRef, success: PullRequestDetail, error: PullRequestRpcError, }); -export const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { +const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, { payload: PullRequestRef, success: PullRequestActivity, error: PullRequestRpcError, }); -export const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { +const WsPullRequestsThreadCommentsRpc = Rpc.make(WS_METHODS.pullRequestsThreadComments, { payload: PullRequestThreadCommentsInput, success: PullRequestThreadCommentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { +const WsPullRequestsDiffFileContentsRpc = Rpc.make(WS_METHODS.pullRequestsDiffFileContents, { payload: PullRequestDiffFileContentsInput, success: PullRequestDiffFileContentsResult, error: PullRequestRpcError, }); -export const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { +const WsPullRequestsRunActionRpc = Rpc.make(WS_METHODS.pullRequestsRunAction, { payload: PullRequestActionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { +const WsPullRequestsUpdateRpc = Rpc.make(WS_METHODS.pullRequestsUpdate, { payload: PullRequestUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { +const WsPullRequestsCommentRpc = Rpc.make(WS_METHODS.pullRequestsComment, { payload: PullRequestCommentInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { +const WsPullRequestsUpdateCommentRpc = Rpc.make(WS_METHODS.pullRequestsUpdateComment, { payload: PullRequestCommentUpdateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { +const WsPullRequestsSubmitReviewRpc = Rpc.make(WS_METHODS.pullRequestsSubmitReview, { payload: PullRequestSubmitReviewInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { +const WsPullRequestsReplyToThreadRpc = Rpc.make(WS_METHODS.pullRequestsReplyToThread, { payload: PullRequestThreadReplyInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSetThreadResolutionRpc = Rpc.make( - WS_METHODS.pullRequestsSetThreadResolution, - { - payload: PullRequestThreadResolutionInput, - success: Schema.Void, - error: PullRequestRpcError, - }, -); +const WsPullRequestsSetThreadResolutionRpc = Rpc.make(WS_METHODS.pullRequestsSetThreadResolution, { + payload: PullRequestThreadResolutionInput, + success: Schema.Void, + error: PullRequestRpcError, +}); -export const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { +const WsPullRequestsSetReactionRpc = Rpc.make(WS_METHODS.pullRequestsSetReaction, { payload: PullRequestReactionInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { +const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInvalidate, { payload: PullRequestInvalidateInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make( - WS_METHODS.pullRequestsSubscribeRefreshes, - { - payload: Schema.Struct({}), - success: NonNegativeInt, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsPullRequestsSubscribeRefreshesRpc = Rpc.make(WS_METHODS.pullRequestsSubscribeRefreshes, { + payload: Schema.Struct({}), + success: NonNegativeInt, + error: EnvironmentAuthorizationError, + stream: true, +}); /** * Read on its own rather than as part of the detail: the people who may be asked are only wanted * once somebody opens the menu, and reading them with every change request would spend a request * per host on a list nobody looked at. */ -export const WsPullRequestsReviewerCandidatesRpc = Rpc.make( - WS_METHODS.pullRequestsReviewerCandidates, - { - payload: PullRequestRef, - success: PullRequestReviewerCandidateList, - error: PullRequestRpcError, - }, -); +const WsPullRequestsReviewerCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsReviewerCandidates, { + payload: PullRequestRef, + success: PullRequestReviewerCandidateList, + error: PullRequestRpcError, +}); -export const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { +const WsPullRequestsRequestReviewersRpc = Rpc.make(WS_METHODS.pullRequestsRequestReviewers, { payload: PullRequestReviewerRequestInput, success: Schema.Void, error: PullRequestRpcError, }); /** Read when the label menu opens, for the same reason the reviewer candidates are. */ -export const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { +const WsPullRequestsLabelCandidatesRpc = Rpc.make(WS_METHODS.pullRequestsLabelCandidates, { payload: PullRequestRef, success: PullRequestLabelCandidateList, error: PullRequestRpcError, }); -export const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { +const WsPullRequestsSetLabelsRpc = Rpc.make(WS_METHODS.pullRequestsSetLabels, { payload: PullRequestLabelChangeInput, success: Schema.Void, error: PullRequestRpcError, }); -export const WsSourceControlLookupRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlLookupRepository, - { - payload: SourceControlRepositoryLookupInput, - success: SourceControlRepositoryInfo, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlLookupRepositoryRpc = Rpc.make(WS_METHODS.sourceControlLookupRepository, { + payload: SourceControlRepositoryLookupInput, + success: SourceControlRepositoryInfo, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { +const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { payload: SourceControlCloneRepositoryInput, success: SourceControlCloneRepositoryResult, error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), }); -export const WsSourceControlPublishRepositoryRpc = Rpc.make( - WS_METHODS.sourceControlPublishRepository, - { - payload: SourceControlPublishRepositoryInput, - success: SourceControlPublishRepositoryResult, - error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), - }, -); +const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, { + payload: SourceControlPublishRepositoryInput, + success: SourceControlPublishRepositoryResult, + error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), +}); -export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { +const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { +const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { payload: ProjectSearchContentsInput, success: ProjectSearchContentsResult, error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), }); -export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { +const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, error: Schema.Union([ProjectListEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { +const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { payload: ProjectReadFileInput, success: ProjectReadFileResult, error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); -export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { +const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); -export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { +const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), }); -export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { +const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { payload: FilesystemBrowseInput, success: FilesystemBrowseResult, error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); -export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { +const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + +const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { + payload: AgentSessionImportInput, + success: AgentSessionImportResult, + error: Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + +const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { +const WsAttachmentsCreateUploadUrlRpc = Rpc.make(WS_METHODS.attachmentsCreateUploadUrl, { payload: AttachmentCreateUploadUrlInput, success: AttachmentCreateUploadUrlResult, error: Schema.Union([AttachmentUploadSigningKeyError, EnvironmentAuthorizationError]), }); -export const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { +const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { payload: AttachmentDeleteInput, error: EnvironmentAuthorizationError, }); -export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { +const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { payload: ProviderUploadFeedbackInput, success: ProviderUploadFeedbackResult, error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), }); -export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { +const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { +const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { payload: VcsPullInput, success: VcsPullResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { +const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: VcsStatusInput, success: VcsStatusResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { +const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), stream: true, }); -export const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { +const WsGitResolvePullRequestRpc = Rpc.make(WS_METHODS.gitResolvePullRequest, { payload: GitPullRequestRefInput, success: GitResolvePullRequestResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { +const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePullRequestThread, { payload: GitPreparePullRequestThreadInput, success: GitPreparePullRequestThreadResult, error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]), }); -export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { +const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { payload: VcsListRefsInput, success: VcsListRefsResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { +const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { +const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: VcsRemoveWorktreeInput, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { +const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { payload: VcsCreateRefInput, success: VcsCreateRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { +const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { payload: VcsSwitchRefInput, success: VcsSwitchRefResult, error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); -export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { +const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); @@ -923,172 +938,160 @@ export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { * Not the persisted T3 Review model. Future review sessions should use * review.open* + review.getSnapshot. */ -export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { +const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { +const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, { payload: ReviewDiffFileContentsInput, success: ReviewDiffFileContentsResult, error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); -export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { +const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { +const WsTerminalAttachRpc = Rpc.make(WS_METHODS.terminalAttach, { payload: TerminalAttachInput, success: TerminalAttachStreamEvent, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), stream: true, }); -export const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { +const WsTerminalWriteRpc = Rpc.make(WS_METHODS.terminalWrite, { payload: TerminalWriteInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { +const WsTerminalResizeRpc = Rpc.make(WS_METHODS.terminalResize, { payload: TerminalResizeInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { +const WsTerminalClearRpc = Rpc.make(WS_METHODS.terminalClear, { payload: TerminalClearInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { +const WsTerminalRestartRpc = Rpc.make(WS_METHODS.terminalRestart, { payload: TerminalRestartInput, success: TerminalSessionSnapshot, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { +const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { payload: TerminalCloseInput, error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); -export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { +const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { payload: PreviewOpenInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { +const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { payload: PreviewNavigateInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { +const WsPreviewResizeRpc = Rpc.make(WS_METHODS.previewResize, { payload: PreviewResizeInput, success: PreviewSessionSnapshot, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { +const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { payload: PreviewRefreshInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { +const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { payload: PreviewCloseInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { +const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { payload: PreviewListInput, success: PreviewListResult, error: EnvironmentAuthorizationError, }); -export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { +const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { +const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { payload: PreviewAutomationHost, success: PreviewAutomationStreamEvent, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), stream: true, }); -export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { +const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { payload: PreviewAutomationResponse, error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { +const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { payload: PreviewAutomationHostFocus, error: EnvironmentAuthorizationError, }); -export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { +const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( - WS_METHODS.subscribeDiscoveredLocalServers, - { - payload: Schema.Struct({ - configuredUrls: Schema.optional(ConfiguredLocalServerUrls), - }), - success: DiscoveredLocalServerList, - error: EnvironmentAuthorizationError, - stream: true, - }, -); +const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscoveredLocalServers, { + payload: Schema.Struct({ + configuredUrls: Schema.optional(ConfiguredLocalServerUrls), + }), + success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, + stream: true, +}); -export const WsOrchestrationDispatchCommandRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.dispatchCommand, - { - payload: ClientOrchestrationCommand, - success: OrchestrationRpcSchemas.dispatchCommand.output, - error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.dispatchCommand, { + payload: ClientOrchestrationCommand, + success: OrchestrationRpcSchemas.dispatchCommand.output, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationGetWorkflowScriptRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.getWorkflowScript, - { - payload: OrchestrationRpcSchemas.getWorkflowScript.input, - success: OrchestrationRpcSchemas.getWorkflowScript.output, - error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationGetWorkflowScriptRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getWorkflowScript, { + payload: OrchestrationRpcSchemas.getWorkflowScript.input, + success: OrchestrationRpcSchemas.getWorkflowScript.output, + error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { +const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, { payload: OrchestrationGetTurnDiffInput, success: OrchestrationRpcSchemas.getTurnDiff.output, error: Schema.Union([OrchestrationGetTurnDiffError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationGetFullThreadDiffRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - { - payload: OrchestrationGetFullThreadDiffInput, - success: OrchestrationRpcSchemas.getFullThreadDiff.output, - error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), - }, -); +const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { + payload: OrchestrationGetFullThreadDiffInput, + success: OrchestrationRpcSchemas.getFullThreadDiff.output, + error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), +}); -export const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { +const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { payload: OrchestrationSearchThreadsInput, success: OrchestrationRpcSchemas.searchThreads.output, error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), }); -export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( +const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, { payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, @@ -1097,31 +1100,28 @@ export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( }, ); -export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { +const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { payload: OrchestrationRpcSchemas.subscribeShell.input, success: OrchestrationRpcSchemas.subscribeShell.output, error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), stream: true, }); -export const WsOrchestrationSubscribeThreadRpc = Rpc.make( - ORCHESTRATION_WS_METHODS.subscribeThread, - { - payload: OrchestrationRpcSchemas.subscribeThread.input, - success: OrchestrationRpcSchemas.subscribeThread.output, - error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), - stream: true, - }, -); +const WsOrchestrationSubscribeThreadRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeThread, { + payload: OrchestrationRpcSchemas.subscribeThread.input, + success: OrchestrationRpcSchemas.subscribeThread.output, + error: Schema.Union([OrchestrationGetSnapshotError, EnvironmentAuthorizationError]), + stream: true, +}); -export const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { +const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents, { payload: Schema.Struct({}), success: TerminalEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { +const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { payload: Schema.Struct({}), success: TerminalMetadataStreamEvent, error: EnvironmentAuthorizationError, @@ -1140,34 +1140,40 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon environmentThemes: Schema.optional(Schema.Boolean), /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), + /** + * Whether this client answers `/usage-limits` itself. The server injects + * that command into provider catalogs only for such clients; an older + * client would send it to the provider as an ordinary prompt. + */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), stream: true, }); -export const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { +const WsSubscribeServerLifecycleRpc = Rpc.make(WS_METHODS.subscribeServerLifecycle, { payload: Schema.Struct({}), success: ServerLifecycleStreamEvent, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { +const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), success: AuthAccessStreamEvent, error: Schema.Union([AuthAccessStreamError, EnvironmentAuthorizationError]), stream: true, }); -export const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { +const WsSubscribeBackgroundPolicyRpc = Rpc.make(WS_METHODS.subscribeBackgroundPolicy, { payload: Schema.Struct({}), success: BackgroundPolicySnapshot, error: EnvironmentAuthorizationError, stream: true, }); -export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { +const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTelemetry, { payload: Schema.Struct({}), success: ResourceTelemetrySnapshot, error: EnvironmentAuthorizationError, @@ -1199,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, @@ -1241,6 +1248,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, + WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ba0d6679cfc2..3ea7bed8f1c4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), bootstrapProjectId: Schema.optional(ProjectId), bootstrapThreadId: Schema.optional(ThreadId), + bootstrapProjectCreated: Schema.optional(Schema.Boolean), + bootstrapThreadCreated: Schema.optional(Schema.Boolean), }); export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c5d9c52a7175..dc4da6dde5d6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -136,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); @@ -432,14 +446,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -460,6 +466,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( @@ -615,6 +625,7 @@ describe("ServerSettings environment icon", () => { it("keeps a kind this build knows", () => { expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini"); + expect(decodeServerSettings({ environmentIcon: "linux" }).environmentIcon).toBe("linux"); }); it("decodes a kind from a newer server as null instead of failing the snapshot", () => { @@ -624,5 +635,8 @@ describe("ServerSettings environment icon", () => { it("round-trips through encode", () => { const settings = decodeServerSettings({ environmentIcon: "laptop" }); expect(encodeServerSettings(settings).environmentIcon).toBe("laptop"); + + const linuxSettings = decodeServerSettings({ environmentIcon: "linux" }); + expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cf68dcf62de8..1ddf66cde63c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,12 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { + ForwardCompatibleNullable, + ProjectId, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { @@ -11,7 +16,7 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { ModelSelection, ProjectScript } from "./orchestration.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -31,11 +36,11 @@ import { export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; -export const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; @@ -51,7 +56,7 @@ export const SidebarProjectGroupingMode = Schema.Literals([ "separate", ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; -export const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; +const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; export const SidebarThreadPreviewCount = Schema.Int.check( @@ -61,7 +66,7 @@ export const SidebarThreadPreviewCount = Schema.Int.check( }), ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; -export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; +const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; export const SidebarAutoSettleAfterDays = Schema.Number.check( @@ -71,7 +76,7 @@ export const SidebarAutoSettleAfterDays = Schema.Number.check( }), ); export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; +const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -81,7 +86,7 @@ export const GlassOpacity = Schema.Int.check( }), ); export type GlassOpacity = typeof GlassOpacity.Type; -export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; +const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; export const MIN_APPEARANCE_CONTRAST = 50; export const MAX_APPEARANCE_CONTRAST = 200; @@ -89,7 +94,7 @@ export const AppearanceContrast = Schema.Int.check( Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }), ); export type AppearanceContrast = typeof AppearanceContrast.Type; -export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; +const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; export const MIN_PANEL_ANIMATION_DURATION_MS = 0; export const MAX_PANEL_ANIMATION_DURATION_MS = 400; export const PanelAnimationDurationMs = Schema.Int.check( @@ -99,7 +104,7 @@ export const PanelAnimationDurationMs = Schema.Int.check( }), ); export type PanelAnimationDurationMs = typeof PanelAnimationDurationMs.Type; -export const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; +const DEFAULT_PANEL_ANIMATION_DURATION_MS: PanelAnimationDurationMs = 0; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -135,7 +140,7 @@ export const TerminalFontSize = Schema.Int.check( Schema.isBetween({ minimum: MIN_TERMINAL_FONT_SIZE, maximum: MAX_TERMINAL_FONT_SIZE }), ); export type TerminalFontSize = typeof TerminalFontSize.Type; -export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; +const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; @@ -143,7 +148,7 @@ export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationM export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; -export const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; +const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; const LegacyConfirmQuit = Schema.Boolean.pipe( Schema.decodeTo( @@ -199,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -286,6 +298,13 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace before it treats the client as a fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -416,7 +435,7 @@ export type ProviderSettingsOrder = readonl string >[]; -export function makeProviderSettingsSchema( +function makeProviderSettingsSchema( fields: Fields, options?: { readonly order?: ProviderSettingsOrder | undefined; @@ -849,6 +868,22 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + defaultProjectScripts: Schema.Array(ProjectScript).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -975,7 +1010,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined @@ -1109,6 +1144,18 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + projectScriptOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), + ), + projectAutoPullOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( @@ -1170,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), + loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), @@ -1188,6 +1237,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index be3d70aefadd..b013eea3bb6f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..066253602a49 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,12 +7,18 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, + TerminalProviderEnvironmentError, TerminalResizeInput, TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); +const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; @@ -27,6 +33,28 @@ function decodes(schema: S, input: unknown): boolean { } } +describe("TerminalProviderEnvironmentError", () => { + it("round-trips its required cause without exposing it in the message", () => { + const cause = { operation: "read-secret", detail: "secret backend unavailable" }; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause, + }); + const encoded = encodeTerminalError(error); + const decoded = decodeTerminalError(encoded); + + expect(decoded).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + }); + expect(decoded.message).toBe( + "Could not prepare the terminal environment for provider instance: codex_work", + ); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); +}); + describe("TerminalOpenInput", () => { it("accepts valid open input", () => { expect( @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }, + providerInstanceId: "codex_work", }); expect(parsed.env).toMatchObject({ T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); + expect(parsed.providerInstanceId).toBe("codex_work"); }); it("rejects invalid env keys", () => { @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { }), ).toBe(false); }); + + it("rejects invalid provider instance ids", () => { + for (const providerInstanceId of ["", "1invalid", "invalid id"]) { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + providerInstanceId, + }), + ).toBe(false); + } + }); }); describe("TerminalAttachInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..36e3d339f521 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass()( + "TerminalProviderInstanceNotFoundError", + { + providerInstanceId: ProviderInstanceId, + }, +) { + override get message() { + return `Provider instance is not available: ${this.providerInstanceId}`; + } +} + +export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass()( + "TerminalProviderEnvironmentError", + { + providerInstanceId: ProviderInstanceId, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; + } +} + export class TerminalNotRunningError extends Schema.TaggedErrorClass()( "TerminalNotRunningError", { @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalNotRunningError, TerminalWriteError, TerminalResizeError, diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index bff3491c3aa9..8e209fe01bd9 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -254,6 +254,7 @@ interface AcpCoreAgentRequestHandlers { const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( stdio: Stdio.Stdio, options: AcpAgentOptions = {}, diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index a3d8dfa31d9b..b3bcf3cd2433 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -585,11 +585,6 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); -export const layer = ( - stdio: AcpProtocol.AcpStdio, - options: AcpClientOptions = {}, -): Layer.Layer => Layer.effect(AcpClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index a3e6ffa65c2e..d79a5701ec85 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -3,7 +3,7 @@ import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; -export const AcpRequestOperation = Schema.Literals([ +const AcpRequestOperation = Schema.Literals([ "decode-extension-request-payload", "encode-extension-response", "handle-request", @@ -11,12 +11,12 @@ export const AcpRequestOperation = Schema.Literals([ "receive-response", "receive-streaming-response", ]); -export type AcpRequestOperation = typeof AcpRequestOperation.Type; +type AcpRequestOperation = typeof AcpRequestOperation.Type; export const AcpRequestId = Schema.Union([Schema.String, Schema.Number]); export type AcpRequestId = typeof AcpRequestId.Type; -export const AcpSchemaIssueKind = Schema.Literals([ +const AcpSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -29,9 +29,9 @@ export const AcpSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; +type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; -export interface AcpSchemaIssueDiagnostics { +interface AcpSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 93d903e78729..5026645374eb 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -4,127 +4,127 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as AcpSchema from "./_generated/schema.gen.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; -export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { +const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { payload: AcpSchema.InitializeRequest, success: AcpSchema.InitializeResponse, error: AcpSchema.Error, }); -export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { +const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { payload: AcpSchema.AuthenticateRequest, success: AcpSchema.AuthenticateResponse, error: AcpSchema.Error, }); -export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { +const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { payload: AcpSchema.LogoutRequest, success: AcpSchema.LogoutResponse, error: AcpSchema.Error, }); -export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { +const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { payload: AcpSchema.NewSessionRequest, success: AcpSchema.NewSessionResponse, error: AcpSchema.Error, }); -export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { +const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { payload: AcpSchema.LoadSessionRequest, success: AcpSchema.LoadSessionResponse, error: AcpSchema.Error, }); -export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { +const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { payload: AcpSchema.ListSessionsRequest, success: AcpSchema.ListSessionsResponse, error: AcpSchema.Error, }); -export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { +const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { payload: AcpSchema.ForkSessionRequest, success: AcpSchema.ForkSessionResponse, error: AcpSchema.Error, }); -export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { +const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { payload: AcpSchema.ResumeSessionRequest, success: AcpSchema.ResumeSessionResponse, error: AcpSchema.Error, }); -export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { +const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { payload: AcpSchema.CloseSessionRequest, success: AcpSchema.CloseSessionResponse, error: AcpSchema.Error, }); -export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { +const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { payload: AcpSchema.PromptRequest, success: AcpSchema.PromptResponse, error: AcpSchema.Error, }); -export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { +const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { payload: AcpSchema.SetSessionModelRequest, success: AcpSchema.SetSessionModelResponse, error: AcpSchema.Error, }); -export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { +const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { payload: AcpSchema.SetSessionConfigOptionRequest, success: AcpSchema.SetSessionConfigOptionResponse, error: AcpSchema.Error, }); -export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { +const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { payload: AcpSchema.ReadTextFileRequest, success: AcpSchema.ReadTextFileResponse, error: AcpSchema.Error, }); -export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { +const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { payload: AcpSchema.WriteTextFileRequest, success: AcpSchema.WriteTextFileResponse, error: AcpSchema.Error, }); -export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { +const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { payload: AcpSchema.RequestPermissionRequest, success: AcpSchema.RequestPermissionResponse, error: AcpSchema.Error, }); -export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { +const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { payload: AcpSchema.ElicitationRequest, success: AcpSchema.ElicitationResponse, error: AcpSchema.Error, }); -export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { +const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { payload: AcpSchema.CreateTerminalRequest, success: AcpSchema.CreateTerminalResponse, error: AcpSchema.Error, }); -export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { +const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { payload: AcpSchema.TerminalOutputRequest, success: AcpSchema.TerminalOutputResponse, error: AcpSchema.Error, }); -export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { +const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { payload: AcpSchema.ReleaseTerminalRequest, success: AcpSchema.ReleaseTerminalResponse, error: AcpSchema.Error, }); -export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { +const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { payload: AcpSchema.WaitForTerminalExitRequest, success: AcpSchema.WaitForTerminalExitResponse, error: AcpSchema.Error, }); -export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { +const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { payload: AcpSchema.KillTerminalRequest, success: AcpSchema.KillTerminalResponse, error: AcpSchema.Error, diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..8bcb59467d3d 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -5,7 +5,7 @@ import * as CodexError from "../errors.ts"; export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); -export const JsonRpcError = Schema.Struct({ +const JsonRpcError = Schema.Struct({ code: Schema.Number, message: Schema.String, data: Schema.optional(Schema.Unknown), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23a..78d719626139 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -84,7 +84,7 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( +const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, @@ -250,11 +250,6 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); -export const layer = ( - stdio: Stdio.Stdio, - options: CodexAppServerClientOptions = {}, -): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3803b4d40659..f0bf470c251c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,15 +1,15 @@ import * as Schema from "effect/Schema"; import type * as SchemaIssue from "effect/SchemaIssue"; -export const CodexAppServerRequestOperation = Schema.Literals([ +const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", "receive-response", ]); -export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; +type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; -export const CodexAppServerSchemaIssueKind = Schema.Literals([ +const CodexAppServerSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -22,9 +22,9 @@ export const CodexAppServerSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; +type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; -export interface CodexAppServerSchemaIssueDiagnostics { +interface CodexAppServerSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; @@ -62,7 +62,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIs }; }; -export const CodexAppServerPayloadKind = Schema.Literals([ +const CodexAppServerPayloadKind = Schema.Literals([ "null", "array", "string", @@ -74,7 +74,7 @@ export const CodexAppServerPayloadKind = Schema.Literals([ "function", "undefined", ]); -export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; +type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { if (payload === null) return "null"; @@ -84,8 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; -export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); -export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; +const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); export interface CodexAppServerRequestDiagnostics { readonly method?: string; diff --git a/packages/fork-core/package.json b/packages/fork-core/package.json new file mode 100644 index 000000000000..c2b091f387d6 --- /dev/null +++ b/packages/fork-core/package.json @@ -0,0 +1,40 @@ +{ + "name": "@q1code/core", + "private": true, + "type": "module", + "exports": { + "./flags": { + "types": "./src/flags.ts", + "import": "./src/flags.ts" + }, + "./brand": { + "types": "./src/brand.ts", + "import": "./src/brand.ts" + }, + "./config": { + "types": "./src/config.ts", + "import": "./src/config.ts" + }, + "./prism": { + "types": "./src/prism.ts", + "import": "./src/prism.ts" + }, + "./prismApi": { + "types": "./src/prismApi.ts", + "import": "./src/prismApi.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "vp test run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/fork-core/src/brand.test.ts b/packages/fork-core/src/brand.test.ts new file mode 100644 index 000000000000..9f6b02949b25 --- /dev/null +++ b/packages/fork-core/src/brand.test.ts @@ -0,0 +1,48 @@ +import { assert, it } from "@effect/vitest"; + +import { + BRAND, + formatAboutVersion, + manualInstallCommand, + releaseChecksumsUrl, + releaseInstallScriptUrl, + releaseTarballName, + releaseTarballUrl, + upstreamVersionOf, +} from "./brand.ts"; + +it("derives release asset URLs from the exact version", () => { + assert.equal(releaseTarballName("0.0.39-q1.3"), "q1code-0.0.39-q1.3.tgz"); + assert.equal( + releaseTarballUrl("0.0.39-q1.3"), + "https://github.com/q1/q1code/releases/download/v0.0.39-q1.3/q1code-0.0.39-q1.3.tgz", + ); + assert.equal( + releaseChecksumsUrl("0.0.39-q1.3"), + "https://github.com/q1/q1code/releases/download/v0.0.39-q1.3/checksums.txt", + ); + assert.equal( + releaseInstallScriptUrl("0.0.39-q1.3"), + "https://github.com/q1/q1code/releases/download/v0.0.39-q1.3/install.sh", + ); +}); + +it("renders the manual install command with the pinned version", () => { + assert.equal( + manualInstallCommand("0.0.39-q1.3"), + "curl -fsSL https://github.com/q1/q1code/releases/download/v0.0.39-q1.3/install.sh | sh -s -- 0.0.39-q1.3", + ); +}); + +it("maps q1code versions back to their upstream version", () => { + assert.equal(upstreamVersionOf("0.0.39-q1.3"), "0.0.39"); + assert.equal(upstreamVersionOf("0.0.39-q1nightly.20260901.12"), "0.0.39"); + assert.equal(upstreamVersionOf("0.0.39"), "0.0.39"); + assert.equal(upstreamVersionOf("0.0.39-nightly.20260901.12"), "0.0.39-nightly.20260901.12"); + assert.equal(formatAboutVersion("0.0.39-q1.3"), "0.0.39-q1.3 on T3 Code 0.0.39"); +}); + +it("keeps the identity frozen", () => { + assert.isTrue(Object.isFrozen(BRAND)); + assert.equal(BRAND.runtimeEntryRelativePath, `node_modules/${BRAND.packageName}/dist/bin.mjs`); +}); diff --git a/packages/fork-core/src/brand.ts b/packages/fork-core/src/brand.ts new file mode 100644 index 000000000000..fe29c461a2c8 --- /dev/null +++ b/packages/fork-core/src/brand.ts @@ -0,0 +1,55 @@ +/** + * The q1code identity. Upstream strings stay upstream everywhere except at the + * seams that identify this build: package/bin name, home directory, service + * unit name, release download URLs, and the app title. Not a global rename. + */ +export const BRAND = Object.freeze({ + productName: "q1code", + cliName: "q1code", + /** Internal npm package name, kept as upstream's so `--filter t3` and service keys stay untouched. */ + packageName: "t3", + /** Prefix for user-facing release assets (tarball name). */ + releaseAssetPrefix: "q1code", + homeDirName: ".q1code", + releaseRepository: "q1/q1code", + releaseBaseUrl: "https://github.com/q1/q1code/releases/download", + /** systemd/launchd unit base name; distinct from stock `t3code` so both can coexist. */ + serviceName: "q1code", + /** Where `npm install ` puts the CLI entry inside a pinned runtime directory. */ + runtimeEntryRelativePath: "node_modules/t3/dist/bin.mjs", + /** Upstream product this build is derived from, shown next to the version. */ + upstreamProductName: "T3 Code", +} as const); + +const releaseVersionTag = (version: string) => `v${version}`; + +/** Release asset filename for the server tarball published by fork-release. */ +export const releaseTarballName = (version: string) => `${BRAND.releaseAssetPrefix}-${version}.tgz`; + +/** Exact-version release asset URL, e.g. `.../download/v0.0.39-q1.1/q1code-0.0.39-q1.1.tgz`. */ +export const releaseAssetUrl = (version: string, asset: string) => + `${BRAND.releaseBaseUrl}/${releaseVersionTag(version)}/${asset}`; + +export const releaseTarballUrl = (version: string) => + releaseAssetUrl(version, releaseTarballName(version)); + +export const releaseChecksumsUrl = (version: string) => releaseAssetUrl(version, "checksums.txt"); + +export const releaseInstallScriptUrl = (version: string) => releaseAssetUrl(version, "install.sh"); + +/** The command handed to users whose server cannot update itself. */ +export const manualInstallCommand = (version: string) => + `curl -fsSL ${releaseInstallScriptUrl(version)} | sh -s -- ${version}`; + +const FORK_PRERELEASE_SUFFIX = /-q1(?:nightly)?\.[0-9A-Za-z.-]+$/; + +/** + * The upstream T3 Code version a q1code version is built on: `0.0.39-q1.3` + * and `0.0.39-q1nightly.20260901.12` both map to `0.0.39`. Versions without a + * q1 suffix are upstream versions already. + */ +export const upstreamVersionOf = (version: string) => version.replace(FORK_PRERELEASE_SUFFIX, ""); + +/** About/version label: ` on T3 Code `. */ +export const formatAboutVersion = (version: string) => + `${version} on ${BRAND.upstreamProductName} ${upstreamVersionOf(version)}`; diff --git a/packages/fork-core/src/config.test.ts b/packages/fork-core/src/config.test.ts new file mode 100644 index 000000000000..cfebc60a7bb1 --- /dev/null +++ b/packages/fork-core/src/config.test.ts @@ -0,0 +1,123 @@ +import * as Exit from "effect/Exit"; +import { describe, expect, it } from "vite-plus/test"; +import { + PRISM_DEFAULT_API_KEY_SECRET_NAME, + PRISM_DEFAULT_MANAGEMENT_SECRET_NAME, + decodeForkConfig, + decodeForkConfigJson, +} from "./config.ts"; + +describe("fork config", () => { + it("decodes a flags section", () => { + const exit = decodeForkConfig({ flags: { "update-check": true } }); + expect(Exit.isSuccess(exit) && exit.value).toEqual({ flags: { "update-check": true } }); + }); + + it("accepts an empty object", () => { + expect(Exit.isSuccess(decodeForkConfig({}))).toBe(true); + }); + + it("drops unknown sections and unknown flag keys", () => { + const exit = decodeForkConfig({ + flags: { prism: true, "not-a-flag": "whatever" }, + future: { anything: 1 }, + }); + expect(Exit.isSuccess(exit) && exit.value).toEqual({ flags: { prism: true } }); + }); + + it("rejects non-boolean values for known flags", () => { + expect(Exit.isFailure(decodeForkConfig({ flags: { prism: "yes" } }))).toBe(true); + }); + + it("decodes raw JSON text and rejects malformed text", () => { + const ok = decodeForkConfigJson('{"flags":{"prism":true}}'); + expect(Exit.isSuccess(ok) && ok.value).toEqual({ flags: { prism: true } }); + expect(Exit.isFailure(decodeForkConfigJson("{not json"))).toBe(true); + }); + + it("decodes the prism section and drops unknown keys inside it", () => { + const exit = decodeForkConfig({ + prism: { + port: 9000, + routingStrategy: "fill-first", + binaryPath: "/opt/cli-proxy-api", + releaseVersion: "7.2.200", + future: true, + }, + }); + expect(Exit.isSuccess(exit) && exit.value).toEqual({ + prism: { + port: 9000, + routingStrategy: "fill-first", + binaryPath: "/opt/cli-proxy-api", + releaseVersion: "7.2.200", + }, + }); + }); + + it("decodes the prism sync section and rejects a bad role or interval", () => { + const exit = decodeForkConfig({ + prism: { + sync: { + role: "replica", + primaryUrl: "http://spark-01:3774", + tokenSecretName: "prism-sync-token", + intervalSeconds: 60, + extra: 1, + }, + }, + }); + expect(Exit.isSuccess(exit) && exit.value).toEqual({ + prism: { + sync: { + role: "replica", + primaryUrl: "http://spark-01:3774", + tokenSecretName: "prism-sync-token", + intervalSeconds: 60, + }, + }, + }); + expect(Exit.isFailure(decodeForkConfig({ prism: { sync: { role: "leader" } } }))).toBe(true); + expect( + Exit.isFailure( + decodeForkConfig({ prism: { sync: { role: "primary", intervalSeconds: 1 } } }), + ), + ).toBe(true); + }); + + it("decodes the prism mode and external section, leaving the mode unset by default", () => { + const exit = decodeForkConfig({ + prism: { + mode: "external", + external: { + baseUrl: "http://127.0.0.1:8317", + managementSecretName: "prism-management", + authDir: "/var/lib/prism/auths", + extra: 1, + }, + }, + }); + expect(Exit.isSuccess(exit) && exit.value).toEqual({ + prism: { + mode: "external", + external: { + baseUrl: "http://127.0.0.1:8317", + managementSecretName: "prism-management", + authDir: "/var/lib/prism/auths", + }, + }, + }); + const sidecar = decodeForkConfig({ prism: { port: 9000 } }); + expect(Exit.isSuccess(sidecar) && sidecar.value.prism?.mode).toBeUndefined(); + expect(Exit.isFailure(decodeForkConfig({ prism: { mode: "container" } }))).toBe(true); + expect(Exit.isFailure(decodeForkConfig({ prism: { external: {} } }))).toBe(true); + expect(PRISM_DEFAULT_MANAGEMENT_SECRET_NAME).toBe("prism-management-secret"); + expect(PRISM_DEFAULT_API_KEY_SECRET_NAME).toBe("prism-api-key"); + }); + + it("rejects an out-of-range port or an unknown routing strategy", () => { + expect(Exit.isFailure(decodeForkConfig({ prism: { port: 70000 } }))).toBe(true); + expect(Exit.isFailure(decodeForkConfig({ prism: { port: 80.5 } }))).toBe(true); + expect(Exit.isFailure(decodeForkConfig({ prism: { routingStrategy: "random" } }))).toBe(true); + }); +}); diff --git a/packages/fork-core/src/config.ts b/packages/fork-core/src/config.ts new file mode 100644 index 000000000000..37c04059bedd --- /dev/null +++ b/packages/fork-core/src/config.ts @@ -0,0 +1,110 @@ +/** + * Schema for `fork.json`, the fork's own config file under the userdata + * directory. Kept separate from upstream `settings.json` so the upstream + * settings contract is never a seam. Unknown top-level and flag keys are + * dropped, not rejected, so an older server can read a newer file. + */ +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { FORK_FLAG_KEYS, type ForkFlagKey } from "./flags.ts"; + +export const FORK_CONFIG_FILENAME = "fork.json"; + +const ForkFlagOverrides = Schema.Struct( + Object.fromEntries(FORK_FLAG_KEYS.map((key) => [key, Schema.optionalKey(Schema.Boolean)])) as { + readonly [K in ForkFlagKey]: Schema.optionalKey; + }, +); + +export const PrismRoutingStrategy = Schema.Literals([ + "round-robin", + "weighted-round-robin", + "fill-first", +]); +export type PrismRoutingStrategy = typeof PrismRoutingStrategy.Type; + +export const PrismSyncRole = Schema.Literals(["primary", "replica"]); +export type PrismSyncRole = typeof PrismSyncRole.Type; + +export const PRISM_SYNC_DEFAULT_INTERVAL_SECONDS = 300; +/** Secret-store names the sync reads when `fork.json` does not name others (`q1code fork secret set `). */ +export const PRISM_SYNC_DEFAULT_TOKEN_SECRET_NAME = "prism-sync-token"; +export const PRISM_SYNC_DEFAULT_KEY_SECRET_NAME = "prism-sync-key"; + +/** + * `prism.sync` section: cross-machine auth-file sync. The bearer token and + * the shared encryption secret are read from the server secret store first + * (`tokenSecretName` / `sharedKeySecretName`, defaulting to the names above), + * then from `Q1CODE_PRISM_SYNC_TOKEN` / `Q1CODE_PRISM_SYNC_KEY`. + */ +export const PrismSyncConfig = Schema.Struct({ + role: PrismSyncRole, + /** Replica only: the primary's environment origin, e.g. `http://spark-01:3774`. */ + primaryUrl: Schema.optionalKey(Schema.String), + /** Replica only: secret-store name of an admin-scoped bearer token issued on the primary. Default `prism-sync-token`. */ + tokenSecretName: Schema.optionalKey(Schema.String), + /** Secret-store name of the shared encryption secret; must match on every environment. Default `prism-sync-key`. */ + sharedKeySecretName: Schema.optionalKey(Schema.String), + /** Replica pull interval. Default 300. */ + intervalSeconds: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(5))), +}); +export type PrismSyncConfig = typeof PrismSyncConfig.Type; + +/** + * `sidecar` (default): q1code spawns and supervises the bundled CLIProxyAPI. + * `external`: q1code manages a CLIProxyAPI that something else runs (a container, + * a system service) through its management API; nothing is spawned. + */ +export const PrismMode = Schema.Literals(["sidecar", "external"]); +export type PrismMode = typeof PrismMode.Type; + +/** Secret-store names the sidecar generates into and external mode reads from (`q1code fork secret set `). */ +export const PRISM_DEFAULT_MANAGEMENT_SECRET_NAME = "prism-management-secret"; +export const PRISM_DEFAULT_API_KEY_SECRET_NAME = "prism-api-key"; + +/** `prism.external` section: where the externally managed CLIProxyAPI lives and how to talk to it. */ +export const PrismExternalConfig = Schema.Struct({ + /** Origin of the running proxy, e.g. `http://127.0.0.1:8317`. No trailing slash, no path. */ + baseUrl: Schema.String, + /** Secret-store name of the proxy's `remote-management.secret-key`. Default `prism-management-secret`. */ + managementSecretName: Schema.optionalKey(Schema.String), + /** Secret-store name of an API key the proxy accepts from clients; provider CLIs send it. Default `prism-api-key`. */ + apiKeySecretName: Schema.optionalKey(Schema.String), + /** The proxy's `auth-dir` on this host, when sync should read and write it directly. */ + authDir: Schema.optionalKey(Schema.String), +}); +export type PrismExternalConfig = typeof PrismExternalConfig.Type; + +/** `prism` section: the few sidecar knobs a user may pin from the file. Everything else is generated. */ +export const PrismConfig = Schema.Struct({ + /** Default `sidecar`. `external` requires the `external` section. */ + mode: Schema.optionalKey(PrismMode), + external: Schema.optionalKey(PrismExternalConfig), + /** Publish the pooled accounts to the Limits view as a usage-limit source (upstream's CLI proxy hub kind). Default true. */ + usageSource: Schema.optionalKey(Schema.Boolean), + /** Loopback port the sidecar listens on. Default 8317. Ignored in external mode. */ + port: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + routingStrategy: Schema.optionalKey(PrismRoutingStrategy), + /** Use this executable instead of the bundled or downloaded one. */ + binaryPath: Schema.optionalKey(Schema.String), + /** Download this upstream release instead of the pinned one. */ + releaseVersion: Schema.optionalKey(Schema.String), + sync: Schema.optionalKey(PrismSyncConfig), +}); +export type PrismConfig = typeof PrismConfig.Type; + +export const ForkConfig = Schema.Struct({ + flags: Schema.optionalKey(ForkFlagOverrides), + prism: Schema.optionalKey(PrismConfig), +}); +export type ForkConfig = typeof ForkConfig.Type; + +export const EMPTY_FORK_CONFIG: ForkConfig = {}; + +/** Decode an already-parsed JSON value. */ +export const decodeForkConfig: (input: unknown) => Exit.Exit = + Schema.decodeUnknownExit(ForkConfig); + +/** Decode raw file contents. */ +export const decodeForkConfigJson: (input: unknown) => Exit.Exit = + Schema.decodeUnknownExit(Schema.fromJsonString(ForkConfig)); diff --git a/packages/fork-core/src/flags.test.ts b/packages/fork-core/src/flags.test.ts new file mode 100644 index 000000000000..584dff6688f0 --- /dev/null +++ b/packages/fork-core/src/flags.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_FORK_FLAGS, + FORK_FLAGS, + envVarForFlag, + isForkFlagKey, + resolveForkFlags, +} from "./flags.ts"; + +describe("fork flags", () => { + it("derives env var names from slugs", () => { + expect(envVarForFlag("update-check")).toBe("T3FORK_UPDATE_CHECK"); + expect(envVarForFlag("prism")).toBe("T3FORK_PRISM"); + }); + + it("falls back to registry defaults when nothing is set", () => { + expect(resolveForkFlags({})).toEqual(DEFAULT_FORK_FLAGS); + expect(DEFAULT_FORK_FLAGS["update-check"]).toBe(FORK_FLAGS["update-check"].default); + }); + + it("file values beat defaults", () => { + expect(resolveForkFlags({ file: { "update-check": true } })["update-check"]).toBe(true); + }); + + it("env beats file, accepting 1/0 and true/false", () => { + const file = { "update-check": true, prism: true }; + expect(resolveForkFlags({ env: { T3FORK_UPDATE_CHECK: "0" }, file })["update-check"]).toBe( + false, + ); + expect(resolveForkFlags({ env: { T3FORK_PRISM: "false" }, file }).prism).toBe(false); + expect(resolveForkFlags({ env: { T3FORK_UPDATE_CHECK: "1" } })["update-check"]).toBe(true); + expect(resolveForkFlags({ env: { T3FORK_PRISM: "TRUE" } }).prism).toBe(true); + }); + + it("ignores unparseable env values and falls through to the file", () => { + expect( + resolveForkFlags({ env: { T3FORK_UPDATE_CHECK: "yes" }, file: { "update-check": true } })[ + "update-check" + ], + ).toBe(true); + }); + + it("ignores unknown keys in env and file", () => { + const resolved = resolveForkFlags({ + env: { T3FORK_NOT_A_FLAG: "1" }, + file: { "not-a-flag": true }, + }); + expect(resolved).toEqual(DEFAULT_FORK_FLAGS); + expect(Object.keys(resolved)).toEqual(Object.keys(FORK_FLAGS)); + expect(isForkFlagKey("not-a-flag")).toBe(false); + expect(isForkFlagKey("prism")).toBe(true); + }); +}); diff --git a/packages/fork-core/src/flags.ts b/packages/fork-core/src/flags.ts new file mode 100644 index 000000000000..1d68a594eafa --- /dev/null +++ b/packages/fork-core/src/flags.ts @@ -0,0 +1,75 @@ +/** + * Fork feature-flag registry. Adding a flag is one entry here; every consumer + * (server resolution, capabilities wire, client hooks, the Settings section) + * derives its types from this object. + * + * Resolution order on the server: `T3FORK_` env var, then `fork.json` + * under the userdata directory, then the registry default. + */ +export const FORK_FLAGS = { + "update-check": { + description: "Poll q1/q1code GitHub Releases daily and surface a newer version to clients", + scope: "server", + default: false, + }, + prism: { + description: + "Run Prism, the bundled CLIProxyAPI-based account proxy, or manage an external one, and route provider CLIs through it", + scope: "both", + default: false, + }, +} as const satisfies Record; + +export interface ForkFlagDefinition { + readonly description: string; + /** Who consults the flag: the server, the clients, or both. */ + readonly scope: "server" | "client" | "both"; + readonly default: boolean; +} + +export type ForkFlagKey = keyof typeof FORK_FLAGS; +export type ForkFlagValues = Readonly>; + +export const FORK_FLAG_KEYS: ReadonlyArray = Object.keys( + FORK_FLAGS, +) as Array; + +export const isForkFlagKey = (key: string): key is ForkFlagKey => + Object.prototype.hasOwnProperty.call(FORK_FLAGS, key); + +export const DEFAULT_FORK_FLAGS: ForkFlagValues = Object.fromEntries( + FORK_FLAG_KEYS.map((key) => [key, FORK_FLAGS[key].default]), +) as Record; + +/** Environment variable that overrides a flag: `update-check` -> `T3FORK_UPDATE_CHECK`. */ +export const envVarForFlag = (key: ForkFlagKey): string => + `T3FORK_${key.replace(/-/g, "_").toUpperCase()}`; + +const parseEnvFlag = (raw: string | undefined): boolean | undefined => { + switch (raw?.trim().toLowerCase()) { + case "1": + case "true": + return true; + case "0": + case "false": + return false; + default: + return undefined; + } +}; + +export interface ResolveForkFlagsInput { + /** Process environment; only `T3FORK_*` entries are consulted. */ + readonly env?: Readonly> | undefined; + /** The `flags` section of `fork.json`. Unknown keys are ignored. */ + readonly file?: Readonly> | undefined; +} + +/** Resolve every registry flag: env beats file beats registry default. Pure. */ +export const resolveForkFlags = ({ env, file }: ResolveForkFlagsInput): ForkFlagValues => + Object.fromEntries( + FORK_FLAG_KEYS.map((key) => [ + key, + parseEnvFlag(env?.[envVarForFlag(key)]) ?? file?.[key] ?? FORK_FLAGS[key].default, + ]), + ) as Record; diff --git a/packages/fork-core/src/prism.pin.json b/packages/fork-core/src/prism.pin.json new file mode 100644 index 000000000000..ff84dcb574ce --- /dev/null +++ b/packages/fork-core/src/prism.pin.json @@ -0,0 +1,12 @@ +{ + "version": "7.2.151-prism.1", + "repository": "q1/prism", + "platforms": { + "darwin-arm64": "darwin_aarch64", + "darwin-x64": "darwin_amd64", + "linux-arm64": "linux_aarch64", + "linux-x64": "linux_amd64", + "win32-arm64": "windows_aarch64", + "win32-x64": "windows_amd64" + } +} diff --git a/packages/fork-core/src/prism.test.ts b/packages/fork-core/src/prism.test.ts new file mode 100644 index 000000000000..b8254a3f37a2 --- /dev/null +++ b/packages/fork-core/src/prism.test.ts @@ -0,0 +1,99 @@ +import { assert, it } from "@effect/vitest"; + +import { + PRISM_PIN, + prismAssetName, + prismChecksumsUrl, + prismExecutableName, + prismPlatformKey, + prismReleaseUrl, + prismAccountHealth, +} from "./prism.ts"; + +it("distinguishes renewal, reauthentication, cooldown, and unknown account state", () => { + const now = Date.parse("2026-09-04T12:00:00Z"); + assert.equal(prismAccountHealth({ disabled: false }, now), "unknown"); + assert.equal( + prismAccountHealth({ disabled: false, lifecycle: { status: "active" } }, now), + "ready", + ); + assert.equal( + prismAccountHealth( + { disabled: false, lifecycle: { status: "active", expiresAt: "2026-09-04T11:00:00Z" } }, + now, + ), + "expired", + ); + assert.equal( + prismAccountHealth( + { + disabled: false, + lifecycle: { + status: "error", + unavailable: true, + lastErrorStatus: 401, + requiresLogin: true, + }, + }, + now, + ), + "needs-login", + ); + assert.equal( + prismAccountHealth( + { + disabled: false, + lifecycle: { unavailable: true, lastErrorStatus: 429, retryAt: "2026-09-04T13:00:00Z" }, + }, + now, + ), + "cooldown", + ); + assert.equal( + prismAccountHealth( + { disabled: false, lifecycle: { unavailable: true, lastErrorStatus: 503 } }, + now, + ), + "unavailable", + ); + assert.equal( + prismAccountHealth({ disabled: true, lifecycle: { status: "active" } }, now), + "disabled", + ); +}); + +it("maps host platforms to release targets the way the resource monitor does", () => { + assert.equal(prismPlatformKey("darwin", "arm64"), "darwin-arm64"); + assert.equal(prismPlatformKey("linux", "x64"), "linux-x64"); + assert.equal(prismPlatformKey("linux", "arm64"), "linux-arm64"); + assert.equal(prismPlatformKey("win32", "x64"), "win32-x64"); + assert.equal(prismPlatformKey("freebsd", "x64"), undefined); + assert.equal(prismPlatformKey("linux", "ia32"), undefined); +}); + +it("names assets from the pin and an explicit version", () => { + assert.equal( + prismAssetName("darwin", "arm64"), + `CLIProxyAPI_${PRISM_PIN.version}_darwin_aarch64.tar.gz`, + ); + assert.equal(prismAssetName("linux", "x64", "1.2.3"), "CLIProxyAPI_1.2.3_linux_amd64.tar.gz"); + assert.equal(prismAssetName("win32", "x64", "1.2.3"), "CLIProxyAPI_1.2.3_windows_amd64.zip"); + assert.equal(prismAssetName("aix", "x64"), undefined); +}); + +it("builds release URLs under the pinned repository tag", () => { + assert.equal( + prismReleaseUrl("linux", "arm64", "7.2.147"), + "https://github.com/q1/prism/releases/download/v7.2.147/CLIProxyAPI_7.2.147_linux_aarch64.tar.gz", + ); + assert.equal( + prismChecksumsUrl("7.2.147"), + "https://github.com/q1/prism/releases/download/v7.2.147/checksums.txt", + ); + assert.equal(prismReleaseUrl("sunos", "x64"), undefined); +}); + +it("uses the archive's executable name per platform", () => { + assert.equal(prismExecutableName("linux"), "cli-proxy-api"); + assert.equal(prismExecutableName("win32"), "cli-proxy-api.exe"); +}); diff --git a/packages/fork-core/src/prism.ts b/packages/fork-core/src/prism.ts new file mode 100644 index 000000000000..f214bff6a087 --- /dev/null +++ b/packages/fork-core/src/prism.ts @@ -0,0 +1,92 @@ +/** + * The pinned CLIProxyAPI release and the pure helpers that name its assets. + * `prism.pin.json` is the single place the version lives: the server's + * on-demand download, the release workflow's bundling step, and the Codex/ + * Claude wiring all read it from here. + */ +import pin from "./prism.pin.json" with { type: "json" }; +import type { PrismAccount } from "./prismApi.ts"; + +export const PRISM_PIN = pin; + +/** Name of the executable inside every release archive. */ +export const PRISM_BINARY_NAME = "cli-proxy-api"; + +export const PRISM_DEFAULT_PORT = 8317; + +/** Authenticated, local-only gateway state; readiness must not depend on GitHub release access. */ +export const PRISM_MANAGEMENT_PROBE_PATH = "/routing/strategy"; + +/** Separates an expired access token from an account that needs a new login. */ +export const prismAccountHealth = ( + account: Pick, + now: number, +) => { + if (account.disabled) return "disabled"; + const lifecycle = account.lifecycle; + if (lifecycle === undefined) return "unknown"; + if (lifecycle.requiresLogin === true) return "needs-login"; + if (lifecycle.retryAt !== undefined && Date.parse(lifecycle.retryAt) > now) return "cooldown"; + if (lifecycle.unavailable || lifecycle.status === "error") return "unavailable"; + if (lifecycle.expiresAt !== undefined && Date.parse(lifecycle.expiresAt) <= now) return "expired"; + if (lifecycle.status === "active") return "ready"; + return "unknown"; +}; + +export const PRISM_ACCOUNT_HEALTH_LABELS = { + disabled: "Disabled", + "needs-login": "Sign-in required", + cooldown: "Waiting to retry", + unavailable: "Unavailable", + expired: "Token expired", + ready: "Ready", + unknown: "Health unknown", +} as const; + +export type PrismPlatformKey = keyof typeof pin.platforms; + +export const PRISM_PLATFORM_KEYS = Object.keys(pin.platforms) as ReadonlyArray; + +/** `darwin-arm64`, `linux-x64`, ... matching the resource monitor's bundle layout, or undefined when no release exists. */ +export const prismPlatformKey = ( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, +): PrismPlatformKey | undefined => { + const key = `${platform}-${architecture}`; + return Object.prototype.hasOwnProperty.call(pin.platforms, key) + ? (key as PrismPlatformKey) + : undefined; +}; + +/** Windows releases are zip archives; everything else is a gzipped tarball. */ +export const prismArchiveKind = (platform: NodeJS.Platform): "tar.gz" | "zip" => + platform === "win32" ? "zip" : "tar.gz"; + +export const prismExecutableName = (platform: NodeJS.Platform): string => + platform === "win32" ? `${PRISM_BINARY_NAME}.exe` : PRISM_BINARY_NAME; + +/** `CLIProxyAPI_7.2.147_darwin_aarch64.tar.gz`, or undefined for an unsupported platform. */ +export const prismAssetName = ( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + version: string = pin.version, +): string | undefined => { + const key = prismPlatformKey(platform, architecture); + if (key === undefined) return undefined; + return `CLIProxyAPI_${version}_${pin.platforms[key]}.${prismArchiveKind(platform)}`; +}; + +const releaseAssetUrl = (version: string, asset: string) => + `https://github.com/${pin.repository}/releases/download/v${version}/${asset}`; + +export const prismReleaseUrl = ( + platform: NodeJS.Platform, + architecture: NodeJS.Architecture, + version: string = pin.version, +): string | undefined => { + const asset = prismAssetName(platform, architecture, version); + return asset === undefined ? undefined : releaseAssetUrl(version, asset); +}; + +export const prismChecksumsUrl = (version: string = pin.version): string => + releaseAssetUrl(version, "checksums.txt"); diff --git a/packages/fork-core/src/prismApi.test.ts b/packages/fork-core/src/prismApi.test.ts new file mode 100644 index 000000000000..403e8797c3d9 --- /dev/null +++ b/packages/fork-core/src/prismApi.test.ts @@ -0,0 +1,126 @@ +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; +import { + PRISM_API_PATHS, + PrismAccount, + PrismAccountId, + PrismHttpApi, + PrismStatus, + PrismSyncBundle, + PrismUnavailableError, +} from "./prismApi.ts"; + +const decodeAccountId = Schema.decodeUnknownExit(PrismAccountId); +const decodeStatus = Schema.decodeUnknownExit(PrismStatus); +const decodeAccount = Schema.decodeUnknownExit(PrismAccount); +const decodeBundle = Schema.decodeUnknownExit(PrismSyncBundle); +const encodeUnavailable = Schema.encodeUnknownSync(PrismUnavailableError); + +describe("prism api contract", () => { + it("keeps every path under the fork prefix", () => { + for (const path of Object.values(PRISM_API_PATHS)) { + expect(path.startsWith("/api/fork/prism/")).toBe(true); + } + }); + + it("declares every endpoint the client runtime calls", () => { + const group = PrismHttpApi.groups.prism; + expect(Object.keys(group.endpoints).sort()).toEqual( + [ + "cancelLogin", + "deleteAccount", + "getRouting", + "getUsage", + "listAccounts", + "loginCallback", + "loginStatus", + "patchAccount", + "restart", + "setUsageSource", + "setRouting", + "startLogin", + "status", + "syncExport", + "syncPush", + "syncStatus", + ].sort(), + ); + }); + + it("accepts only single-segment .json account ids", () => { + expect(Exit.isSuccess(decodeAccountId("claude-me@example.com.json"))).toBe(true); + expect(Exit.isFailure(decodeAccountId("../escape.json"))).toBe(true); + expect(Exit.isFailure(decodeAccountId("nested/file.json"))).toBe(true); + expect(Exit.isFailure(decodeAccountId("config.yaml"))).toBe(true); + }); + + it("round-trips the status, account, and sync bundle shapes", () => { + const status = decodeStatus({ + state: "ready", + port: 8317, + version: "7.2.147", + role: "replica", + lastSyncAt: "2026-09-02T00:00:00.000Z", + }); + expect(Exit.isSuccess(status)).toBe(true); + const account = decodeAccount({ + id: "codex-a.json", + provider: "codex", + label: "a@example.com", + disabled: false, + updatedAt: "2026-09-02T00:00:00.000Z", + }); + expect(Exit.isSuccess(account)).toBe(true); + const withUsage = decodeAccount({ + id: "codex-a.json", + provider: "codex", + label: "a@example.com", + disabled: false, + updatedAt: "2026-09-02T00:00:00.000Z", + usage: { success: 12, failed: 1, quota: { signals: { "5h": "ok" } } }, + }); + expect(Exit.isSuccess(withUsage) && withUsage.value.usage?.success).toBe(12); + const bundle = decodeBundle({ + version: 2, + generatedAt: "2026-09-02T00:00:00.000Z", + primaryEnvironmentId: "env-1", + entries: [{ id: "codex-a.json", updatedAt: "2026-09-02T00:00:00.000Z", ciphertext: "AA==" }], + tombstones: [{ id: "gone.json", deletedAt: "2026-09-01T00:00:00.000Z" }], + }); + expect(Exit.isSuccess(bundle)).toBe(true); + expect(Exit.isFailure(decodeBundle({ version: 3 }))).toBe(true); + }); + + it("still decodes a version 1 bundle, which has no tombstones", () => { + const bundle = decodeBundle({ + version: 1, + generatedAt: "2026-09-02T00:00:00.000Z", + primaryEnvironmentId: "env-1", + entries: [], + }); + expect(Exit.isSuccess(bundle) && bundle.value.tombstones).toBeUndefined(); + expect(Exit.isSuccess(bundle)).toBe(true); + expect( + Exit.isFailure( + decodeBundle({ + version: 2, + generatedAt: "2026-09-02T00:00:00.000Z", + primaryEnvironmentId: "env-1", + entries: [], + tombstones: [{ id: "../escape.json", deletedAt: "2026-09-01T00:00:00.000Z" }], + }), + ), + ).toBe(true); + }); + + it("serializes the unavailable error with its reason and state", () => { + const error = new PrismUnavailableError({ reason: "flag-off", state: "off" }); + expect(encodeUnavailable(error)).toEqual({ + _tag: "PrismUnavailableError", + reason: "flag-off", + state: "off", + }); + expect(error.message).toContain("flag-off"); + }); +}); diff --git a/packages/fork-core/src/prismApi.ts b/packages/fork-core/src/prismApi.ts new file mode 100644 index 000000000000..682e759c5762 --- /dev/null +++ b/packages/fork-core/src/prismApi.ts @@ -0,0 +1,486 @@ +/** + * The q1code accounts API: the one contract the server (`PrismHttpApi.ts`), + * the client runtime (`prismClient.ts`), and the UI share. It fronts the + * CLIProxyAPI management API so the management secret never leaves the box. + * + * Every endpoint sits behind the environment auth middleware, so clients send + * the same bearer/DPoP headers they send to every other environment endpoint + * and decode the same 401/403 errors. + */ +import { EnvironmentAuthenticatedAuth, EnvironmentScopeRequiredError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as HttpServerRespondable from "effect/unstable/http/HttpServerRespondable"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; + +import { PrismMode, PrismRoutingStrategy } from "./config.ts"; + +export const PRISM_API_PREFIX = "/api/fork/prism"; + +/** + * The `usageLimitSources` id and label Prism publishes its pooled accounts + * under (upstream's CLI proxy hub kind), so the Limits view can tell the + * managed source from a hub the user added by hand. + */ +export const PRISM_USAGE_SOURCE_ID = "prism"; +export const PRISM_USAGE_SOURCE_LABEL = "Prism"; + +export const PRISM_API_PATHS = { + status: `${PRISM_API_PREFIX}/status`, + restart: `${PRISM_API_PREFIX}/restart`, + usageSource: `${PRISM_API_PREFIX}/usage-source`, + accounts: `${PRISM_API_PREFIX}/accounts`, + accountsLogin: `${PRISM_API_PREFIX}/accounts/login`, + accountsLoginSession: `${PRISM_API_PREFIX}/accounts/login/:sessionId`, + accountsLoginCallback: `${PRISM_API_PREFIX}/accounts/login/:sessionId/callback`, + account: `${PRISM_API_PREFIX}/accounts/:id`, + routing: `${PRISM_API_PREFIX}/routing`, + usage: `${PRISM_API_PREFIX}/usage`, + syncExport: `${PRISM_API_PREFIX}/sync/export`, + syncPush: `${PRISM_API_PREFIX}/sync/push`, + syncStatus: `${PRISM_API_PREFIX}/sync/status`, +} as const; + +export const PrismState = Schema.Literals(["off", "starting", "ready", "failed"]); +export type PrismState = typeof PrismState.Type; + +export const PrismRole = Schema.Literals(["primary", "replica", "standalone"]); +export type PrismRole = typeof PrismRole.Type; + +/** ISO-8601 timestamps travel as strings; every `updatedAt` below is millisecond precision. */ +const IsoTimestamp = Schema.String; + +export const PrismStatus = Schema.Struct({ + state: PrismState, + port: Schema.Number, + version: Schema.optionalKey(Schema.String), + role: PrismRole, + lastSyncAt: Schema.optionalKey(IsoTimestamp), + lastSyncError: Schema.optionalKey(Schema.String), + /** Absent from servers older than this field; treat as `sidecar`. */ + mode: Schema.optionalKey(PrismMode), + /** The proxy origin provider CLIs are pointed at while `ready`. */ + baseUrl: Schema.optionalKey(Schema.String), + /** Why the proxy is `failed` or keeps restarting; never contains a secret. */ + lastError: Schema.optionalKey(Schema.String), + /** Supervisor restarts (sidecar) or reconnects (external) since the flag turned on. */ + restarts: Schema.optionalKey(Schema.Number), + /** When the current `state` was entered. */ + since: Schema.optionalKey(IsoTimestamp), + /** Whether the pooled accounts are published to the Limits view (`prism.usageSource`, default true). */ + usageSource: Schema.optionalKey(Schema.Boolean), +}); +export type PrismStatus = typeof PrismStatus.Type; + +/** An auth file name: one path segment ending in `.json`. */ +export const PrismAccountId = Schema.String.check(Schema.isPattern(/^[^/\\]+\.json$/)); +export type PrismAccountId = typeof PrismAccountId.Type; + +/** Passive provider quota observations the sidecar attaches to an auth file (`observed_at`, `signals`). */ +export const PrismAccountQuota = Schema.Struct({ + observedAt: Schema.optionalKey(IsoTimestamp), + signals: Schema.Record(Schema.String, Schema.String), +}); +export type PrismAccountQuota = typeof PrismAccountQuota.Type; + +/** Per-account request counters since the sidecar started, from the `/auth-files` entry. */ +export const PrismAccountUsage = Schema.Struct({ + success: Schema.Number, + failed: Schema.Number, + quota: Schema.optionalKey(PrismAccountQuota), +}); +export type PrismAccountUsage = typeof PrismAccountUsage.Type; + +/** Optional observations from the gateway; absent timestamps mean unknown. */ +export const PrismAccountLifecycle = Schema.Struct({ + requiresLogin: Schema.optionalKey(Schema.Boolean), + status: Schema.optionalKey(Schema.String), + unavailable: Schema.optionalKey(Schema.Boolean), + expiresAt: Schema.optionalKey(IsoTimestamp), + lastRefreshedAt: Schema.optionalKey(IsoTimestamp), + refreshNotBefore: Schema.optionalKey(IsoTimestamp), + retryAt: Schema.optionalKey(IsoTimestamp), + lastErrorStatus: Schema.optionalKey(Schema.Number), +}); +export type PrismAccountLifecycle = typeof PrismAccountLifecycle.Type; + +export const PrismAccount = Schema.Struct({ + /** The auth file name, unique per sidecar. */ + id: PrismAccountId, + /** Sidecar provider key: `claude`, `codex`, `gemini`, `antigravity`, ... */ + provider: Schema.String, + label: Schema.String, + email: Schema.optionalKey(Schema.String), + disabled: Schema.Boolean, + weight: Schema.optionalKey(Schema.Number), + updatedAt: IsoTimestamp, + /** Absent when the sidecar reports no counters for the file (older sidecars, disk-only listings). */ + usage: Schema.optionalKey(PrismAccountUsage), + lifecycle: Schema.optionalKey(PrismAccountLifecycle), +}); +export type PrismAccount = typeof PrismAccount.Type; + +export const PrismAccountList = Schema.Struct({ + accounts: Schema.Array(PrismAccount), +}); +export type PrismAccountList = typeof PrismAccountList.Type; + +export const PrismAccountPatch = Schema.Struct({ + disabled: Schema.optionalKey(Schema.Boolean), + weight: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}); +export type PrismAccountPatch = typeof PrismAccountPatch.Type; + +/** The OAuth flows CLIProxyAPI can start from its management API. */ +export const PrismLoginProvider = Schema.Literals([ + "anthropic", + "codex", + "antigravity", + "xai", + "kimi", +]); +export type PrismLoginProvider = typeof PrismLoginProvider.Type; + +export const PrismLoginStart = Schema.Struct({ + provider: PrismLoginProvider, +}); +export type PrismLoginStart = typeof PrismLoginStart.Type; + +export const PrismLoginStarted = Schema.Struct({ + sessionId: Schema.String, + /** Open this in a browser. */ + authUrl: Schema.String, + /** `redirect` flows land on a localhost callback; `device` flows show `userCode` instead. */ + flow: Schema.Literals(["redirect", "device"]), + userCode: Schema.optionalKey(Schema.String), +}); +export type PrismLoginStarted = typeof PrismLoginStarted.Type; + +export const PrismLoginState = Schema.Literals(["pending", "completed", "failed", "cancelled"]); +export type PrismLoginState = typeof PrismLoginState.Type; + +export const PrismLoginStatus = Schema.Struct({ + sessionId: Schema.String, + status: PrismLoginState, + /** The auth file the flow produced, once it shows up in the account list. */ + accountId: Schema.optionalKey(PrismAccountId), + error: Schema.optionalKey(Schema.String), +}); +export type PrismLoginStatus = typeof PrismLoginStatus.Type; + +/** + * For redirect flows whose callback landed in a browser on another machine: + * the user pastes the full `localhost:/callback?code=...&state=...` URL. + */ +export const PrismLoginCallback = Schema.Struct({ + redirectUrl: Schema.String, +}); +export type PrismLoginCallback = typeof PrismLoginCallback.Type; + +export const PrismRouting = Schema.Struct({ + strategy: PrismRoutingStrategy, +}); +export type PrismRouting = typeof PrismRouting.Type; + +/** One bucket of the sidecar's recent-request histogram; shape is the sidecar's, passed through. */ +export const PrismUsageBucket = Schema.Record(Schema.String, Schema.Unknown); + +export const PrismUsageEntry = Schema.Struct({ + success: Schema.Number, + failed: Schema.Number, + recentRequests: Schema.Array(PrismUsageBucket), +}); +export type PrismUsageEntry = typeof PrismUsageEntry.Type; + +/** `GET /api-key-usage`: provider -> `|` -> counters. Only API-key credentials appear. */ +export const PrismUsage = Schema.Record( + Schema.String, + Schema.Record(Schema.String, PrismUsageEntry), +); +export type PrismUsage = typeof PrismUsage.Type; + +export const PrismSyncEntry = Schema.Struct({ + id: PrismAccountId, + updatedAt: IsoTimestamp, + /** base64 of `nonce(12) || tag(16) || AES-256-GCM data`. */ + ciphertext: Schema.String, +}); +export type PrismSyncEntry = typeof PrismSyncEntry.Type; + +/** A deletion that still has to reach the other side. A file stamped strictly later than `deletedAt` beats it. */ +export const PrismSyncTombstone = Schema.Struct({ + id: PrismAccountId, + deletedAt: IsoTimestamp, +}); +export type PrismSyncTombstone = typeof PrismSyncTombstone.Type; + +/** Version 3 is an authoritative serving snapshot; older bundles decode for a clear upgrade error. */ +export const PrismSyncBundle = Schema.Struct({ + version: Schema.Literals([1, 2, 3]), + generatedAt: IsoTimestamp, + primaryEnvironmentId: Schema.String, + entries: Schema.Array(PrismSyncEntry), + tombstones: Schema.optionalKey(Schema.Array(PrismSyncTombstone)), +}); +export type PrismSyncBundle = typeof PrismSyncBundle.Type; + +export const PrismSyncPush = Schema.Struct({ + entries: Schema.Array(PrismSyncEntry), + tombstones: Schema.optionalKey(Schema.Array(PrismSyncTombstone)), +}); +export type PrismSyncPush = typeof PrismSyncPush.Type; + +export const PrismSyncPushResult = Schema.Struct({ + written: Schema.Array(PrismAccountId), + skipped: Schema.Array(PrismAccountId), + /** Files the pushed tombstones removed on the primary. Absent from a version 1 primary. */ + deleted: Schema.optionalKey(Schema.Array(PrismAccountId)), +}); +export type PrismSyncPushResult = typeof PrismSyncPushResult.Type; + +export const PrismSyncStatus = Schema.Struct({ + role: PrismRole, + primaryUrl: Schema.optionalKey(Schema.String), + intervalSeconds: Schema.optionalKey(Schema.Number), + lastSyncAt: Schema.optionalKey(IsoTimestamp), + lastSyncError: Schema.optionalKey(Schema.String), +}); +export type PrismSyncStatus = typeof PrismSyncStatus.Type; + +/** `PUT usage-source` body: whether Prism publishes its pooled accounts to the Limits view. Persisted in `fork.json`. */ +export const PrismUsageSource = Schema.Struct({ enabled: Schema.Boolean }); +export type PrismUsageSource = typeof PrismUsageSource.Type; + +export const PrismOk = Schema.Struct({ ok: Schema.Literal(true) }); +export type PrismOk = typeof PrismOk.Type; + +export const PrismUnavailableReason = Schema.Literals([ + "flag-off", + "sidecar-not-ready", + "sync-not-configured", + "replica-read-only", +]); +export type PrismUnavailableReason = typeof PrismUnavailableReason.Type; + +/** 503: the flag is off, the sidecar is not ready, or sync is not configured for this role. */ +export class PrismUnavailableError extends Schema.TaggedErrorClass()( + "PrismUnavailableError", + { + reason: PrismUnavailableReason, + state: PrismState, + }, + { httpApiStatus: 503 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(PrismUnavailableError)(this, { status: 503 }); + } + + override get message(): string { + return `Prism is unavailable (${this.reason}, state ${this.state}).`; + } +} + +/** 502: the sidecar answered with an error or could not be reached. `status` is the sidecar's. */ +export class PrismUpstreamError extends Schema.TaggedErrorClass()( + "PrismUpstreamError", + { + status: Schema.Number, + message: Schema.String, + }, + { httpApiStatus: 502 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(PrismUpstreamError)(this, { status: 502 }); + } +} + +/** 404: no auth file or login session with that id. */ +export class PrismNotFoundError extends Schema.TaggedErrorClass()( + "PrismNotFoundError", + { + id: Schema.String, + }, + { httpApiStatus: 404 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(PrismNotFoundError)(this, { status: 404 }); + } + + override get message(): string { + return `Prism has nothing named '${this.id}'.`; + } +} + +/** 500: a change the sidecar accepted could not be persisted into `fork.json`, so it would not survive a restart. */ +export class PrismConfigError extends Schema.TaggedErrorClass()( + "PrismConfigError", + { + message: Schema.String, + }, + { httpApiStatus: 500 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(PrismConfigError)(this, { status: 500 }); + } +} + +export const PrismSyncFailureReason = Schema.Literals(["crypto", "io", "transport"]); +export type PrismSyncFailureReason = typeof PrismSyncFailureReason.Type; + +/** 500: a sync export, push, or pull could not complete. Never carries plaintext or keys. */ +export class PrismSyncFailedError extends Schema.TaggedErrorClass()( + "PrismSyncFailedError", + { + reason: PrismSyncFailureReason, + message: Schema.String, + }, + { httpApiStatus: 500 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(PrismSyncFailedError)(this, { status: 500 }); + } +} + +/** Same optional headers every environment endpoint declares, so clients pass bearer/DPoP the same way. */ +const OptionalBearerHeaders = Schema.Struct({ + authorization: Schema.optionalKey(Schema.String), + dpop: Schema.optionalKey(Schema.String), +}); + +const SessionParams = Schema.Struct({ sessionId: Schema.String }); +const AccountParams = Schema.Struct({ id: PrismAccountId }); + +const ScopeErrors = [EnvironmentScopeRequiredError] as const; +const ProxyErrors = [...ScopeErrors, PrismUnavailableError, PrismUpstreamError] as const; +const SyncErrors = [...ScopeErrors, PrismUnavailableError, PrismSyncFailedError] as const; + +export class PrismHttpApiGroup extends HttpApiGroup.make("prism") + .add( + HttpApiEndpoint.get("status", PRISM_API_PATHS.status, { + headers: OptionalBearerHeaders, + success: PrismStatus, + error: ScopeErrors, + }), + ) + .add( + HttpApiEndpoint.post("restart", PRISM_API_PATHS.restart, { + headers: OptionalBearerHeaders, + success: PrismStatus, + error: [...ScopeErrors, PrismUnavailableError], + }), + ) + .add( + HttpApiEndpoint.put("setUsageSource", PRISM_API_PATHS.usageSource, { + headers: OptionalBearerHeaders, + payload: PrismUsageSource, + success: PrismStatus, + error: [...ScopeErrors, PrismUnavailableError, PrismConfigError], + }), + ) + .add( + HttpApiEndpoint.get("listAccounts", PRISM_API_PATHS.accounts, { + headers: OptionalBearerHeaders, + success: PrismAccountList, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.post("startLogin", PRISM_API_PATHS.accountsLogin, { + headers: OptionalBearerHeaders, + payload: PrismLoginStart, + success: PrismLoginStarted, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.get("loginStatus", PRISM_API_PATHS.accountsLoginSession, { + headers: OptionalBearerHeaders, + params: SessionParams, + success: PrismLoginStatus, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.post("loginCallback", PRISM_API_PATHS.accountsLoginCallback, { + headers: OptionalBearerHeaders, + params: SessionParams, + payload: PrismLoginCallback, + success: PrismLoginStatus, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.delete("cancelLogin", PRISM_API_PATHS.accountsLoginSession, { + headers: OptionalBearerHeaders, + params: SessionParams, + success: PrismLoginStatus, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.patch("patchAccount", PRISM_API_PATHS.account, { + headers: OptionalBearerHeaders, + params: AccountParams, + payload: PrismAccountPatch, + success: PrismAccount, + error: [...ProxyErrors, PrismNotFoundError], + }), + ) + .add( + HttpApiEndpoint.delete("deleteAccount", PRISM_API_PATHS.account, { + headers: OptionalBearerHeaders, + params: AccountParams, + success: PrismOk, + error: [...ProxyErrors, PrismNotFoundError], + }), + ) + .add( + HttpApiEndpoint.get("getRouting", PRISM_API_PATHS.routing, { + headers: OptionalBearerHeaders, + success: PrismRouting, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.put("setRouting", PRISM_API_PATHS.routing, { + headers: OptionalBearerHeaders, + payload: PrismRouting, + success: PrismRouting, + error: [...ProxyErrors, PrismConfigError], + }), + ) + .add( + HttpApiEndpoint.get("getUsage", PRISM_API_PATHS.usage, { + headers: OptionalBearerHeaders, + success: PrismUsage, + error: ProxyErrors, + }), + ) + .add( + HttpApiEndpoint.get("syncExport", PRISM_API_PATHS.syncExport, { + headers: OptionalBearerHeaders, + success: PrismSyncBundle, + error: SyncErrors, + }), + ) + .add( + HttpApiEndpoint.post("syncPush", PRISM_API_PATHS.syncPush, { + headers: OptionalBearerHeaders, + payload: PrismSyncPush, + success: PrismSyncPushResult, + error: SyncErrors, + }), + ) + .add( + HttpApiEndpoint.get("syncStatus", PRISM_API_PATHS.syncStatus, { + headers: OptionalBearerHeaders, + success: PrismSyncStatus, + error: ScopeErrors, + }), + ) + .middleware(EnvironmentAuthenticatedAuth) {} + +/** The API both `HttpApiBuilder` (server) and `HttpApiClient` (client runtime) build from. */ +export class PrismHttpApi extends HttpApi.make("q1prism").add(PrismHttpApiGroup) {} diff --git a/packages/fork-core/tsconfig.json b/packages/fork-core/tsconfig.json new file mode 100644 index 000000000000..374bac55202d --- /dev/null +++ b/packages/fork-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json index fd932b8b146b..a9f60297f4a0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -211,6 +211,10 @@ "types": "./src/filePreview.ts", "import": "./src/filePreview.ts" }, + "./imageDimensions": { + "types": "./src/imageDimensions.ts", + "import": "./src/imageDimensions.ts" + }, "./video": { "types": "./src/video.ts", "import": "./src/video.ts" @@ -270,6 +274,10 @@ "./hostClassification": { "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" + }, + "./dateTime": { + "types": "./src/dateTime.ts", + "import": "./src/dateTime.ts" } }, "scripts": { diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 4644576296bc..e3b653692880 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -63,6 +63,7 @@ export class NetService extends Context.Service()( "@t3tools/shared/Net/NetService", ) {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = () => { /** * Returns true when a TCP server can bind to {host, port}. diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index c0f5842eb7c5..77c7c845db31 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -43,7 +43,7 @@ export interface ProjectThreadAwarenessInput { >; } -export function buildAgentAwarenessDeepLink(input: { +function buildAgentAwarenessDeepLink(input: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; }): string { diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 50c8cd7c2080..4b2763854457 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,20 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { serializeComposerFileLink, serializeComposerMentionPath } from "./composerTrigger.ts"; - -describe("serializeComposerMentionPath", () => { - it("keeps simple mention paths unquoted", () => { - expect(serializeComposerMentionPath("src/index.ts")).toBe("src/index.ts"); - }); - - it("quotes mention paths containing whitespace", () => { - expect(serializeComposerMentionPath("docs/My File.md")).toBe('"docs/My File.md"'); - }); - - it("escapes quoted mention path content", () => { - expect(serializeComposerMentionPath('docs/My "File".md')).toBe('"docs/My \\"File\\".md"'); - }); -}); +import { serializeComposerFileLink } from "./composerTrigger.ts"; describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index dcbdc784934b..6176d10d7908 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -8,15 +8,6 @@ export interface ComposerTrigger { rangeEnd: number; } -const SIMPLE_MENTION_PATH_REGEX = /^[^\s@"\\]+$/; - -export function serializeComposerMentionPath(path: string): string { - if (SIMPLE_MENTION_PATH_REGEX.test(path)) { - return path; - } - return `"${path.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; -} - function composerFileLinkBasename(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; @@ -124,18 +115,6 @@ export function detectComposerTrigger( }; } -export function parseStandaloneComposerSlashCommand( - text: string, -): Exclude | null { - const match = /^\/(plan|default)\s*$/i.exec(text.trim()); - if (!match) { - return null; - } - const command = match[1]?.toLowerCase(); - if (command === "plan") return "plan"; - return "default"; -} - export function replaceTextRange( text: string, rangeStart: number, diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts new file mode 100644 index 000000000000..562507de3ac2 --- /dev/null +++ b/packages/shared/src/dateTime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +describe("compareDateTimeStrings", () => { + it("compares valid date-time strings by absolute time", () => { + expect( + compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), + ).toBe(0); + expect( + compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), + ).toBeGreaterThan(0); + }); + + it.each([ + ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], + ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], + ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], + ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], + ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], + ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], + ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], + ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], + ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], + ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], + ])("preserves equal ISO instants %s and %s", (left, right) => { + expect(compareDateTimeStrings(left, right)).toBe(0); + }); + + it("sorts malformed values before valid values", () => { + expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); + expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); + }); + + it.each([ + "2014-02-30", + "2014-03-02", + "2014-03-02T00:00:00", + "2014-03-02T00:00:00.000", + "03/02/2014", + "March 2, 2014", + "Sun, 02 Mar 2014 00:00:00 GMT", + "2014-03-02T00:00:00.000Z\n", + "2014-02-30T00:00:00.000Z", + "1900-02-29T00:00:00.000-07:00", + "2024-04-31T00:00:00.000+05:30", + "2024-03-02T12:00:00.000+24:00", + "2026-09-01T24:01:00Z", + "2026-09-01T24:00:01Z", + "2026-09-01T24:00:00.0001Z", + "2026-09-01T24:01Z", + "2026-09-01T25:00Z", + ])("treats %s as malformed without native date guessing", (malformed) => { + const valid = "1970-01-01T00:00:00.000Z"; + expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); + expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); + expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, malformed)).toBe(0); + }); + + it("uses code-unit order for malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); + expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); + }); + + it("returns zero for equal malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); + }); + + it("gives every permutation of mixed values the same order", () => { + const early = "2026-09-01T12:00:00.000+14:00"; + const late = "2026-09-01T00:00:00.000-12:00"; + const malformed = "2026-09-01T06:invalid"; + const expected = [malformed, early, late]; + + const permutations = [ + [early, late, malformed], + [early, malformed, late], + [late, early, malformed], + [late, malformed, early], + [malformed, early, late], + [malformed, late, early], + ]; + + for (const values of permutations) { + expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); + } + }); +}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts new file mode 100644 index 000000000000..544dd2d0d73b --- /dev/null +++ b/packages/shared/src/dateTime.ts @@ -0,0 +1,38 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const isZonedIsoDateTime = Schema.is( + Schema.String.check( + Schema.isPattern( + /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + ), + Schema.isTrimmed(), + ), +); + +function parseTimestamp(value: string): number { + if (!isZonedIsoDateTime(value)) return Number.NaN; + + // Engines can normalize invalid calendar dates instead of rejecting them. + const datePart = value.slice(0, value.indexOf("T")); + const date = DateTime.make(`${datePart}T00:00:00.000Z`); + if (Option.isNone(date)) return Number.NaN; + const parts = DateTime.toPartsUtc(date.value); + if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { + return Number.NaN; + } + return Date.parse(value); +} + +/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ +export function compareDateTimeStrings(left: string, right: string): number { + const leftTimestamp = parseTimestamp(left); + const rightTimestamp = parseTimestamp(right); + const leftIsValid = !Number.isNaN(leftTimestamp); + const rightIsValid = !Number.isNaN(rightTimestamp); + + if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; + if (leftIsValid) return leftTimestamp - rightTimestamp; + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index ce80a079b3fd..676f7811011e 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { - explicitFaviconUrl, - faviconUrlForOrigin, - faviconUrlForPage, - toolActivityFaviconUrl, -} from "./favicon.ts"; +import { faviconUrlForOrigin, toolActivityFaviconUrl } from "./favicon.ts"; describe("faviconUrlForOrigin", () => { it.each([ @@ -46,12 +41,12 @@ describe("faviconUrlForOrigin", () => { ); }); -describe("faviconUrlForPage", () => { +describe("toolActivityFaviconUrl", () => { it("uses the page origin instead of a third-party favicon service", () => { - expect(faviconUrlForPage("https://example.com/docs/page?q=1")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "https://example.com/docs/page?q=1" }, "light")).toBe( "https://example.com/favicon.ico", ); - expect(faviconUrlForPage("http://localhost:5173/app")).toBe( + expect(toolActivityFaviconUrl({ pageUrl: "http://localhost:5173/app" }, "light")).toBe( "http://localhost:5173/favicon.ico", ); }); @@ -86,7 +81,17 @@ describe("faviconUrlForPage", () => { }); it("accepts provider-supplied image URLs but rejects extension URLs", () => { - expect(explicitFaviconUrl("https://example.com/icon.png")).toBe("https://example.com/icon.png"); - expect(explicitFaviconUrl("chrome-extension://example/_favicon/")).toBeNull(); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "https://example.com/icon.png" }, + "light", + ), + ).toBe("https://example.com/icon.png"); + expect( + toolActivityFaviconUrl( + { pageUrl: "https://example.com/docs", faviconUrl: "chrome-extension://example/_favicon/" }, + "light", + ), + ).toBe("https://example.com/favicon.ico"); }); }); diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index a3286b28e99a..2c4847115b90 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -5,7 +5,7 @@ import { isPublicFaviconHost } from "./hostClassification.ts"; * conventional favicon and let the image element fall back to a browser glyph. * Chrome-backed tools can pass their tab's explicit favicon URL separately. */ -export function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { +function faviconUrlForPage(rawUrl: string | null | undefined, _size = 32): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const pageUrl = new URL(rawUrl); @@ -40,7 +40,7 @@ function themedFaviconUrlForPage( } /** Accepts image URLs supplied by a trusted provider event. */ -export function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { +function explicitFaviconUrl(rawUrl: string | null | undefined): string | null { if (!rawUrl || rawUrl.length > 4096) return null; try { const url = new URL(rawUrl); diff --git a/packages/shared/src/httpReadiness.ts b/packages/shared/src/httpReadiness.ts index be1b3475ff3e..5aad9d488aae 100644 --- a/packages/shared/src/httpReadiness.ts +++ b/packages/shared/src/httpReadiness.ts @@ -5,7 +5,7 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -export const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; +const DEFAULT_HTTP_READY_PROBE_TIMEOUT_MS = 1_000; /** * Normalizes an arbitrary readiness probe failure into a plain, structured value diff --git a/packages/shared/src/imageDimensions.test.ts b/packages/shared/src/imageDimensions.test.ts new file mode 100644 index 000000000000..1b86bb97493d --- /dev/null +++ b/packages/shared/src/imageDimensions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { readImageDimensions } from "./imageDimensions.ts"; + +function bytes(...parts: ReadonlyArray>): Uint8Array { + const out: number[] = []; + for (const part of parts) { + if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0)); + else if (typeof part === "number") out.push(part); + else out.push(...part); + } + return Uint8Array.from(out); +} + +const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff]; +const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff]; + +describe("readImageDimensions", () => { + it("reads a PNG IHDR", () => { + const png = bytes( + [0x89], + "PNG", + [0x0d, 0x0a, 0x1a, 0x0a], + u32(13), + "IHDR", + u32(1600), + u32(900), + ); + expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 }); + }); + + it("reads a GIF logical screen", () => { + expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({ + width: 320, + height: 240, + }); + }); + + it("reads a JPEG start-of-frame after an APP segment", () => { + const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0"); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 720, + height: 1400, + }); + }); + + it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => { + // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6. + const tiff = [ + 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + const exif = ["Exif\0\0", tiff] as const; + const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif); + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // A later XMP APP1 segment must not clear the rotation. + const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0"); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({ + width: 3024, + height: 4032, + }); + // Orientation 1 leaves the frame size alone. + const upright = [...tiff]; + upright[19] = 0x01; + const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright); + expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({ + width: 4032, + height: 3024, + }); + }); + + it("steps over standalone TEM and restart markers", () => { + const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual( + { width: 20, height: 10 }, + ); + }); + + it("does not mistake a Huffman table marker for a frame", () => { + const dht = bytes([0xff, 0xc4], u16(4), [0, 0]); + const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20)); + expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({ + width: 20, + height: 10, + }); + }); + + it("reads each WebP container flavour", () => { + const riff = (chunk: string, body: ReadonlyArray) => + bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body); + // VP8: frame tag (3), start code (3), then 14-bit width and height. + expect( + readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])), + ).toEqual({ width: 800, height: 600 }); + // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE. + const packed = (800 - 1) | ((600 - 1) << 14); + expect( + readImageDimensions( + riff("VP8L", [ + 0x2f, + packed & 0xff, + (packed >>> 8) & 0xff, + (packed >>> 16) & 0xff, + (packed >>> 24) & 0xff, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + // VP8X: flags (4), then 24-bit width-1 and height-1. + expect( + readImageDimensions( + riff("VP8X", [ + 0, + 0, + 0, + 0, + 799 & 0xff, + (799 >> 8) & 0xff, + 0, + 599 & 0xff, + (599 >> 8) & 0xff, + 0, + ]), + ), + ).toEqual({ width: 800, height: 600 }); + }); + + it("returns null for unsupported, truncated, or zero-sized input", () => { + expect(readImageDimensions(bytes(""))).toBeNull(); + expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull(); + expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull(); + expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull(); + expect(readImageDimensions(new Uint8Array())).toBeNull(); + }); +}); diff --git a/packages/shared/src/imageDimensions.ts b/packages/shared/src/imageDimensions.ts new file mode 100644 index 000000000000..8eceb305b764 --- /dev/null +++ b/packages/shared/src/imageDimensions.ts @@ -0,0 +1,154 @@ +/** + * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP + * file so a client can reserve the exact box before the bytes arrive. Any + * other format, a truncated header, or a malformed file yields null; callers + * fall back to measuring after decode. + */ +export interface ImageDimensions { + readonly width: number; + readonly height: number; +} + +/** + * Enough for every supported header. A JPEG's frame header can sit behind + * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a + * few of them before giving up. + */ +export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; + +export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null { + const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes); + return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null; +} + +const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + +function readPng(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 24) return null; + if ( + bytes[0] !== 0x89 || + bytes[1] !== 0x50 || + bytes[2] !== 0x4e || + bytes[3] !== 0x47 || + bytes[12] !== 0x49 || + bytes[13] !== 0x48 || + bytes[14] !== 0x44 || + bytes[15] !== 0x52 + ) { + return null; + } + const data = view(bytes); + return { width: data.getUint32(16), height: data.getUint32(20) }; +} + +function readGif(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 10) return null; + if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null; + const data = view(bytes); + return { width: data.getUint16(6, true), height: data.getUint16(8, true) }; +} + +function readWebp(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 16) return null; + if ( + bytes[0] !== 0x52 || + bytes[1] !== 0x49 || + bytes[2] !== 0x46 || + bytes[3] !== 0x46 || + bytes[8] !== 0x57 || + bytes[9] !== 0x45 || + bytes[10] !== 0x42 || + bytes[11] !== 0x50 + ) { + return null; + } + const data = view(bytes); + const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); + switch (chunk) { + case "VP8 ": + if (bytes.length < 30) return null; + // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code. + return { + width: data.getUint16(26, true) & 0x3fff, + height: data.getUint16(28, true) & 0x3fff, + }; + case "VP8L": { + if (bytes.length < 25) return null; + // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the + // 32 bits after the signature byte. + const packed = data.getUint32(21, true); + return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; + } + case "VP8X": + if (bytes.length < 30) return null; + // Extended: 24-bit canvas dimensions minus one. + return { + width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1, + height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1, + }; + default: + return null; + } +} + +function readJpeg(bytes: Uint8Array): ImageDimensions | null { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + const data = view(bytes); + let offset = 2; + let rotated = false; + while (offset + 9 <= bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1]!; + // Padding bytes between segments. + if (marker === 0xff) { + offset += 1; + continue; + } + // Start-of-frame markers carry the dimensions; skip the arithmetic-coding + // and Huffman-table markers that share the range. + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + const height = data.getUint16(offset + 5); + const width = data.getUint16(offset + 7); + return rotated ? { width: height, height: width } : { width, height }; + } + if (marker === 0xd9 || marker === 0xda) return null; + // TEM and the restart markers stand alone, with no length field. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + offset += 2; + continue; + } + const length = data.getUint16(offset + 2); + // Viewers apply the EXIF orientation before display, so a phone photo + // stored on its side takes the swapped size on screen. + if (marker === 0xe1 && !rotated) { + rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length); + } + offset += 2 + length; + } + return null; +} + +/** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */ +function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean { + end = Math.min(end, bytes.length); + // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset. + if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") { + return false; + } + const tiff = start + 6; + const data = view(bytes); + const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false; + const ifd = tiff + data.getUint32(tiff + 4, littleEndian); + if (ifd + 2 > end) return false; + const entries = data.getUint16(ifd, littleEndian); + for (let i = 0; i < entries; i += 1) { + const entry = ifd + 2 + i * 12; + if (entry + 12 > end) return false; + if (data.getUint16(entry, littleEndian) === 0x0112) { + const orientation = data.getUint16(entry + 8, littleEndian); + return orientation >= 5 && orientation <= 8; + } + } + return false; +} diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 940778fba1ea..d6cb26f25be3 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -35,7 +35,7 @@ function getRawSelectionValueById( return selection?.value; } -export function getProviderOptionSelectionValue( +function getProviderOptionSelectionValue( selections: ReadonlyArray | null | undefined, id: string, ): string | boolean | undefined { @@ -298,11 +298,6 @@ export function readCustomModelEntries(value: unknown): CustomModelDefinition[] return entries; } -/** Slugs of a `customModels` setting, in stored order. */ -export function readCustomModelSlugs(value: unknown): string[] { - return readCustomModelEntries(value).map((entry) => entry.slug); -} - /** * Write a definition back to the compact stored shape: a bare slug when it * carries nothing custom, otherwise an entry with only the set fields. @@ -361,7 +356,7 @@ export function resolveSelectableModel( } /** Trim a string, returning null for empty/missing values. */ -export function trimOrNull(value: T | null | undefined): T | null { +function trimOrNull(value: T | null | undefined): T | null { if (typeof value !== "string") return null; const trimmed = value.trim() as T; return trimmed || null; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 67057c548806..9692a05f7592 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -303,7 +303,7 @@ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttri return truncated ?? attributes; } -export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { +function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 7703421d5c29..dab35ad3e08c 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; +import { formatDuration } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,9 +29,3 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); - -describe("formatElapsed", () => { - it("formats a long run across midnight", () => { - expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); - }); -}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 956226e19a7d..2ae82c22a691 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,17 +28,7 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - -export function isLatestTurnSettled( +function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, ): boolean { diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index fec4203c5334..14139216194e 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { isLoopbackHost, - isPreviewableUrl, newPreviewTabId, normalizePreviewUrl, PreviewUrlNormalizationError, @@ -27,24 +26,6 @@ describe("isLoopbackHost", () => { }); }); -describe("isPreviewableUrl", () => { - it.each([ - "http://localhost:5173", - "http://127.0.0.1:3000/path", - "http://0.0.0.0:8080", - "http://[::1]:5173", - ])("%s is previewable", (url) => { - expect(isPreviewableUrl(url)).toBe(true); - }); - - it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( - "%s is not previewable", - (url) => { - expect(isPreviewableUrl(url)).toBe(false); - }, - ); -}); - describe("normalizePreviewUrl", () => { it("treats bare loopback hosts as http", () => { expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 926b30966e52..f0a781290b1c 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -36,17 +36,6 @@ export function isLoopbackHost(host: string): boolean { return false; } -/** True when a raw URL string looks like a loopback dev URL we can preview. */ -export function isPreviewableUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; - return isLoopbackHost(parsed.hostname); - } catch { - return false; - } -} - export class PreviewUrlNormalizationError extends Schema.TaggedErrorClass()( "PreviewUrlNormalizationError", { diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index 3222e90d7be5..7a049376c50e 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - previewViewportPresetOrientation, - resolvePreviewViewport, -} from "./previewViewport.ts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "./previewViewport.ts"; describe("previewViewport", () => { it("resolves fill and exact freeform viewports", () => { @@ -59,12 +54,4 @@ describe("previewViewport", () => { "Nest Hub Max", ]); }); - - it("formats settings for compact UI", () => { - expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); - expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); - expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( - "landscape", - ); - }); }); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index 1d70bca5dfbd..d1e066bee16d 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -173,14 +173,3 @@ export function resolvePreviewViewport( height: input.height, }; } - -export function previewViewportLabel(viewport: PreviewViewportSetting): string { - return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; -} - -export function previewViewportPresetOrientation( - viewport: PreviewViewportSetting, -): "portrait" | "landscape" | null { - if (viewport._tag === "fill" || viewport.width === viewport.height) return null; - return viewport.width > viewport.height ? "landscape" : "portrait"; -} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cbf..4d98e36b4d70 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,24 @@ -import type { ProjectScript } from "@t3tools/contracts"; +import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; + +/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +export function resolveProjectScripts( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): readonly ProjectScript[] { + const override = settings.projectScriptOverrides[project.id]; + if (override === null) return settings.defaultProjectScripts; + return ( + override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) + ); +} + +export function projectScriptsInheritDefaults( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): boolean { + const override = settings.projectScriptOverrides[project.id]; + return override === null || (override === undefined && project.scripts.length === 0); +} interface ProjectScriptRuntimeEnvInput { project: { diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts index 3abff9b52109..4e1f28eefdaa 100644 --- a/packages/shared/src/relayAuth.test.ts +++ b/packages/shared/src/relayAuth.test.ts @@ -5,7 +5,6 @@ import { ClerkPublishableKeyFrontendApiError, clerkFrontendApiHostnameFromPublishableKey, clerkFrontendApiUrlFromPublishableKey, - isAllowedClerkFrontendApiHostname, } from "./relayAuth.ts"; const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; @@ -75,14 +74,4 @@ describe("Clerk relay auth", () => { }); expect((error as ClerkPublishableKeyFrontendApiError).cause).toBeInstanceOf(Error); }); - - it("allows standard Clerk hosts and an exact configured custom hostname", () => { - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); - expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); - expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); - expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( - false, - ); - }); }); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index a384db77d8ac..4c5d766c1480 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -81,17 +81,6 @@ export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: strin return parseClerkFrontendApi(publishableKey).hostname; } -export function isAllowedClerkFrontendApiHostname( - hostname: string, - configuredHostname: string | null, -): boolean { - return ( - hostname.endsWith(".clerk.accounts.dev") || - hostname.endsWith(".clerk.accounts.com") || - hostname === configuredHostname - ); -} - export function relayClerkTokenOptions(template: string) { return { template, diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 1d556ed6dc30..404d765ba74c 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -61,7 +61,10 @@ const makeSpawnerLayer = (commands: Array) => ChildProcessSpawner.make((command) => Effect.sync(() => { commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); - return makeHandle(); + // The pinned Windows executable rejects --version but accepts the version subcommand. + return makeHandle( + ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, + ); }), ), ); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 0a56e45191c2..4743f12b19d5 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -20,7 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; -export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; +const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; export type RelayClientExecutableSource = "override" | "managed" | "path"; @@ -421,7 +421,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - yield* runCommand(executablePath, ["--version"]).pipe( + yield* runCommand(executablePath, ["version"]).pipe( wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), ); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index e132b7084b74..076ff67b62df 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -199,12 +199,12 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { * strips trailing commas and JS-style comments before parsing. * Encoding produces strict JSON via `JSON.stringify`. */ -export const fromLenientJsonString = new SchemaTransformation.Transformation( +const fromLenientJsonString = new SchemaTransformation.Transformation( parseLenientJsonGetter, SchemaGetter.stringifyJson(), ); -export const prettyJsonString = SchemaGetter.parseJson().compose( +const prettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), ); diff --git a/packages/shared/src/schemaYaml.ts b/packages/shared/src/schemaYaml.ts index 70e3c987ae00..55b5b97122ae 100644 --- a/packages/shared/src/schemaYaml.ts +++ b/packages/shared/src/schemaYaml.ts @@ -32,32 +32,8 @@ function formatYamlParseError(error: unknown): string { return `Invalid YAML (code=${error.code}${location}).`; } -/** - * Parses a YAML string into a value. - * - * **When to use** - * - * Use when you need a schema getter to parse a present encoded YAML string - * during decoding. - * - * **Details** - * - * Parse failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Parse YAML) - * - * ```ts - * import { parseYaml } from "@t3tools/shared/schemaYaml" - * - * const parse = parseYaml() - * // Getter - * ``` - * - * @see {@link stringifyYaml} for the inverse operation - */ -export function parseYaml( - options?: YamlParseOptions, -): SchemaGetter.Getter { +/** Parses YAML during decoding, reporting parse failures as InvalidValue issues. */ +function parseYaml(options?: YamlParseOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: E) => Effect.try({ try: () => parseYamlString(input, options) as unknown, @@ -66,32 +42,8 @@ export function parseYaml( ); } -/** - * Stringifies a present value as YAML. - * - * **When to use** - * - * Use when you need a schema getter to serialize a present decoded value to - * YAML text during encoding. - * - * **Details** - * - * Stringify failures become `SchemaIssue.InvalidValue` values. - * - * **Example** (Stringify YAML) - * - * ```ts - * import { stringifyYaml } from "@t3tools/shared/schemaYaml" - * - * const stringify = stringifyYaml() - * // Getter - * ``` - * - * @see {@link parseYaml} for the inverse operation - */ -export function stringifyYaml( - options?: YamlStringifyOptions, -): SchemaGetter.Getter { +/** Serializes YAML during encoding, reporting stringify failures as InvalidValue issues. */ +function stringifyYaml(options?: YamlStringifyOptions): SchemaGetter.Getter { return SchemaGetter.transformOrFail((input: unknown) => Effect.try({ try: () => stringifyYamlValue(input, options), diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index 7e2ccce6e063..8ddf02ec8498 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - compareRankedSearchResults, insertRankedSearchResult, normalizeSearchQuery, scoreQueryMatch, @@ -90,6 +89,5 @@ describe("insertRankedSearchResult", () => { insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); - expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); }); }); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index b2fb2e223d3b..c8ec69e39703 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -135,7 +135,7 @@ export function scoreQueryMatch(input: { return null; } -export function compareRankedSearchResults( +function compareRankedSearchResults( left: RankedSearchResult, right: RankedSearchResult, ): number { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 1f847412b6c7..a5e428fcdaac 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, UsageLimitSourceId, @@ -9,45 +10,203 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; +import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, - extractPersistedServerObservabilitySettings, isModelSelectionProviderEnabled, - normalizePersistedServerSettingString, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { - it("normalizes optional persisted strings", () => { - expect(normalizePersistedServerSettingString(undefined)).toBeUndefined(); - expect(normalizePersistedServerSettingString(" ")).toBeUndefined(); - expect(normalizePersistedServerSettingString(" http://localhost:4318/v1/traces ")).toBe( - "http://localhost:4318/v1/traces", - ); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { + const project = { id: ProjectId.make("project-actions"), scripts: [] }; + const action = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(defaults, project)).toEqual([action]); + expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + const disabled = applyServerSettingsPatch(defaults, { + projectScriptOverrides: { [project.id]: [] }, + }); + expect(resolveProjectScripts(disabled, project)).toEqual([]); + expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); + const changedDefault = applyServerSettingsPatch(disabled, { + defaultProjectScripts: [{ ...action, command: "npm run build" }], + }); + expect(resolveProjectScripts(changedDefault, project)).toEqual([]); + const reset = applyServerSettingsPatch(changedDefault, { + projectScriptOverrides: { [project.id]: null }, + }); + expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); + expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); + expect( + resolveProjectScripts( + applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), + existing, + ), + ).toEqual([]); + }); + + it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { + const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; + const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; + const defaultAction = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const firstAction = { ...defaultAction, command: "npm run lint" }; + const secondAction = { ...defaultAction, command: "npm run build" }; + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [defaultAction], + projectScriptOverrides: { [firstProject.id]: [firstAction] }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectScriptOverrides: { [secondProject.id]: [secondAction] }, + }); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); + + const cleared = applyServerSettingsPatch(secondUpdate, { + projectScriptOverrides: { [firstProject.id]: [] }, + }); + expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); + expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); + + const reset = applyServerSettingsPatch(cleared, { + projectScriptOverrides: { [firstProject.id]: null }, + }); + expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ + defaultAction, + ]); + expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + }); + + it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { + const projectId = ProjectId.make("project-pull"); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); + const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); + expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); + const overridden = applyServerSettingsPatch(enabled, { + projectAutoPullOverrides: { [projectId]: false }, + }); + expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); + expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); + const disabled = applyServerSettingsPatch(reset, { + defaultAutoPull: false, + projectAutoPullOverrides: { [projectId]: true }, + }); + expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); + expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); + }); + + it("inherits browser access and restores inheritance when a project override is removed", () => { + const projectId = ProjectId.make("project-browser"); + const otherProjectId = ProjectId.make("other-project"); + const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAgentBrowserAccessOverrides: { [projectId]: false }, + }); + expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); + expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); + expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); + const enabled = applyServerSettingsPatch(reset, { + enableAgentBrowserAccess: false, + projectAgentBrowserAccessOverrides: { [projectId]: true }, + }); + expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); + expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); }); - it("extracts persisted observability settings", () => { + it("preserves other projects' boolean overrides across separate updates and resets", () => { + const firstProjectId = ProjectId.make("first-project"); + const secondProjectId = ProjectId.make("second-project"); + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + projectAutoPullOverrides: { [firstProjectId]: false }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectAutoPullOverrides: { [secondProjectId]: false }, + projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, + }); + for (const projectId of [firstProjectId, secondProjectId]) { + expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); + } + + const reset = applyServerSettingsPatch(secondUpdate, { + projectAutoPullOverrides: { [firstProjectId]: null }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, + }); + expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); + expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); + expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); + expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); + expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); + expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); + }); + + it("replaces and clears conversation model defaults without retaining old options", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); + const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); + expect(updated.defaultModelSelection).toEqual(selection); expect( - extractPersistedServerObservabilitySettings({ - observability: { - otlpTracesUrl: " http://localhost:4318/v1/traces ", - otlpMetricsUrl: " http://localhost:4318/v1/metrics ", - }, - }), + applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, + ).toBeNull(); + }); + + it("ignores missing and blank persisted observability URLs", () => { + expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + }); + expect( + parsePersistedServerObservabilitySettings( + JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }), + ), ).toEqual({ - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, }); }); - it("parses lenient persisted settings JSON", () => { + it("parses lenient persisted settings JSON and trims observability URLs", () => { expect( parsePersistedServerObservabilitySettings( JSON.stringify({ observability: { - otlpTracesUrl: "http://localhost:4318/v1/traces", - otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpTracesUrl: " http://localhost:4318/v1/traces ", + otlpMetricsUrl: " http://localhost:4318/v1/metrics ", }, }), ), diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dfd5b742e4e4..f969e4412c30 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -3,6 +3,7 @@ import { isProviderAvailable, resolveProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -23,6 +24,27 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +export function resolveProjectAgentBrowserAccess( + settings: Pick, + projectId: ProjectId, +): boolean { + return ( + settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + ); +} + +export function resolveProjectAutoPull( + settings: Pick, + projectId: ProjectId, + legacyAutoPull: boolean | undefined, +): boolean { + // Existing opt-ins stay enabled until explicitly overridden or reset. + return ( + settings.projectAutoPullOverrides[projectId] ?? + (legacyAutoPull === true || settings.defaultAutoPull) + ); +} + type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( @@ -69,14 +91,14 @@ export interface PersistedServerObservabilitySettings { readonly otlpMetricsUrl: string | undefined; } -export function normalizePersistedServerSettingString( +function normalizePersistedServerSettingString( value: string | null | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } -export function extractPersistedServerObservabilitySettings(input: { +function extractPersistedServerObservabilitySettings(input: { readonly observability?: { readonly otlpTracesUrl?: string; readonly otlpMetricsUrl?: string; @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, + projectAutoPullOverrides: projectAutoPullOverridesPatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } + : {}), + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index c98c1c452d4b..621fe49b3087 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,7 +8,6 @@ import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; import { - extractPathFromShellOutput, CommandAvailability, CommandResolutionCache, type CommandAvailabilityChecker, @@ -39,28 +38,6 @@ const withWindowsEnvironmentMocks = ( Effect.provideService(CommandAvailability, commandAvailable), ); -describe("extractPathFromShellOutput", () => { - it("extracts the path between capture markers", () => { - expect( - extractPathFromShellOutput( - "__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("ignores shell startup noise around the capture markers", () => { - expect( - extractPathFromShellOutput( - "Welcome to fish\n__T3CODE_PATH_START__\n/opt/homebrew/bin:/usr/bin\n__T3CODE_PATH_END__\nBye\n", - ), - ).toBe("/opt/homebrew/bin:/usr/bin"); - }); - - it("returns null when the markers are missing", () => { - expect(extractPathFromShellOutput("/opt/homebrew/bin /usr/bin")).toBeNull(); - }); -}); - describe("readPathFromLoginShell", () => { it("uses a shell-agnostic printenv PATH probe", () => { const execFile = vi.fn< diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 7d7a7d7b4f41..07ac73f8c6a7 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -12,8 +12,6 @@ import * as Path from "effect/Path"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; import * as Context from "effect/Context"; -const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; -const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; @@ -179,18 +177,6 @@ export function listLoginShellCandidates( return candidates; } -export function extractPathFromShellOutput(output: string): string | null { - const startIndex = output.indexOf(PATH_CAPTURE_START); - if (startIndex === -1) return null; - - const valueStartIndex = startIndex + PATH_CAPTURE_START.length; - const endIndex = output.indexOf(PATH_CAPTURE_END, valueStartIndex); - if (endIndex === -1) return null; - - const pathValue = output.slice(valueStartIndex, endIndex).trim(); - return pathValue.length > 0 ? pathValue : null; -} - export function readPathFromLoginShell( shell: string, execFile: ExecFileSyncLike = NodeChildProcess.execFileSync, diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..93b7c41ad44f 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -92,23 +92,12 @@ export function resolveChangeRequestPresentation( } } -export function resolveChangeRequestPresentationForKind( +function resolveChangeRequestPresentationForKind( kind: SourceControlProviderKind, ): ChangeRequestPresentation { return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); } -export function formatChangeRequestAction( - verb: "View" | "Create", - presentation: ChangeRequestPresentation, -): string { - return `${verb} ${presentation.shortName}`; -} - -export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { - return `create ${presentation.shortName}`; -} - export function getChangeRequestTerminology( provider: SourceControlProviderInfo | null | undefined, ): ChangeRequestTerminology { diff --git a/packages/shared/src/threadReference.test.ts b/packages/shared/src/threadReference.test.ts index 60600c7e1465..1b975a8defe9 100644 --- a/packages/shared/src/threadReference.test.ts +++ b/packages/shared/src/threadReference.test.ts @@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => { threadId: "thread-1", openPanelPullRequestUrl: null, linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toBeNull(); }); - it("prefers the open panel pull request over linked and detected pull requests", () => { + it("prefers the open panel pull request over the thread pull request", () => { expect( resolveThreadReferenceCopyTarget({ threadId: "thread-1", openPanelPullRequestUrl: "https://github.com/t3/pr/14", linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toMatchObject({ kind: "pull-request", @@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => { }); }); - it("prefers a durable linked pull request", () => { + it("uses the thread pull request when no panel is open", () => { expect( resolveThreadReferenceCopyTarget({ threadId: "thread-1", linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toMatchObject({ kind: "pull-request", @@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => { }); }); - it("uses a pull request detected from the active branch", () => { - expect( - resolveThreadReferenceCopyTarget({ - threadId: "thread-1", - detectedPullRequestUrl: "https://github.com/t3/pr/13", - }), - ).toMatchObject({ - kind: "pull-request", - value: "https://github.com/t3/pr/13", - }); - }); - it("falls back to the thread ID", () => { expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ kind: "thread", diff --git a/packages/shared/src/threadReference.ts b/packages/shared/src/threadReference.ts index 20eac6fba039..ce53078862ac 100644 --- a/packages/shared/src/threadReference.ts +++ b/packages/shared/src/threadReference.ts @@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: { /** Undefined means no PR panel; null means its URL is not available yet. */ readonly openPanelPullRequestUrl?: string | null | undefined; readonly linkedPullRequestUrl?: string | null; - readonly detectedPullRequestUrl?: string | null; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; - const pullRequestUrl = - input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; + const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl; return pullRequestUrl ? { kind: "pull-request", diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..32fb4eaa9503 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,13 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isUsageLimitsCommand, + collectProviderUsageLimits, + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -16,6 +23,7 @@ import { limitsNotice, paceOf, providersWithLimits, + remainingPercent, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -304,3 +312,580 @@ describe("collectLimitSources", () => { ]); }); }); + +describe("pools", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-06T12:00:00.000Z", + } as const; + const claude = ProviderDriverKind.make("claudeAgent"); + const source = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + }; + const laptop = { entry: { target: { label: "Laptop" } } }; + + it("merges one account reported natively on two environments and by a hub into one entry", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "Same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...native, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + key: "env-a:claude", + sourceLabel: null, + // Desktop's read is fresher, so its credits and its redeem are the ones on show. + redeem: { environmentId: "env-b", instanceId: "claude" }, + environments: [ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ], + }); + // The fresher native snapshot wins; the hub row is pre-filtered by email. + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); + }); + + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + resetCredits: { availableCount: 2 }, + }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.windows[0]?.usedPercent).toBe(55); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); + expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); + }); + + it("redeems on the environment whose snapshot supplied the credits on show", () => { + const stale = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 0 }, + }, + }); + const fresh = { + ...stale, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [window], + resetCredits: { availableCount: 2 }, + }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); + }); + + it("names an environment once however many of its instances share the account", () => { + const shared = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + ]); + }); + + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { + const seat = { + id: "claude-team-seat.json", + driver: claude, + usageLimits: { checkedAt, windows: [window] }, + }; + const hub = { ...source, accounts: [seat] }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); + expect(accounts[0]?.displayName).toBe("claude-team-seat"); + }); + + it("pools windows by id across accounts and orders resets by when they land", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt, + windows: [ + { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, + { ...weekly, usedPercent: 20 }, + ], + }, + }, + { + id: "b", + driver: claude, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + }, + }, + { + id: "c", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }, + { + id: "unsupported", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(input), now); + expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ + ["claudeAgent", 2], + ["codex", 1], + ]); + const [session, week] = pools[0]!.windows; + // A member with no reset has no clock, so it does not vote on pace. + const untimed = collectLimitPools( + collectLimitAccounts(input).map((account) => + account.key === "hub:b" + ? { + ...account, + limits: { + ...account.limits, + windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), + }, + } + : account, + ), + now, + ); + // Only a votes: 80% used, 80% elapsed. + expect(untimed[0]?.windows[0]?.pace).toBe("on"); + // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. + expect(session).toMatchObject({ + id: "five_hour", + remainingPercent: 40, + usedPercent: 60, + pace: "under", + }); + expect( + session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), + ).toEqual([ + ["hub:a", 40], + ["hub:b", 20], + ]); + expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); + // Codex reports `primary` for both its five-hour and (on Go) monthly window. + const mixed = collectLimitPools( + [ + ...collectLimitAccounts(input), + { + key: "go", + driver: claude, + displayName: "Go", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-09-14T12:00:00.000Z", + }, + ], + }, + }, + ], + now, + ); + expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ + ["session", 2], + ["weekly", 1], + ["monthly", 1], + ]); + // Segments read left to right as "who refills next", matching the reset list. + expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); + expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); + }); +}); + +describe("collectLimitNotices", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const laptop = { entry: { target: { label: "Laptop" } } }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + accounts: [], + }; + + it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { + const failed = provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + displayName: "Claude Max", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const apiKey = provider({ + instanceId: ProviderInstanceId.make("api"), + driver: claude, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const silent = provider({ usageLimits: { checkedAt, windows: [] } }); + const one = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [failed, apiKey, silent], + usageLimitSources: [ + hub, + { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(one)).toEqual([ + "Claude Max: Could not read limits.", + "codex: No limits reported.", + "hub: No accounts reported.", + "down: ECONNREFUSED", + ]); + + one.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [], usageLimitSources: [] }, + }); + expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); + }); +}); + +describe("/usage-limits", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; + const selected = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com" }, + }); + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: limits.checkedAt, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "SAME@example.com", + usageLimits: limits, + }, + { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, + { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, + ], + }, + ]; + + it("keeps accounts and custom instances separate, filtering by driver", () => { + const report = collectProviderUsageLimits( + selected.instanceId, + [ + selected, + provider({ + instanceId: ProviderInstanceId.make("codex-work"), + displayName: "Work", + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }), + provider({ + driver: ProviderDriverKind.make("claude"), + instanceId: ProviderInstanceId.make("claude"), + usageLimits: limits, + }), + ], + sources, + now, + ); + expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); + expect(report?.accounts.map((account) => account.id)).toEqual([ + "codex", + "codex-work", + "hub:oss", + ]); + expect(report?.accounts[0]).toMatchObject({ + instanceId: selected.instanceId, + email: selected.auth.email, + }); + expect(report?.accounts[1]).toMatchObject({ + displayName: "Work", + limits: { resetCredits: { availableCount: 2 } }, + }); + expect(report?.accounts[2]).toMatchObject({ + label: "Accounts · oss", + sourceLabel: "CLI Proxy", + plan: "Codex OSS", + }); + expect(report?.notices).toEqual([]); + }); + + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { + expect( + collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["hub:duplicate", "hub:oss"]); + const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); + expect( + collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["codex", "hub:duplicate", "hub:oss"]); + expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); + expect( + collectProviderUsageLimits( + selected.instanceId, + [provider({ enabled: false, usageLimits: limits })], + [], + now, + ), + ).toBeNull(); + }); + + it("surfaces source errors only for sources that carry the selected driver", () => { + const failing = { ...sources[0]!, error: "token expired" }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, + ).toEqual(["Accounts: token expired"]); + const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, + ).toEqual([]); + // A read failure clears the accounts, so the error must not depend on a match. + const unreadable = { ...failing, accounts: [] }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, + ).toEqual(["Accounts: token expired"]); + // A source-only provider still gets the report, carrying only the error. + const sourceOnly = collectProviderUsageLimits( + selected.instanceId, + [provider({})], + [unreadable], + now, + ); + expect(sourceOnly?.accounts).toEqual([]); + expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); + }); + + it("advertises global and workspace commands only for providers present in Limits", () => { + const withWorkspace = provider({ + workspaceSnapshots: [ + { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, + ], + }); + const [supported] = withUsageLimitsCommands([withWorkspace], sources); + expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect( + supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + // A provider's own command of the same name is left alone without coverage. + const ownCommand = provider({ + slashCommands: [{ name: "usage-limits", description: "Provider's own" }], + }); + expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ + { name: "usage-limits", description: "Provider's own" }, + ]); + const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; + expect( + withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( + (command) => command.name, + ), + ).toEqual(["usage-limits"]); + expect( + withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); + +describe("sameUsageLimitCommandCoverage", () => { + const codexAccount = { + id: "a", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, + }; + const base = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + }; + it("ignores quota movement but not the drivers offered the command", () => { + const withCodex = [{ ...base, accounts: [codexAccount] }]; + const withCodexLater = [ + { + ...base, + accounts: [ + { + ...codexAccount, + usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, + }, + ], + }, + ]; + expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); + expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); + }); + it("treats a failed read as a change in coverage, in both directions", () => { + const empty = [{ ...base, accounts: [] }]; + const failed = [{ ...base, accounts: [], error: "token expired" }]; + expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); + expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); + expect( + sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), + ).toBe(true); + }); +}); + +describe("remainingPercent", () => { + it("inverts and clamps the reported usage", () => { + expect(remainingPercent(window)).toBe(60); + expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); + expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); + expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); + }); +}); + +describe("isUsageLimitsCommand", () => { + it("recognizes only the standalone local action", () => { + expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); + expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); + expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); + expect(isUsageLimitsCommand("/usage")).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8341796e39c3..d3b295bf42fb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,9 @@ */ import { type EnvironmentId, + type UsageLimitsReport, + type ProviderInstanceId, + type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, @@ -15,6 +18,8 @@ import { type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -53,7 +58,9 @@ export function collectLimitsGroups( EnvironmentId, { readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + } | null; } >, ): readonly LimitsGroup[] { @@ -142,12 +149,298 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } -/** The instance's configured name, else the driver's, else its raw kind. */ -export function providerLimitsLabel( - provider: ServerProvider, - driverLabel: (driver: ServerProvider["driver"]) => string | undefined, -): string { - return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); +/** + * One subscription account as the pooled views see it, whichever way it was + * reported. The same email signed in natively on two environments, or reported + * by a hub as well as natively, is one account: its quota is one bucket, so + * counting it twice would misstate what is left. + */ +export interface LimitAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + /** The instance's configured name, which is not sensitive; null for hub accounts. */ + readonly displayName: string | null; + readonly email: string | undefined; + readonly plan: string | undefined; + readonly accentColor: string | undefined; + /** Environments the account is signed in on; empty when only a hub reports it. */ + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + /** The hub that reported it, when no environment has it natively. */ + readonly sourceLabel: string | null; + /** Where a reset credit can be redeemed; only native instances can. */ + readonly redeem: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + } | null; + readonly limits: ServerProviderUsageLimits; +} + +/** + * Every account with usable windows across the connected environments, one + * entry per distinct account. Native instances win over hub reports, and the + * freshest snapshot wins when the same account is reported twice. + */ +export function collectLimitAccounts( + presentations: Parameters[0], +): readonly LimitAccount[] { + const accounts = new Map(); + const merge = (key: string, next: LimitAccount) => { + const previous = accounts.get(key); + if (!previous) { + accounts.set(key, next); + return; + } + const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + // Two instances on one machine sharing an account still name it once. + const environments = [ + ...previous.environments, + ...next.environments.filter( + (candidate) => + !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), + ), + ]; + const winner = fresher ? next : previous; + // Windows come from the freshest snapshot, wherever it was read. Reset + // credits only ever come from a native instance, and the redeem must go + // to the instance whose credits are on show, so the two travel together: + // the freshest native snapshot supplies both, or neither. + const native = [previous, next] + .filter((candidate) => candidate.redeem !== null) + .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + accounts.set(key, { + ...previous, + displayName: previous.displayName ?? next.displayName, + plan: previous.plan ?? next.plan, + accentColor: previous.accentColor ?? next.accentColor, + environments, + // A hub only names the account when no environment has it natively. + sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), + redeem: native?.redeem ?? null, + limits: { + ...winner.limits, + ...(native?.limits.resetCredits + ? { resetCredits: native.limits.resetCredits } + : { resetCredits: undefined }), + }, + }); + }; + for (const [environmentId, presentation] of presentations) { + const label = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + merge( + accountKey(provider.driver, provider.auth.email) ?? + `${environmentId}:${provider.instanceId}`, + { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, instanceId: provider.instanceId }, + limits: provider.usageLimits, + }, + ); + } + } + // Every hub account, including those a native instance also knows: the hub + // may hold a fresher read of the same subscription, and the merge above + // keeps the redeem target consistent with whichever snapshot wins. + const labelEnvironment = presentations.size > 1; + for (const presentation of presentations.values()) { + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const sourceLabel = labelEnvironment + ? `${presentation.entry.target.label} · ${source.label}` + : source.label; + for (const account of source.accounts) { + if (limitsNotice(account.usageLimits) !== null) continue; + merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: null, + limits: account.usageLimits, + }); + } + } + } + return [...accounts.values()]; +} + +/** + * What the pooled views cannot draw as a bar: a hub that failed to read, a + * provider whose probe failed. Accounts that can never report (API keys) + * are left out; there is nothing for the user to act on. The environment + * is named only when more than one is connected. + */ +export function collectLimitNotices( + presentations: Parameters[0], +): readonly string[] { + const label = (environmentLabel: string, subject: string) => + presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; + const notices: string[] = []; + for (const presentation of presentations.values()) { + const environmentLabel = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + // An account that can never report (API key) is left out; one that + // failed, or reported nothing at all, is worth a line. + if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; + const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + const name = provider.displayName?.trim() || String(provider.driver); + if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + if (source.error) { + notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); + } else if (source.accounts.length === 0) { + notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } + } + } + return notices; +} + +export interface LimitPoolMember { + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow; +} + +/** + * One window id across every account that reports it: the pooled share left, + * pace against the clock, and the resets in the order they will land, each + * with the share of the pool it hands back. + */ +export interface LimitPoolWindow { + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly members: readonly LimitPoolMember[]; + readonly remainingPercent: number; + readonly usedPercent: number; + readonly pace: LimitPace | null; + readonly resets: ReadonlyArray<{ + readonly member: LimitPoolMember; + readonly at: number; + /** Points of the pool the reset restores: the member's used share over the member count. */ + readonly restoresPercent: number; + }>; +} + +export interface LimitPool { + readonly driver: ServerProvider["driver"]; + readonly accounts: readonly LimitAccount[]; + readonly windows: readonly LimitPoolWindow[]; +} + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * Accounts grouped by driver, each with its windows pooled by kind and id. + * Window ids are stable per provider, so a hub row and a native row for the + * same window land in the same pool; the kind is part of the key because + * Codex's `primary` is a position, not a duration (five hours on paid plans, + * a month on Free/Go), and a monthly allowance must not average into a + * five-hour pool. Pools order by kind, then first appearance. + * + * `accounts` is the table order: instances the user can act on (native, + * named) before hub-only accounts, each group alphabetical. Each window's + * `members` sort by reset instead, soonest first, so a bar reads left to + * right as "who refills next" and matches the reset list under it. + */ +export function collectLimitPools( + accounts: readonly LimitAccount[], + now: number, +): readonly LimitPool[] { + const byDriver = new Map(); + for (const account of accounts) { + const list = byDriver.get(account.driver); + if (list) list.push(account); + else byDriver.set(account.driver, [account]); + } + return [...byDriver].map(([driver, members]) => { + const sorted = [...members].sort( + (left, right) => + Number(left.redeem === null) - Number(right.redeem === null) || + accountSortName(left).localeCompare(accountSortName(right)), + ); + return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; + }); +} + +function accountSortName(account: LimitAccount): string { + return (account.displayName ?? account.email ?? account.key).toLowerCase(); +} + +function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { + const byKey = new Map(); + for (const account of accounts) { + for (const window of account.limits.windows) { + const key = `${window.kind}:${window.id}`; + const list = byKey.get(key); + if (list) list.push({ account, window }); + else byKey.set(key, [{ account, window }]); + } + } + const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { + const members = [...unordered].sort( + (left, right) => + (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - + (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), + ); + const first = members[0]!.window; + const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // Pace compares spend against the clock, so it is judged only over the + // members that have a clock; a window with no reset would otherwise + // count as spend with no time elapsed and skew the verdict. + const timed = members.flatMap((m) => { + const share = elapsedShare(m.window, now); + return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + }); + const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const meanElapsed = + timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + const resets = members + .flatMap((member) => { + const at = resetMillis(member.window); + return at === null + ? [] + : [ + { + member, + at, + restoresPercent: Math.round(member.window.usedPercent / members.length), + }, + ]; + }) + .sort((left, right) => left.at - right.at); + return { + id: first.id, + kind: first.kind, + label: first.label, + members, + usedPercent: Math.round(usedPercent), + remainingPercent: Math.round(100 - usedPercent), + pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), + resets, + }; + }); + return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); } /** The one-line status under a provider heading when there are no bars to draw. */ @@ -161,7 +454,12 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } -export function resetMillis(window: ServerProviderUsageWindow): number | null { +/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ +export function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); +} + +function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); return Number.isFinite(at) ? at : null; @@ -179,14 +477,17 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; /** - * Usage against the clock. The bar is the whole window, so the elapsed share - * is also where even spending would have put the fill; within five points of - * it counts as on pace. + * Usage against the clock. Spending evenly leaves the same share of quota as + * there is time left in the window; within five points of that counts as on + * pace, further ahead means the window may run dry first. */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); - if (elapsed === null) return null; - const gap = window.usedPercent - elapsed * 100; + return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); +} + +function paceOfShares(usedPercent: number, elapsed: number): LimitPace { + const gap = usedPercent - elapsed * 100; if (gap > 5) return "ahead"; if (gap < -5) return "under"; return "on"; @@ -209,3 +510,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ +export const USAGE_LIMITS_COMMAND = { + name: "usage-limits", + description: "Show this provider's usage limits", +} satisfies ServerProviderSlashCommand; + +/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ +export function isUsageLimitsCommand(prompt: string): boolean { + return prompt.trim().toLowerCase() === "/usage-limits"; +} + +/** + * Whether Limits has anything to say about this driver. A source that failed to + * read keeps no accounts, so its error counts for every driver rather than + * disappearing until the next successful refresh. + */ +export function hasProviderUsageLimits( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + providersWithLimits(providers).some((provider) => provider.driver === driver) || + sources.some( + (source) => + source.accounts.some((account) => account.driver === driver) || + (source.error !== undefined && source.accounts.length === 0), + ) + ); +} + +/** + * The drivers a set of sources would offer the command to, where a source that + * failed to read counts for every driver. Two snapshots with the same coverage + * need no catalog republish, however much their quotas moved. + */ +export function sameUsageLimitCommandCoverage( + previous: UsageLimitSourceSnapshots, + next: UsageLimitSourceSnapshots, +): boolean { + const coverage = (sources: UsageLimitSourceSnapshots) => + new Set( + sources.flatMap((source) => + source.error !== undefined && source.accounts.length === 0 + ? ["*"] + : source.accounts.map((account) => String(account.driver)), + ), + ); + const before = coverage(previous); + const after = coverage(next); + return before.size === after.size && [...before].every((driver) => after.has(driver)); +} + +/** Advertise on workspace catalogs too, which replace the global command list. */ +export function withUsageLimitsCommands( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): ServerProvider[] { + return providers.map((provider) => { + if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + const commands = (items: readonly ServerProviderSlashCommand[]) => [ + ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), + USAGE_LIMITS_COMMAND, + ]; + return { + ...provider, + slashCommands: commands(provider.slashCommands), + ...(provider.workspaceSnapshots + ? { + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: commands(snapshot.slashCommands), + })), + } + : {}), + }; + }); +} + +/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ +export function collectProviderUsageLimits( + instanceId: ProviderInstanceId, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): UsageLimitsReport | null { + const selected = providers.find((provider) => provider.instanceId === instanceId); + if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + const native = providersWithLimits(providers).filter( + (provider) => provider.driver === selected.driver, + ); + const nativeAccounts = new Set( + native.flatMap((provider) => { + const key = accountKey(provider.driver, provider.auth.email); + return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + ? [key] + : []; + }), + ); + const accounts: Array = []; + const notices: string[] = []; + for (const provider of native) { + if (!provider.usageLimits) continue; + accounts.push({ + id: provider.instanceId, + driver: provider.driver, + label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, + ...(provider.auth.label ? { plan: provider.auth.label } : {}), + instanceId: provider.instanceId, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), + ...(provider.auth.email ? { email: provider.auth.email } : {}), + limits: provider.usageLimits, + }); + } + for (const source of sources) { + const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const account of matching) { + const key = accountKey(account.driver, account.email); + if (key && nativeAccounts.has(key)) continue; + accounts.push({ + id: `${source.id}:${account.id}`, + driver: account.driver, + label: `${source.label} · ${account.id}`, + sourceLabel: "CLI Proxy", + ...(account.plan ? { plan: account.plan } : {}), + ...(account.email ? { email: account.email } : {}), + limits: account.usageLimits, + }); + } + // A source that failed to read has no accounts left to match on, so its + // error is reported to every provider rather than silently dropped. + if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + notices.push(`${source.label}: ${source.error}`); + } + } + return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; +} diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index ef78b2f24fec..fca086de3186 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -17,7 +17,7 @@ export interface SshPasswordRequest { readonly attempt: number; } -export interface SshAskpassFile { +interface SshAskpassFile { readonly path: string; readonly contents: string; readonly mode?: number; @@ -71,7 +71,7 @@ function joinSshAskpassPath( return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`; } -export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh +const ASKPASS_POSIX_SCRIPT = `#!/bin/sh # Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password # from the renderer's in-app prompt. We never expose a native dialog here - if # T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly. @@ -83,11 +83,11 @@ printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2 exit 1 `; -export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r +const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r `; -export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r +const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r # ssh with a cached password from the renderer's in-app prompt. We never expose\r # a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r # and we fail loudly.\r @@ -99,7 +99,7 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; -export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( +const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -146,31 +146,29 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( }; }); -export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( - function* (input: { - readonly directory: string; - }): Effect.fn.Return { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const descriptor = yield* buildSshAskpassHelperDescriptor(input); - const platform = yield* HostProcessPlatform; - - yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); - - for (const file of descriptor.files) { - const existing = yield* fs.exists(file.path); - const current = existing ? yield* fs.readFileString(file.path) : null; - if (current !== file.contents) { - yield* fs.writeFileString(file.path, file.contents); - } - if (file.mode !== undefined && platform !== "win32") { - yield* fs.chmod(file.path, file.mode); - } +const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(function* (input: { + readonly directory: string; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const descriptor = yield* buildSshAskpassHelperDescriptor(input); + const platform = yield* HostProcessPlatform; + + yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); + + for (const file of descriptor.files) { + const existing = yield* fs.exists(file.path); + const current = existing ? yield* fs.readFileString(file.path) : null; + if (current !== file.contents) { + yield* fs.writeFileString(file.path, file.contents); + } + if (file.mode !== undefined && platform !== "win32") { + yield* fs.chmod(file.path, file.mode); } + } - return descriptor.launcherPath; - }, -); + return descriptor.launcherPath; +}); export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( input: SshChildEnvironmentOptions = {}, diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b43089c..7a94670370b2 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -80,7 +80,7 @@ export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { .slice(0, 16); } -export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { +function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { const destination = target.alias.trim() || target.hostname.trim(); if (destination.length === 0) { throw new Error("SSH target is missing its alias/hostname."); diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index bb702515a31d..840f16170267 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -89,7 +89,7 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) { return matchedPaths.toSorted((left, right) => left.localeCompare(right)); }); -export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( +const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( filePath: string, visited = new Set(), homeDir: string, diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 7dda675ee95c..82061c0d8c84 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -11,7 +11,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeNet from "node:net"; -import { buildRemoteT3RunnerScript } from "./tunnel.ts"; +import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts"; const Started = Schema.Struct({ pid: Schema.Number, @@ -165,3 +165,266 @@ if (args.includes("--package")) { ); }, ); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote stop process ownership", + () => { + it.live.each(["graceful", "timeout", "external"] as const)( + "confirms the stop result for a %s server", + (mode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-stop-" }); + const signalPath = path.join(fixture, "signals"); + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--input-type=module", + "-e", + `import * as fs from "node:fs"; +import * as net from "node:net"; +const server = net.createServer((socket) => socket.end()); +let signals = 0; +process.on("SIGTERM", () => { + fs.writeFileSync(process.argv[2], String(++signals)); + if (process.argv[1] !== "timeout" || signals > 1) server.close(); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write(JSON.stringify({ pid: process.pid, port: server.address().port, args: [] }) + "\\n"); +}); +`, + mode, + signalPath, + ], + { cwd: fixture, detached: false }, + ), + ); + // A failed assertion must still stop this captured fixture process. + yield* Effect.addFinalizer(() => + child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore), + ); + const started = decodeStarted( + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.take(1), + Stream.mkString, + ), + ); + assert.equal(started.pid, child.pid); + const savedState = { + pid: `${child.pid}\n`, + port: `${started.port}\n`, + managed: mode === "external" ? "external\n" : "managed\n", + }; + for (const [name, contents] of Object.entries(savedState)) { + yield* fs.writeFileString(path.join(fixture, name), contents); + } + const script = buildRemoteStopScript({ + alias: "fixture", + hostname: "fixture", + username: null, + port: null, + }); + // Redirect only the state directory. Never use the developer's SSH state. + const isolatedScript = script.replace( + /^STATE_DIR=.*$/mu, + 'STATE_DIR="$T3_TEST_STATE_DIR"', + ); + assert.notEqual(isolatedScript, script); + const runStop = Effect.fn("test.remoteStop")(function* () { + const stop = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s"], { + cwd: fixture, + env: { T3_TEST_STATE_DIR: fixture }, + stdin: Stream.make(new TextEncoder().encode(isolatedScript)), + }), + ); + return yield* Effect.all( + { + stdout: stop.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: stop.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: stop.exitCode, + }, + { concurrency: "unbounded" }, + ); + }, Effect.scoped); + let result = yield* runStop(); + if (mode !== "graceful") { + assert.isTrue(yield* child.isRunning); + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(started.port, "127.0.0.1"); + connection.once("error", (error) => resume(Effect.fail(error))); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + } + if (mode === "timeout") { + assert.equal(result.exitCode, 1); + assert.equal(result.stdout, ""); + assert.include(result.stderr, "did not stop within 2 seconds"); + assert.equal(yield* fs.readFileString(signalPath), "1"); + for (const [name, contents] of Object.entries(savedState)) { + assert.equal(yield* fs.readFileString(path.join(fixture, name)), contents); + } + result = yield* runStop(); + } + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, '{"stopped":true}\n'); + assert.equal(result.stderr, ""); + for (const name of Object.keys(savedState)) { + assert.isFalse(yield* fs.exists(path.join(fixture, name))); + } + if (mode === "external") { + assert.isFalse(yield* fs.exists(signalPath)); + } else { + assert.equal(yield* child.exitCode, 0); + assert.equal(yield* fs.readFileString(signalPath), mode === "timeout" ? "2" : "1"); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4a49cacc2eb8..e2536ba92017 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -13,6 +14,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { SshPasswordPrompt } from "./auth.ts"; +import { SshCommandError } from "./errors.ts"; import { buildRemoteLaunchScript, buildRemotePairingScript, @@ -384,63 +386,205 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("closes the tunnel scope and starts fresh after disconnect", () => { - const spawnedCommands: Array> = []; - let tunnelKillCount = 0; - let stopCommandCount = 0; - const spawner = ChildProcessSpawner.make((command) => - Effect.sync(() => { - const args = commandArgs(command); - spawnedCommands.push(args); - if (args.includes("-N")) { - return makeRunningProcess(() => { - tunnelKillCount += 1; - }); - } - if (args.includes("sh") && args.includes("--")) { - return makeSuccessfulProcess('{"remotePort":3773}\n'); - } - if (args.includes("sh")) { - stopCommandCount += 1; - return makeSuccessfulProcess('{"stopped":true}\n'); + it.effect.each(["successful stop", "failed stop"] as const)( + "closes the tunnel scope and starts fresh after a %s", + (mode) => { + const spawnedCommands: Array> = []; + let tunnelKillCount = 0; + let stopCommandCount = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + spawnedCommands.push(args); + if (args.includes("-N")) { + return makeRunningProcess(() => { + tunnelKillCount += 1; + }); + } + if (args.includes("sh") && args.includes("--")) { + return makeSuccessfulProcess('{"remotePort":3773}\n'); + } + if (args.includes("sh")) { + stopCommandCount += 1; + if (mode === "failed stop" && stopCommandCount === 1) { + return { + ...makeSuccessfulProcess(""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.make( + new TextEncoder().encode("Remote T3 server did not stop within 2 seconds.\n"), + ), + }; + } + return makeSuccessfulProcess('{"stopped":true}\n'); + } + return makeSuccessfulProcess("\n"); + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + return Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + + const first = yield* manager.ensureEnvironment(target); + assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); + const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N")); + assert.isDefined(firstTunnelArgs); + assert.include(firstTunnelArgs, "ControlMaster=no"); + assert.include(firstTunnelArgs, "ControlPath=none"); + assert.include(firstTunnelArgs, "ControlPersist=no"); + + const disconnected = yield* Effect.result(manager.disconnectEnvironment(target)); + if (mode === "failed stop") { + assert.isTrue(Result.isFailure(disconnected)); + if (Result.isFailure(disconnected)) { + assert.instanceOf(disconnected.failure, SshCommandError); + assert.equal( + disconnected.failure.message, + "Remote T3 server did not stop within 2 seconds.", + ); + } + } else { + assert.isTrue(Result.isSuccess(disconnected)); } - return makeSuccessfulProcess("\n"); - }), - ); - const layer = Layer.mergeAll( - NodeServices.layer, - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(HttpClient.HttpClient, testHttpClient), - Layer.succeed(NetService.NetService, testNetService), - SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), - ); - const target = { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - } as const; - - return Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; + assert.equal(tunnelKillCount, 1); + assert.equal(stopCommandCount, 1); - const first = yield* manager.ensureEnvironment(target); - assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); - const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N")); - assert.isDefined(firstTunnelArgs); - assert.include(firstTunnelArgs, "ControlMaster=no"); - assert.include(firstTunnelArgs, "ControlPath=none"); - assert.include(firstTunnelArgs, "ControlPersist=no"); - - yield* manager.disconnectEnvironment(target); - assert.equal(tunnelKillCount, 1); - assert.equal(stopCommandCount, 1); + if (mode === "failed stop") { + yield* manager.disconnectEnvironment(target); + assert.equal(tunnelKillCount, 1); + assert.equal(stopCommandCount, 2); + } - yield* manager.ensureEnvironment(target); + yield* manager.ensureEnvironment(target); + + assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); + assert.equal(tunnelKillCount, 1); + }).pipe( + Effect.provide(layer), + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + assert.equal(tunnelKillCount, 2); + assert.equal(stopCommandCount, mode === "failed stop" ? 3 : 2); + }), + ), + ); + }, + ); - assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); - assert.equal(tunnelKillCount, 1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); + it.effect.each(["local tunnel", "remote server"] as const)( + "waits for %s shutdown before reconnecting the same target", + (stalledStep) => + Effect.gen(function* () { + const shutdownStarted = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + const reconnectsStarted = yield* Deferred.make(); + const pauseShutdown = Deferred.succeed(shutdownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishShutdown)), + ); + let resolutions = 0; + let launches = 0; + let tunnels = 0; + let stops = 0; + let remoteRunning = false; + const target = { alias: "devbox", hostname: "devbox", username: null, port: null }; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = commandArgs(command); + const isTarget = args.includes(target.alias); + if (args.includes("-G")) { + if (isTarget && ++resolutions === 4) { + yield* Deferred.succeed(reconnectsStarted, undefined); + } + return makeSuccessfulProcess(""); + } + if (args.includes("-N")) { + const tunnel = makeRunningProcess(() => undefined); + if (isTarget && ++tunnels === 1 && stalledStep === "local tunnel") { + return { + ...tunnel, + kill: (options?: ChildProcess.KillOptions) => + pauseShutdown.pipe(Effect.andThen(tunnel.kill(options))), + }; + } + return tunnel; + } + if (args.includes("--")) { + if (isTarget) { + launches += 1; + remoteRunning = true; + } + return makeSuccessfulProcess('{"remotePort":3773}\n'); + } + const stop = makeSuccessfulProcess('{"stopped":true}\n'); + if (!isTarget) return stop; + const pause = ++stops === 1 && stalledStep === "remote server"; + return { + ...stop, + exitCode: (pause ? pauseShutdown : Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + remoteRunning = false; + return ChildProcessSpawner.ExitCode(0); + }), + ), + ), + }; + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + yield* Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + yield* manager.ensureEnvironment(target); + const disconnect = yield* Effect.forkChild(manager.disconnectEnvironment(target)); + yield* Deferred.await(shutdownStarted); + const firstReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + const secondReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(reconnectsStarted); + + yield* manager.ensureEnvironment({ + alias: "other", + hostname: "other", + username: null, + port: null, + }); + yield* TestClock.adjust(Duration.zero); + const launchesBeforeShutdown = launches; + yield* Deferred.succeed(finishShutdown, undefined); + yield* Fiber.join(disconnect); + const first = yield* Fiber.join(firstReconnect); + const second = yield* Fiber.join(secondReconnect); + + assert.equal(launchesBeforeShutdown, 1); + assert.equal(launches, 2); + assert.equal(tunnels, 2); + assert.isTrue(remoteRunning); + assert.equal(first.httpBaseUrl, second.httpBaseUrl); + }).pipe( + Effect.ensuring(Deferred.succeed(finishShutdown, undefined)), + Effect.provide(layer), + Effect.scoped, + ); + }), + ); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 04dbce65af60..9cb6b25e4121 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -10,7 +10,6 @@ import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -18,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -49,7 +49,7 @@ import { SshReadinessError, } from "./errors.ts"; -export const DEFAULT_REMOTE_PORT = 3773; +const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; @@ -98,15 +98,6 @@ type SshEnvironmentEffectError = | SshPasswordPromptError | NetService.NetError; -function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { - return new SshCommandError({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`, - }); -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -209,7 +200,7 @@ function buildRemoteNodeEngineCheckScript(): string { (${remoteNodeEngineCheckMain.toString()})();`; } -export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { +function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { const cleaned = stderr.trim(); return cleaned.length > 0 ? cleaned : fallbackMessage; } @@ -270,7 +261,7 @@ function tryPort(port) { })().catch(() => process.exit(1)); `; -export const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); +const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); const port = Number.parseInt(process.argv[2] ?? "", 10); const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10); const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10); @@ -318,7 +309,7 @@ function probe() { })().catch(() => process.exit(1)); `; -export const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { +const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { if [ -d "$1" ]; then case ":$PATH:" in *":$1:"*) ;; @@ -411,7 +402,7 @@ ensure_remote_node_path() { } `; -export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh +const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu @@T3_NODE_ENV_SCRIPT@@ ensure_remote_node_path || true @@ -433,7 +424,10 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi @@ -453,7 +447,7 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE exit 1 `; -export const REMOTE_LAUNCH_SCRIPT = `set -eu +const REMOTE_LAUNCH_SCRIPT = `set -eu @@T3_NODE_ENV_SCRIPT@@ STATE_KEY="$1" STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" @@ -612,7 +606,7 @@ fi printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" `; -export const REMOTE_PAIRING_SCRIPT = `set -eu +const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" DEFAULT_SERVER_HOME="$HOME/.t3" RUNNER_FILE="$STATE_DIR/run-t3.sh" @@ -625,7 +619,7 @@ PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" "$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json `; -export const REMOTE_STOP_SCRIPT = `set -eu +const REMOTE_STOP_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" @@ -639,6 +633,10 @@ if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMO WAIT_COUNT=$((WAIT_COUNT + 1)) sleep 0.1 done + if kill -0 "$REMOTE_PID" 2>/dev/null; then + printf 'Remote T3 server with PID %s did not stop within 2 seconds. Its ownership files were kept.\\n' "$REMOTE_PID" >&2 + exit 1 + fi fi rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" printf '{"stopped":true}\\n' @@ -820,7 +818,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT }; }); -export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( +const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( target: DesktopSshEnvironmentTarget, input?: SshAuthOptions, ): Effect.fn.Return< @@ -1173,12 +1171,22 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ): Effect.fn.Return { const managerScope = yield* Scope.Scope; const tunnels = new Map(); - const pendingTunnelEntries = new Map< - string, - Deferred.Deferred - >(); + const targetLocks = new Map(); const authSecrets = new Map(); + // Keep one lock per target so reconnect cannot reuse a server while stop is pending. + const withTargetLock = Effect.fn("ssh/tunnel.withTargetLock")(function* ( + key: string, + effect: Effect.Effect, + ): Effect.fn.Return { + let lock = targetLocks.get(key); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + targetLocks.set(key, lock); + } + return yield* lock.withPermits(1)(effect); + }); + const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( entry: SshTunnelEntry, ) { @@ -1197,18 +1205,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); }); - const cancelPendingTunnelEntry = Effect.fn("ssh/tunnel.cancelPendingTunnelEntry")(function* ( - key: string, - target: DesktopSshEnvironmentTarget, - ) { - const pending = pendingTunnelEntries.get(key); - if (!pending) { - return; - } - pendingTunnelEntries.delete(key); - yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); - }); - yield* Scope.addFinalizer( managerScope, Effect.sync(() => [...tunnels.values()]).pipe( @@ -1389,7 +1385,17 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma yield* Scope.addFinalizer( entryScope, Effect.gen(function* () { - if (tunnels.get(tunnelEntry.key) !== tunnelEntry) { + const stopRemote = tunnels.get(tunnelEntry.key) === tunnelEntry; + if (stopRemote) { + tunnels.delete(tunnelEntry.key); + } + yield* tunnelEntry.process + .kill({ + killSignal: "SIGTERM", + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + }) + .pipe(Effect.ignore); + if (!stopRemote) { return; } yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", { @@ -1398,34 +1404,24 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma localPort: tunnelEntry.localPort, remotePort: tunnelEntry.remotePort, }); - tunnels.delete(tunnelEntry.key); const authSecret = authSecrets.get(tunnelEntry.key) ?? null; - yield* Effect.all( - [ - tunnelEntry.process.kill({ - killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, - }), - stopRemoteServer( - tunnelEntry.target, - authSecret === null - ? { - batchMode: "yes", - interactiveAuth: false, - } - : { - authSecret, - batchMode: "no", - interactiveAuth: true, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), - Effect.provideService(FileSystem.FileSystem, fileSystemService), - Effect.provideService(Path.Path, pathService), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.ignore); + yield* stopRemoteServer( + tunnelEntry.target, + authSecret === null + ? { + batchMode: "yes", + interactiveAuth: false, + } + : { + authSecret, + batchMode: "no", + interactiveAuth: true, + }, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), + Effect.provideService(FileSystem.FileSystem, fileSystemService), + Effect.provideService(Path.Path, pathService), + ); yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", { ...sshTargetLogFields(tunnelEntry.target), key: tunnelEntry.key, @@ -1448,7 +1444,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma resolvedTarget: DesktopSshEnvironmentTarget, runner?: RemoteT3RunnerOptions, ): Effect.fn.Return { - let entry = tunnels.get(key) ?? null; + const entry = tunnels.get(key) ?? null; if (entry !== null) { yield* Effect.logDebug("ssh.environment.tunnel.existing.check", { @@ -1477,22 +1473,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma cause: readinessExit.cause, }); yield* closeTunnelEntry(entry); - yield* cancelPendingTunnelEntry(key, resolvedTarget); - entry = null; } - const pending = pendingTunnelEntries.get(key); - if (pending) { - yield* Effect.logDebug("ssh.environment.tunnel.pending.await", { - ...sshTargetLogFields(resolvedTarget), - key, - }); - return yield* Deferred.await(pending); - } - - const deferred = yield* Deferred.make(); - pendingTunnelEntries.set(key, deferred); - return yield* createTunnelEntry({ key, resolvedTarget, @@ -1505,13 +1487,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma cause, }), ), - Effect.onExit((exit) => - Effect.sync(() => { - if (pendingTunnelEntries.get(key) === deferred) { - pendingTunnelEntries.delete(key); - } - }).pipe(Effect.andThen(Deferred.done(deferred, exit))), - ), ); }); @@ -1550,33 +1525,39 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshRunnerLogFields(runner), key, }); - const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); + return yield* withTargetLock( + key, + Effect.gen(function* () { + const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); + + const pairingResult = requestOptions?.issuePairingToken + ? yield* runWithSshAuth({ + key, + target: entry.target, + operation: (authOptions) => + issueRemotePairingToken(entry.target, authOptions, runner), + }) + : null; + const pairingToken = pairingResult?.credential ?? null; - const pairingResult = requestOptions?.issuePairingToken - ? yield* runWithSshAuth({ + yield* Effect.logInfo("ssh.environment.ensure.succeeded", { + ...sshTargetLogFields(entry.target), key, + localPort: entry.localPort, + remotePort: entry.remotePort, + remoteServerKind: entry.remoteServerKind, + issuedPairingToken: pairingToken !== null, + }); + return { target: entry.target, - operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner), - }) - : null; - const pairingToken = pairingResult?.credential ?? null; - - yield* Effect.logInfo("ssh.environment.ensure.succeeded", { - ...sshTargetLogFields(entry.target), - key, - localPort: entry.localPort, - remotePort: entry.remotePort, - remoteServerKind: entry.remoteServerKind, - issuedPairingToken: pairingToken !== null, - }); - return { - target: entry.target, - httpBaseUrl: entry.httpBaseUrl, - wsBaseUrl: entry.wsBaseUrl, - pairingToken, - remotePort: entry.remotePort, - ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}), - }; + httpBaseUrl: entry.httpBaseUrl, + wsBaseUrl: entry.wsBaseUrl, + pairingToken, + remotePort: entry.remotePort, + ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}), + }; + }), + ); }); const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* ( @@ -1590,28 +1571,33 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...(target.port !== null ? { port: target.port } : {}), }; const key = targetConnectionKey(resolvedTarget); - const entry = tunnels.get(key) ?? null; - yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { - ...sshTargetLogFields(resolvedTarget), + yield* withTargetLock( key, - hasTunnel: entry !== null, - hasPendingTunnel: pendingTunnelEntries.has(key), - }); - if (entry !== null) { - yield* closeTunnelEntry(entry); - } - yield* cancelPendingTunnelEntry(key, resolvedTarget); - if (entry === null) { - yield* runWithSshAuth({ - key, - target: resolvedTarget, - operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions), - }); - } - yield* Effect.logInfo("ssh.environment.disconnect.succeeded", { - ...sshTargetLogFields(resolvedTarget), - key, - }); + Effect.gen(function* () { + const entry = tunnels.get(key) ?? null; + yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { + ...sshTargetLogFields(resolvedTarget), + key, + hasTunnel: entry !== null, + }); + if (entry !== null) { + // Explicit disconnect owns the remote stop so its failure reaches the caller. + yield* Effect.gen(function* () { + tunnels.delete(key); + yield* closeTunnelEntry(entry); + }).pipe(Effect.uninterruptible); + } + yield* runWithSshAuth({ + key, + target: resolvedTarget, + operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions), + }); + yield* Effect.logInfo("ssh.environment.disconnect.succeeded", { + ...sshTargetLogFields(resolvedTarget), + key, + }); + }), + ); }); return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index d6db5e8bcc59..7f1cf41661fb 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -9,8 +9,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; export const TAILSCALE_STATUS_TIMEOUT = Duration.millis(1_500); -export const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); -export const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); +const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); +const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); // tailscale is a real executable everywhere (`tailscale.exe` on Windows), so // it is always spawned directly rather than through cmd.exe shell mode. @@ -47,7 +47,7 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< ]; /** Classifies stderr into a safe label, dropping the text itself. */ -export const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { +const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => { if (stderr.trim().length === 0) { return undefined; } @@ -66,7 +66,7 @@ export class TailscaleCommandSpawnError extends Schema.TaggedErrorClass()( +class TailscaleCommandOutputError extends Schema.TaggedErrorClass()( "TailscaleCommandOutputError", { ...TailscaleCommandContext, @@ -137,7 +137,6 @@ const TailscaleStatusJson = Schema.Struct({ Self: Schema.optional(TailscaleStatusSelf), }); -export type TailscaleStatusSelf = typeof TailscaleStatusSelf.Type; export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; export interface TailscaleStatus { diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index b342d4b5dd13..5cf80feb3356 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,3 +1,79 @@ +diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +--- a/dist/components/VirtualizedFile.d.ts ++++ b/dist/components/VirtualizedFile.d.ts +@@ -42,7 +42,7 @@ declare class VirtualizedFile extends File { + private computeApproximateSize; + setVisibility(visible: boolean): void; + rerender(): void; +- applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; ++ applyDocumentChange(textDocument: DiffsTextDocument, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; + protected renderPreparedFile({ + fileContainer, + file, +diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +--- a/dist/components/VirtualizedFile.js ++++ b/dist/components/VirtualizedFile.js +@@ -20,6 +20,7 @@ + cache = { + heights: /* @__PURE__ */ new Map(), + checkpoints: [], ++ codeWidth: void 0, + fileAnnotationHeight: 0 + }; + isVisible = false; +@@ -31,6 +32,8 @@ + super(options, workerManager, isContainerManaged); + this.virtualizer = virtualizer; + this.metrics = metrics; ++ const simpleVirtualizer = this.getSimpleVirtualizer(); ++ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this); + } + setMetrics(metrics, force = false) { + if (!force && areObjectsEqual(this.metrics, metrics)) return; +@@ -70,10 +73,12 @@ + if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); + super.setThemeType(themeType); + } +- resetLayoutCache(recompute = false, resetRenderRange = true) { ++ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) { + this.layoutDirty = true; +- this.cache.fileAnnotationHeight = 0; +- if (this.cache.heights.size > 0) this.cache.heights.clear(); ++ if (startLine === 0) this.cache.fileAnnotationHeight = 0; ++ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured. ++ if (startLine === 0) this.cache.heights.clear(); ++ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex); + if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; + if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; + if (recompute && this.isSimpleMode()) this.computeApproximateSize(); +@@ -91,6 +96,13 @@ + if (this.code == null) return hasHeightChange; + const content = this.code.children[1]; + if (!(content instanceof HTMLElement)) return hasHeightChange; ++ const codeWidth = this.code.getBoundingClientRect().width; ++ if (!(codeWidth > 0)) return hasHeightChange; ++ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) { ++ this.resetLayoutCache(false, false); ++ hasHeightChange = true; ++ } ++ this.cache.codeWidth = codeWidth; + const hasFileAnnotations = includesFileAnnotations(this.lineAnnotations); + if (this.renderRange != null && hasFileAnnotations && shouldRenderFileAnnotations(this.renderRange)) { + const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; +@@ -287,11 +299,11 @@ + this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, false); + } +- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) { ++ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) { + const previousRenderRange = this.renderRange; + super.applyDocumentChange(textDocument, newLineAnnotations); + this.getSimpleVirtualizer()?.markDOMDirty(); +- this.resetLayoutCache(this.isSimpleMode(), false); ++ this.resetLayoutCache(this.isSimpleMode(), false, startLine); + if (shouldUpdateBuffer && previousRenderRange !== void 0 && this.file !== void 0) { + const windowSpecs = this.virtualizer.getWindowSpecs(); + const renderRange = this.computeRenderRangeFromWindow(this.file, this.top ?? 0, windowSpecs); diff --git a/dist/editor/editor.js b/dist/editor/editor.js index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js @@ -28,12 +104,18 @@ index ff78e2a..f9df318 100644 const gutterRow = resolveGutterTarget(e.composedPath()[0]); if (gutterRow?.dataset.lineType === "change-deletion") { const code = gutterRow.closest("[data-code]"); -@@ -1522,6 +1520,7 @@ var Editor = class { +@@ -1522,6 +1520,12 @@ var Editor = class { if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; } fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); + if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); - if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); +- if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); ++ if (didLineCountChange) { ++ const previousLineCount = change.lineCount - change.lineDelta; ++ // A wider or narrower line-number gutter can rewrap unchanged rows. ++ const layoutStartLine = String(previousLineCount).length === String(change.lineCount).length ? change.startLine : 0; ++ fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine); ++ } if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); if (newLineAnnotations !== void 0) { @@ -1788,6 +1787,7 @@ var Editor = class { @@ -44,6 +126,37 @@ index ff78e2a..f9df318 100644 try { this.#fileInstance?.setSelectedLines(range, { notify: false, +diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +--- a/dist/managers/ResizeManager.d.ts ++++ b/dist/managers/ResizeManager.d.ts +@@ -5,6 +5,8 @@ + columnVariables?: ResizeManagerColumnVariableMode; + } + declare class ResizeManager { ++ /** Schedule owner measurement after an observed code or gutter size change. */ ++ onResize?: () => void; + private static resizeObserver; + private static managersByElement; + private static getResizeObserver; +diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +--- a/dist/managers/ResizeManager.js ++++ b/dist/managers/ResizeManager.js +@@ -19,6 +19,7 @@ + for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); + } + observedNodes = /* @__PURE__ */ new Map(); ++ onResize; + setup(pre, { disableAnnotations, columnVariables = "apply" }) { + const annotationUpdates = /* @__PURE__ */ new Set(); + const applyColumnVariables = columnVariables === "apply"; +@@ -212,6 +213,7 @@ + this.applyAnnotationUpdates(annotationUpdates); + annotationUpdates.clear(); + this.applyColumnUpdates(codeUpdates); ++ if (codeUpdates.size > 0) this.onResize?.(); + codeUpdates.clear(); + } + applyAnnotationUpdates(annotationUpdates) { diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js @@ -60,6 +173,46 @@ index e9f62f5..af82a46 100644 }; return merged; } +diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js +--- a/dist/renderers/FileRenderer.js ++++ b/dist/renderers/FileRenderer.js +@@ -107,10 +107,10 @@ + result: massiveFile ? void 0 : cache?.result, + renderRange: void 0 + }; ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); + } else if (this.highlighter == null) { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + this.initializeHighlighter(); + } + } +@@ -163,6 +163,8 @@ + if (this.renderCache == null) return; + const { file, result } = this.renderCache; + if (result == null) return; ++ this.workerManager?.cleanUpTasks(this); ++ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey); + const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; + for (const [line, tokens] of dirtyLines) { + if (lineCache != null && line < lineCache.lines.length) { +@@ -268,6 +270,7 @@ + const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); + const newContent = !areFilesEqual(file, this.renderCache.file); + const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { + this.renderCache.file = file; +@@ -278,7 +281,6 @@ + } + if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); + } else { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + const hasThemes = this.highlighter != null && areThemesAttached(options.theme); + const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); + const canHighlight = !forcePlainText && hasLangs; diff --git a/package.json b/package.json index ff61c90..1e170e5 100644 --- a/package.json diff --git a/patches/react-native-gesture-handler@2.32.0.patch b/patches/react-native-gesture-handler@2.32.0.patch index a4f345e9e332..b35b5f64e02f 100644 --- a/patches/react-native-gesture-handler@2.32.0.patch +++ b/patches/react-native-gesture-handler@2.32.0.patch @@ -1,8 +1,8 @@ diff --git a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js -index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6770f4a24 100644 +index 551ab92bf58db0b79d428dcd2f2df898ef686493..13db26d88bff975bc5d46bb6432cbf9fda3d489a 100644 --- a/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js +++ b/lib/commonjs/components/ReanimatedSwipeable/ReanimatedSwipeable.js -@@ -34,6 +34,7 @@ const Swipeable = props => { +@@ -34,11 +34,13 @@ const Swipeable = props => { enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, @@ -10,7 +10,28 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6 friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, onSwipeableOpenStartDrag, -@@ -285,11 +286,14 @@ const Swipeable = props => { + onSwipeableCloseStartDrag, + onSwipeableWillOpen, ++ onSwipeableRelease, + onSwipeableWillClose, + onSwipeableOpen, + onSwipeableClose, +@@ -248,8 +250,13 @@ const Swipeable = props => { + toValue = -rightWidth.value; + } + } ++ // Let a UI-thread full-swipe commit take over before the return spring. ++ if (onSwipeableRelease?.(appliedTranslation)) { ++ return; ++ } ++ + animateRow(toValue, velocityX / friction); +- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]); ++ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]); + const close = (0, _react.useCallback)(() => { + 'worklet'; + +@@ -285,11 +292,14 @@ const Swipeable = props => { }).onFinalize(() => { dragStarted.value = false; }); @@ -27,10 +48,10 @@ index 551ab92bf58db0b79d428dcd2f2df898ef686493..f1c355b8e40cd4f583ef3a501b044fb6 const animatedStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({ transform: [{ diff --git a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js -index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb830493c22 100644 +index a2835d5416ffd5cf9a04e98774516b9e6569691e..055afce410d6eb655532e7a09371dfc22eae5e9c 100644 --- a/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js +++ b/lib/module/components/ReanimatedSwipeable/ReanimatedSwipeable.js -@@ -29,6 +29,7 @@ const Swipeable = props => { +@@ -29,11 +29,13 @@ const Swipeable = props => { enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, @@ -38,7 +59,28 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8 friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, onSwipeableOpenStartDrag, -@@ -280,11 +281,14 @@ const Swipeable = props => { + onSwipeableCloseStartDrag, + onSwipeableWillOpen, ++ onSwipeableRelease, + onSwipeableWillClose, + onSwipeableOpen, + onSwipeableClose, +@@ -243,8 +245,13 @@ const Swipeable = props => { + toValue = -rightWidth.value; + } + } ++ // Let a UI-thread full-swipe commit take over before the return spring. ++ if (onSwipeableRelease?.(appliedTranslation)) { ++ return; ++ } ++ + animateRow(toValue, velocityX / friction); +- }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag]); ++ }, [animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, onSwipeableRelease, appliedTranslation]); + const close = useCallback(() => { + 'worklet'; + +@@ -280,11 +287,14 @@ const Swipeable = props => { }).onFinalize(() => { dragStarted.value = false; }); @@ -55,7 +97,7 @@ index a2835d5416ffd5cf9a04e98774516b9e6569691e..b73c177227bf3a232737b0eb1c0e2bb8 const animatedStyle = useAnimatedStyle(() => ({ transform: [{ diff --git a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts -index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a050edbb0 100644 +index ac8b76830d468edfbc29052b452a36221323c3de..6985d54d9d359e825a5a0e6078bb113204e2807b 100644 --- a/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts +++ b/lib/typescript/components/ReanimatedSwipeable/ReanimatedSwipeableProps.d.ts @@ -64,6 +64,13 @@ export interface SwipeableProps { @@ -72,11 +114,25 @@ index ac8b76830d468edfbc29052b452a36221323c3de..589e329d2aa706ddfff0d1037df0d85a /** * Value indicating if the swipeable panel can be pulled further than the left * actions panel's width. It is set to true by default as long as the left +@@ -90,6 +97,13 @@ export interface SwipeableProps { + * Called when action panel is closed. + */ + onSwipeableClose?: (direction: SwipeDirection.LEFT | SwipeDirection.RIGHT) => void; ++ /** ++ * UI-thread worklet called on release before the built-in spring starts. ++ * Return true to consume the release and take ownership of translation. ++ * The caller must reset or remove the row after its custom animation. ++ */ ++ onSwipeableRelease?: (translation: SharedValue) => boolean; ++ + /** + * Called when action panel starts animating on open (either right or left). + */ diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx -index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e092ea997 100644 +index b6134c908624adc590ebd264e90e558b88e235d5..fa4b1c1749bd0e86cbf5f5546f295d8099e1a481 100644 --- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx -@@ -58,6 +58,7 @@ const Swipeable = (props: SwipeableProps) => { +@@ -58,11 +58,13 @@ const Swipeable = (props: SwipeableProps) => { enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, dragOffsetFromLeftEdge = DEFAULT_DRAG_OFFSET, dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, @@ -84,7 +140,34 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, onSwipeableOpenStartDrag, -@@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => { + onSwipeableCloseStartDrag, + onSwipeableWillOpen, ++ onSwipeableRelease, + onSwipeableWillClose, + onSwipeableOpen, + onSwipeableClose, +@@ -457,6 +459,11 @@ const Swipeable = (props: SwipeableProps) => { + } + } + ++ // Let a UI-thread full-swipe commit take over before the return spring. ++ if (onSwipeableRelease?.(appliedTranslation)) { ++ return; ++ } ++ + animateRow(toValue, velocityX / friction); + }, + [ +@@ -468,6 +475,8 @@ const Swipeable = (props: SwipeableProps) => { + rightWidth, + rowState, + userDrag, ++ onSwipeableRelease, ++ appliedTranslation, + ] + ); + +@@ -537,6 +546,10 @@ const Swipeable = (props: SwipeableProps) => { dragStarted.value = false; }); @@ -95,7 +178,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e Object.entries(relationProps).forEach(([relationName, relation]) => { applyRelationProp( pan, -@@ -552,6 +557,7 @@ const Swipeable = (props: SwipeableProps) => { +@@ -552,6 +565,7 @@ const Swipeable = (props: SwipeableProps) => { enableTrackpadTwoFingerGesture, dragOffsetFromRightEdge, dragOffsetFromLeftEdge, @@ -104,7 +187,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e relationProps, userDrag, diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts -index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866dc95b144c 100644 +index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..3ab14d240da346b1927792e480f5b1e902b03bf8 100644 --- a/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts +++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeableProps.ts @@ -77,6 +77,14 @@ export interface SwipeableProps { @@ -122,3 +205,17 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d /** * Value indicating if the swipeable panel can be pulled further than the left * actions panel's width. It is set to true by default as long as the left +@@ -112,6 +120,13 @@ export interface SwipeableProps { + direction: SwipeDirection.LEFT | SwipeDirection.RIGHT + ) => void; + ++ /** ++ * UI-thread worklet called on release before the built-in spring starts. ++ * Return true to consume the release and take ownership of translation. ++ * The caller must reset or remove the row after its custom animation. ++ */ ++ onSwipeableRelease?: (translation: SharedValue) => boolean; ++ + /** + * Called when action panel starts animating on open (either right or left). + */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2bd2b64ecb3..a97338208943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,14 +92,14 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa + '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-audio@57.0.4: fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a expo-sharing@57.0.17: 8d2e3b10eb3f52036a9a086800180ec6cebf3b75bccc5b1775117a7244d4ac45 - react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 + react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d @@ -109,21 +109,18 @@ importers: .: devDependencies: - '@babel/plugin-transform-react-jsx': - specifier: 7.28.6 - version: 7.28.6(@babel/core@7.29.7) '@effect/tsgo': specifier: 'catalog:' version: 0.13.2 - '@oxlint/plugins': - specifier: ^1.63.0 - version: 1.68.0 '@types/node': specifier: 24.12.4 version: 24.12.4 '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 + knip: + specifier: 6.34.0 + version: 6.34.0 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -142,6 +139,9 @@ importers: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@q1code/core': + specifier: workspace:* + version: link:../../packages/fork-core '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -245,7 +245,10 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@q1code/core': + specifier: workspace:* + version: link:../../packages/fork-core '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -413,7 +416,7 @@ importers: version: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.32.0 - version: 2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -515,6 +518,9 @@ importers: '@effect/vitest': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@q1code/core': + specifier: workspace:* + version: link:../../packages/fork-core '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -586,10 +592,13 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@q1code/core': + specifier: workspace:* + version: link:../../packages/fork-core '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -794,6 +803,9 @@ importers: packages/client-runtime: dependencies: + '@q1code/core': + specifier: workspace:* + version: link:../fork-core '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -886,6 +898,25 @@ importers: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/fork-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/shared: dependencies: '@noble/curves': @@ -994,6 +1025,9 @@ importers: '@types/pngjs': specifier: 6.0.5 version: 6.0.5 + typescript: + specifier: 'catalog:' + version: 6.0.3 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -2143,12 +2177,18 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -3385,6 +3425,128 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.146.0': resolution: {integrity: sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3398,6 +3560,112 @@ packages: '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@oxfmt/binding-android-arm-eabi@0.64.0': resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6981,6 +7249,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -7043,6 +7314,11 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7124,6 +7400,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -7624,6 +7903,11 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@6.34.0: + resolution: {integrity: sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} @@ -8496,6 +8780,13 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxfmt@0.64.0: resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8561,6 +8852,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -8703,6 +8997,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -9498,6 +9796,10 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9617,6 +9919,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -9849,6 +10155,10 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -10275,6 +10585,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -10622,10 +10936,10 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 js-yaml: 4.2.0 - picomatch: 4.0.4 + picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.7.0 + smol-toml: 1.8.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -12000,6 +12314,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -12010,6 +12330,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12528,7 +12853,7 @@ snapshots: hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.33.0 - picomatch: 4.0.4 + picomatch: 4.0.7 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: @@ -13329,6 +13654,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@neon-rs/load@0.0.4': {} '@noble/curves@1.9.1': @@ -13446,6 +13778,63 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/runtime@0.146.0': {} '@oxc-project/types@0.127.0': @@ -13455,6 +13844,69 @@ snapshots: '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@oxfmt/binding-android-arm-eabi@0.64.0': optional: true @@ -13613,7 +14065,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -13627,7 +14079,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -13883,7 +14335,7 @@ snapshots: '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) @@ -17064,10 +17516,18 @@ snapshots: dependencies: bser: 2.1.1 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-nodeshim@0.4.10: {} ffi-rs@1.3.2: @@ -17145,6 +17605,11 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formatly@0.7.0: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -17233,6 +17698,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -17801,6 +18270,22 @@ snapshots: kleur@4.1.5: {} + knip@6.34.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.147.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.0 + zod: 4.4.3 + kubernetes-types@1.30.0: {} lan-network@0.2.1: {} @@ -19083,6 +19568,52 @@ snapshots: outvariant@1.4.3: optional: true + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 @@ -19170,6 +19701,8 @@ snapshots: package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parse-entities@4.0.2: @@ -19308,6 +19841,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} pkg-up@3.1.0: @@ -19544,7 +20079,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-gesture-handler@2.32.0(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.32.0(patch_hash=96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 @@ -20403,6 +20938,8 @@ snapshots: smol-toml@1.7.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -20505,6 +21042,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} + strnum@2.3.0: {} structured-headers@0.4.1: {} @@ -20721,6 +21260,8 @@ snapshots: ultrahtml@1.6.0: {} + unbash@4.0.11: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {} @@ -21138,6 +21679,8 @@ snapshots: vscode-uri@3.1.0: {} + walk-up-path@4.0.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 diff --git a/scripts/fork/install.sh b/scripts/fork/install.sh new file mode 100755 index 000000000000..2b6ec9b8e73f --- /dev/null +++ b/scripts/fork/install.sh @@ -0,0 +1,96 @@ +#!/bin/sh +# q1code installer, published with every release. +# +# curl -fsSL https://github.com/q1/q1code/releases/download/v/install.sh | sh -s -- +# +# Downloads q1code-.tgz and checksums.txt from the release, verifies +# the sha256, and installs the tarball into the launcher layout +# ($Q1CODE_HOME/runtime/versions/). Never uses sudo. Fails closed. +set -eu + +usage() { + echo "usage: install.sh " >&2 + exit 64 +} + +fail() { + echo "install.sh: $*" >&2 + exit 1 +} + +[ "$#" -eq 1 ] || usage +version="${1#v}" +case "$version" in + "" | -*) usage ;; +esac +echo "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \ + || fail "not an exact release version: $version" + +repo="${Q1CODE_RELEASE_REPO:-q1/q1code}" +# Release assets are named after the product; the npm package inside stays `t3`. +asset_prefix="${Q1CODE_ASSET_PREFIX:-q1code}" +package="${Q1CODE_PACKAGE:-t3}" +home="${Q1CODE_HOME:-$HOME/.q1code}" +base_url="${Q1CODE_RELEASE_BASE_URL:-https://github.com/$repo/releases/download/v$version}" +tarball="$asset_prefix-$version.tgz" +prefix="$home/runtime/versions/$version" +entry="$prefix/node_modules/$package/dist/bin.mjs" + +for tool in curl npm node; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required" +done + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + fail "sha256sum or shasum is required" + fi +} + +work="$(mktemp -d "${TMPDIR:-/tmp}/q1code-install.XXXXXX")" +trap 'rm -rf "$work"' EXIT INT TERM +cd "$work" + +echo "Downloading $tarball from $base_url" +curl -fsSL --retry 3 -o "$tarball" "$base_url/$tarball" || fail "download failed: $base_url/$tarball" +curl -fsSL --retry 3 -o checksums.txt "$base_url/checksums.txt" || fail "download failed: $base_url/checksums.txt" + +expected="$(grep -E "^[0-9a-f]{64}[ *]+$tarball\$" checksums.txt | head -n 1 | cut -d' ' -f1)" +[ -n "$expected" ] || fail "$tarball is not listed in checksums.txt" +actual="$(sha256_of "$tarball")" +[ "$actual" = "$expected" ] || fail "sha256 mismatch for $tarball: expected $expected, got $actual" +echo "Verified sha256 $actual" + +if [ -e "$entry" ]; then + echo "$version is already installed at $prefix" +else + # Install into a staging dir and move it into place so a failed install never + # leaves a half-populated version directory behind. + staging="$prefix.installing.$$" + rm -rf "$staging" + mkdir -p "$staging" "$(dirname "$prefix")" + npm install --prefix "$staging" --no-fund --no-audit "./$tarball" \ + || { rm -rf "$staging"; fail "npm install failed"; } + [ -e "$staging/node_modules/$package/dist/bin.mjs" ] \ + || { rm -rf "$staging"; fail "tarball did not provide node_modules/$package/dist/bin.mjs"; } + # pnpm pack normalizes file modes, so bundled native binaries arrive as 0644. + find "$staging/node_modules/$package/dist" -type f \ + \( -path '*/prism/*/cli-proxy-api' -o -path '*/resource-monitor/*/t3-resource-monitor' \) \ + -exec chmod 0755 {} + 2>/dev/null || true + printf '%s\n' "$version" > "$staging/.install-complete" + mv "$staging" "$prefix" + echo "Installed $package $version to $prefix" +fi + +next="service install" +for other in "$home"/runtime/versions/*/; do + [ -d "$other" ] || continue + [ "$other" = "$prefix/" ] && continue + next="service update" +done +echo +echo "Next step:" +echo " node \"$entry\" $next" diff --git a/scripts/fork/leak-check.ts b/scripts/fork/leak-check.ts new file mode 100644 index 000000000000..b4acd065012f --- /dev/null +++ b/scripts/fork/leak-check.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// Guards an upstream PR branch against fork leakage. +// +// node scripts/fork/leak-check.ts --range .. +// +// Fails (exit 1) when any commit in the range adds a line containing a fork +// identifier, or when a commit message still carries fork trailers. Prints +// every hit as ` :: : `. Dependency-free. +import * as NodeChildProcess from "node:child_process"; + +// Run git in the checkout the command was started from (a worktree of the fork +// counts), not where this script file happens to live. +const repoRoot = process.cwd(); + +const diffNeedles: ReadonlyArray = [ + "@q1code/", + "packages/fork-core", + "/src/fork/", + "T3FORK_", + "fork:", + "q1code", + "Fork-Feature:", + "Upstream:", +]; + +const trailerPattern = /^(Fork-[A-Za-z-]+|Upstream):/m; + +interface Hit { + readonly sha: string; + readonly location: string; + readonly needle: string; + readonly text: string; +} + +function parseRange(argv: ReadonlyArray): string { + let range: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--range") { + range = argv[index + 1]; + index += 1; + } else if (arg === "--help" || arg === "-h") { + process.stdout.write("usage: node scripts/fork/leak-check.ts --range ..\n"); + process.exit(0); + } else if (range === undefined && arg !== undefined && !arg.startsWith("--")) { + range = arg; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + if (range === undefined || !range.includes("..")) { + throw new Error("--range .. is required"); + } + return range; +} + +function git(args: ReadonlyArray): string { + return NodeChildProcess.execFileSync("git", [...args], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function checkMessage(sha: string): ReadonlyArray { + const message = git(["show", "--no-patch", "--format=%B", sha]); + const hits: Array = []; + for (const line of message.split("\n")) { + if (trailerPattern.test(line)) { + hits.push({ sha, location: "commit message", needle: "trailer", text: line.trim() }); + } + } + return hits; +} + +function checkDiff(sha: string): ReadonlyArray { + const patch = git(["show", "--format=", "--unified=0", "--no-color", "--no-ext-diff", sha]); + const hits: Array = []; + let file = "?"; + let lineNumber = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+++ ")) { + file = line.slice(4).replace(/^b\//, ""); + continue; + } + if (line.startsWith("--- ") || line.startsWith("diff ") || line.startsWith("index ")) continue; + const hunk = /^@@+ [^+]*\+(\d+)/.exec(line); + if (hunk !== null) { + lineNumber = Number.parseInt(hunk[1] ?? "0", 10); + continue; + } + if (line.startsWith("-")) continue; + if (line.startsWith("+")) { + const text = line.replace(/^\++/, ""); + for (const needle of diffNeedles) { + if (text.includes(needle)) { + hits.push({ sha, location: `${file}:${lineNumber}`, needle, text: text.trim() }); + } + } + } + lineNumber += 1; + } + return hits; +} + +function main(): number { + const range = parseRange(process.argv.slice(2)); + const shas = git(["rev-list", "--reverse", range]) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + if (shas.length === 0) { + process.stdout.write(`leak-check: no commits in ${range}\n`); + return 0; + } + const hits: Array = []; + for (const sha of shas) { + hits.push(...checkMessage(sha), ...checkDiff(sha)); + } + for (const hit of hits) { + process.stderr.write(`${hit.sha.slice(0, 10)} ${hit.location}: ${hit.needle}: ${hit.text}\n`); + } + if (hits.length > 0) { + process.stderr.write( + `leak-check: ${hits.length} hit(s) across ${shas.length} commit(s) in ${range}\n`, + ); + return 1; + } + process.stdout.write(`leak-check: ${shas.length} commit(s) in ${range} are clean\n`); + return 0; +} + +process.exit(main()); diff --git a/scripts/fork/prepare-pack-manifest.ts b/scripts/fork/prepare-pack-manifest.ts new file mode 100644 index 000000000000..3659375dc68b --- /dev/null +++ b/scripts/fork/prepare-pack-manifest.ts @@ -0,0 +1,69 @@ +// @effect-diagnostics nodeBuiltinImport:off - Release packaging runs before any Effect runtime exists. +// Rewrites apps/server/package.json into the publishable manifest the same way +// `apps/server/scripts/cli.ts publish` does (runtime dependencies resolved from +// the pnpm catalog, no workspace devDependencies), so `vp pm pack` can produce +// the release tarball without publishing to npm. Restore with `--restore`. +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; + +import { resolveCatalogDependencies } from "../lib/resolve-catalog.ts"; + +const repoRoot = NodePath.resolve(import.meta.dirname, "../.."); +const manifestPath = NodePath.join(repoRoot, "apps/server/package.json"); +const backupPath = `${manifestPath}.pack-backup`; +// `yaml` is a server dependency, not a scripts one; borrow the server's resolver. +const parseYaml = ( + NodeModule.createRequire(manifestPath)("yaml") as { parse: (text: string) => unknown } +).parse; + +const args = process.argv.slice(2); +if (args[0] === "--restore") { + if (NodeFS.existsSync(backupPath)) NodeFS.renameSync(backupPath, manifestPath); + process.exit(0); +} +const version = args[0]; +if (!version) { + process.stderr.write("usage: prepare-pack-manifest.ts | --restore\n"); + process.exit(2); +} + +const original = NodeFS.readFileSync(manifestPath, "utf8"); +const pkg = JSON.parse(original) as { + name: string; + repository?: unknown; + bin?: unknown; + type?: string; + engines?: unknown; + files?: unknown; + dependencies?: Record; +}; +const workspace = parseYaml( + NodeFS.readFileSync(NodePath.join(repoRoot, "pnpm-workspace.yaml"), "utf8"), +) as { + catalog?: Record; + overrides?: Record; +}; +const catalog = workspace.catalog ?? {}; +const publishable = { + name: pkg.name, + repository: pkg.repository, + bin: pkg.bin, + type: pkg.type, + version, + engines: pkg.engines, + files: pkg.files, + dependencies: resolveCatalogDependencies(pkg.dependencies ?? {}, catalog, "apps/server"), + overrides: resolveCatalogDependencies(workspace.overrides ?? {}, catalog, "apps/server"), +}; +for (const [name, spec] of Object.entries(publishable.dependencies)) { + if (spec.startsWith("workspace:")) + throw new Error( + `runtime dependency ${name} is a workspace package; move it to devDependencies so it gets bundled`, + ); +} +NodeFS.writeFileSync(backupPath, original); +NodeFS.writeFileSync(manifestPath, `${JSON.stringify(publishable, null, 2)}\n`); +process.stdout.write( + `prepared ${pkg.name}@${version} with ${Object.keys(publishable.dependencies).length} dependencies\n`, +); diff --git a/scripts/fork/promote.sh b/scripts/fork/promote.sh new file mode 100755 index 000000000000..41cb64c5011a --- /dev/null +++ b/scripts/fork/promote.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Promotes a gated sync branch to `fork`: scripts/fork/promote.sh +# +# Requires `sync/` on origin with a green Fork CI run on its tip, then +# force-pushes it over `fork` with a lease on the current `fork` sha and writes +# a sync-log skeleton (not committed) if none exists. +set -euo pipefail +# Pin gh to the fork even when the clone also has an `upstream` remote. +export GH_REPO="${Q1CODE_GH_REPO:-q1/q1code}" + +stamp="${1:-}" +[[ -n "$stamp" ]] || { echo "usage: scripts/fork/promote.sh " >&2; exit 64; } +origin="${Q1CODE_ORIGIN_REMOTE:-origin}" +branch="sync/$stamp" +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +die() { + echo "promote: $1" >&2 + exit "${2:-1}" +} + +command -v gh >/dev/null 2>&1 || die "gh is required" +git fetch -q "$origin" fork "refs/heads/$branch:refs/remotes/$origin/$branch" \ + || die "$branch does not exist on $origin" +sha="$(git rev-parse "refs/remotes/$origin/$branch")" +expected="$(git rev-parse "refs/remotes/$origin/fork")" +main_sha="$(git rev-parse "refs/remotes/$origin/main" 2>/dev/null || git rev-parse main)" + +if [[ "$sha" == "$expected" ]]; then + echo "promote: fork already points at $branch ($sha)" + exit 0 +fi + +ci="$(gh run list --branch "$branch" --json conclusion,status,workflowName,headSha --limit 30 \ + --jq "[.[] | select(.workflowName == \"Fork CI\" and .headSha == \"$sha\")] | first // empty")" +[[ -n "$ci" ]] || die "no Fork CI run found for $branch at $sha" 2 +status="$(printf '%s' "$ci" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).status')" +conclusion="$(printf '%s' "$ci" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).conclusion')" +[[ "$status" == "completed" ]] || die "Fork CI on $branch is still $status" 2 +[[ "$conclusion" == "success" ]] || die "Fork CI on $branch concluded $conclusion" 2 + +git push --force-with-lease="refs/heads/fork:$expected" "$origin" "$sha:refs/heads/fork" +echo "promote: fork $expected -> $sha" + +if [[ "$(git rev-parse --abbrev-ref HEAD)" == "fork" ]]; then + [[ -z "$(git status --porcelain --untracked-files=no)" ]] || die "fork is checked out with local changes; update it by hand" + git reset -q --hard "$sha" +else + git branch -f fork "$sha" +fi + +log="fork/docs/sync-log/$stamp.md" +if [[ ! -e "$log" ]]; then + mkdir -p "$(dirname "$log")" + cat > "$log" < +// +// Prints JSON { unchanged, contextOnly, contentChanged, dropped, added } with +// commit subjects. Exit 0 when contentChanged, dropped and added are all empty +// (the deterministic gate), 2 otherwise, 1 on usage or git errors. A patch +// whose only differences are hunk headers or context lines is contextOnly; +// anything unrecognised counts as contentChanged. +import * as NodeChildProcess from "node:child_process"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const repoRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../..", +); + +interface Entry { + readonly oldSha: string | undefined; + readonly newSha: string | undefined; + readonly subject: string; + readonly reasons: ReadonlyArray; +} + +interface Classification { + readonly unchanged: ReadonlyArray; + readonly contextOnly: ReadonlyArray; + readonly contentChanged: ReadonlyArray; + readonly dropped: ReadonlyArray; + readonly added: ReadonlyArray; +} + +const headerPattern = + /^\s*(?:\d+|-):\s+([0-9a-f]+|-+)\s+([=!<>])\s+(?:\d+|-):\s+([0-9a-f]+|-+)\s+(.*)$/; + +function git(args: ReadonlyArray): string { + return NodeChildProcess.execFileSync("git", [...args], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function shaOrUndefined(raw: string): string | undefined { + return /^-+$/.test(raw) ? undefined : raw; +} + +// Inner lines of a `!` entry are the diff between the two patches, indented by +// four spaces. A leading `-`/`+` after the indent marks a line that differs. +function classifyInner(lines: ReadonlyArray): ReadonlyArray { + const reasons = new Set(); + let section: "meta" | "message" | "file" = "file"; + for (const line of lines) { + const inner = line.slice(4); + if (inner.startsWith("@@ ")) { + const title = inner.slice(3).trim(); + section = title === "Metadata" ? "meta" : title === "Commit message" ? "message" : "file"; + continue; + } + const marker = inner[0]; + if (marker !== "-" && marker !== "+") continue; + const rest = inner.slice(1); + if (section === "meta") { + reasons.add("metadata differs"); + } else if (section === "message") { + reasons.add("commit message differs"); + } else if (rest.startsWith("@@")) { + // hunk header offsets only + } else if (rest.startsWith(" ")) { + // context line only + } else if (rest.startsWith("+") || rest.startsWith("-")) { + reasons.add("patch content differs"); + } else { + reasons.add(`unrecognised range-diff line: ${inner.trim()}`); + } + } + return [...reasons]; +} + +function parse(output: string): Classification { + const unchanged: Array = []; + const contextOnly: Array = []; + const contentChanged: Array = []; + const dropped: Array = []; + const added: Array = []; + + const lines = output.split("\n"); + let index = 0; + while (index < lines.length) { + const line = lines[index] ?? ""; + const header = headerPattern.exec(line); + if (header === null) { + index += 1; + continue; + } + const [, oldRaw = "", marker = "", newRaw = "", subject = ""] = header; + const inner: Array = []; + index += 1; + while (index < lines.length && (lines[index] ?? "").startsWith(" ")) { + inner.push(lines[index] ?? ""); + index += 1; + } + const base = { + oldSha: shaOrUndefined(oldRaw), + newSha: shaOrUndefined(newRaw), + subject: subject.trim(), + }; + switch (marker) { + case "=": + unchanged.push({ ...base, reasons: [] }); + break; + case "<": + dropped.push({ ...base, reasons: ["missing from the new range"] }); + break; + case ">": + added.push({ ...base, reasons: ["not in the old range"] }); + break; + case "!": { + const reasons = classifyInner(inner); + if (reasons.length === 0) + contextOnly.push({ ...base, reasons: ["hunk offsets or context only"] }); + else contentChanged.push({ ...base, reasons }); + break; + } + default: + contentChanged.push({ ...base, reasons: [`unknown range-diff marker ${marker}`] }); + } + } + return { unchanged, contextOnly, contentChanged, dropped, added }; +} + +function listCommits(base: string, tip: string): ReadonlyArray { + return git(["log", "--reverse", "--format=%h%x09%s", `${base}..${tip}`]) + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => { + const [sha = "", subject = ""] = line.split("\t"); + return { oldSha: sha, newSha: sha, subject, reasons: [] }; + }); +} + +// `git range-diff` rejects an empty range, so a series that vanished (or was +// created) entirely is classified directly from the commit lists. +function classify( + oldBase: string, + oldTip: string, + newBase: string, + newTip: string, +): Classification { + const oldCommits = listCommits(oldBase, oldTip); + const newCommits = listCommits(newBase, newTip); + if (oldCommits.length === 0 || newCommits.length === 0) { + return { + unchanged: [], + contextOnly: [], + contentChanged: [], + dropped: oldCommits.map((entry) => ({ + ...entry, + newSha: undefined, + reasons: ["missing from the new range"], + })), + added: newCommits.map((entry) => ({ + ...entry, + oldSha: undefined, + reasons: ["not in the old range"], + })), + }; + } + return parse(git(["range-diff", "--no-color", `${oldBase}..${oldTip}`, `${newBase}..${newTip}`])); +} + +function main(): number { + const [oldBase, oldTip, newBase, newTip] = process.argv.slice(2); + if ( + oldBase === undefined || + oldTip === undefined || + newBase === undefined || + newTip === undefined + ) { + process.stderr.write( + "usage: node scripts/fork/range-diff-classify.ts \n", + ); + return 1; + } + const result = classify(oldBase, oldTip, newBase, newTip); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + const gatePasses = + result.contentChanged.length === 0 && result.dropped.length === 0 && result.added.length === 0; + return gatePasses ? 0 : 2; +} + +try { + process.exit(main()); +} catch (error) { + process.stderr.write( + `range-diff-classify: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); +} diff --git a/scripts/fork/rollback.sh b/scripts/fork/rollback.sh new file mode 100755 index 000000000000..3d3d4c26ae9c --- /dev/null +++ b/scripts/fork/rollback.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rolls `fork` back to a snapshot: scripts/fork/rollback.sh +# +# Every promotion is preceded by a `snap/` tag; this force-pushes `fork` +# back to it with a lease on the current `fork` sha. +set -euo pipefail + +tag="${1:-}" +[[ -n "$tag" ]] || { echo "usage: scripts/fork/rollback.sh " >&2; exit 64; } +[[ "$tag" == snap/* ]] || tag="snap/$tag" +origin="${Q1CODE_ORIGIN_REMOTE:-origin}" +cd "$(git rev-parse --show-toplevel)" + +die() { + echo "rollback: $1" >&2 + exit 1 +} + +git fetch -q "$origin" fork +git rev-parse -q --verify "refs/tags/$tag^{commit}" >/dev/null \ + || git fetch -q "$origin" "refs/tags/$tag:refs/tags/$tag" \ + || die "tag $tag not found locally or on $origin" +sha="$(git rev-parse "refs/tags/$tag^{commit}")" +expected="$(git rev-parse "refs/remotes/$origin/fork")" + +if [[ "$sha" == "$expected" ]]; then + echo "rollback: fork already points at $tag ($sha)" + exit 0 +fi + +git push --force-with-lease="refs/heads/fork:$expected" "$origin" "$sha:refs/heads/fork" +echo "rollback: fork $expected -> $sha ($tag)" + +if [[ "$(git rev-parse --abbrev-ref HEAD)" == "fork" ]]; then + [[ -z "$(git status --porcelain --untracked-files=no)" ]] || die "fork is checked out with local changes; update it by hand" + git reset -q --hard "$sha" +else + git branch -f fork "$sha" +fi diff --git a/scripts/fork/seams.ts b/scripts/fork/seams.ts new file mode 100644 index 000000000000..4fb94ba6ecfb --- /dev/null +++ b/scripts/fork/seams.ts @@ -0,0 +1,254 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +// Regenerates fork/SEAMS.md: every upstream file the fork series touches, with +// the features (Fork-Feature trailers) and `fork:` markers behind each change. +// +// node scripts/fork/seams.ts [--check] [--base main] [--head HEAD] +// [--budget 40] [--out fork/SEAMS.md] [--no-write] +// +// --check exits 1 when the seam count exceeds the budget or a seam file carries +// no `fork:` marker. Dependency-free on purpose: it runs before `vp i`. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const repoRoot = NodePath.resolve( + NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), + "../..", +); + +// Paths the fork owns outright. Changes there are additions, not seams. +const forkOwnedPatterns: ReadonlyArray = [ + /^packages\/fork-core\//, + /^apps\/[^/]+\/src\/fork\//, + /^packages\/[^/]+\/src\/fork\//, + /^\.github\/workflows\/fork-/, + /^\.agents\/skills\/fork-[^/]*\//, + /^\.claude\/skills\/fork-/, + /^fork\//, + /^scripts\/fork\//, + // Carried upstream PR #5178 (fork feature swift-ios, see fork/FEATURES.md). + /^apps\/swift-ios\//, + /^docs\/user\/swiftui-mobile\.md$/, + // The Prism settings route (fork feature prism) lives in the upstream routes directory. + /^apps\/web\/src\/routes\/(?:settings\.)?prism\.tsx$/, +]; + +const markerPattern = /(?:\/\/|\/\*|#|