Skip to content

feat(web): open the preview when an action with previewUrl runs - #142

Open
alex-woodhouse wants to merge 117 commits into
mainfrom
t3code/enable-project-script-previews
Open

feat(web): open the preview when an action with previewUrl runs#142
alex-woodhouse wants to merge 117 commits into
mainfrom
t3code/enable-project-script-previews

Conversation

@alex-woodhouse

@alex-woodhouse alex-woodhouse commented Aug 15, 2026

Copy link
Copy Markdown

What Changed

ProjectScript.previewUrl and ProjectScript.autoOpenPreview now actually open the in-app browser preview when an action runs. Both paths an action can start from are covered:

Path A — manual run (toolbar button, actions menu, keybinding). runProjectScript in ChatView.tsx opens a terminal, writes the command, and previously returned. It now opens the preview after a successful write. The trailing write-failure block became an early return so the preview only follows a command that actually reached the terminal. The call is deliberately not awaited: a slow or unreachable preview target must never delay the terminal.

Path B — runOnWorktreeCreate. These never reach the client's runProjectScript — the server runs them in ProjectSetupScriptRunner.runForThread. No server change was needed: the existing setup-script.started thread activity already carries scriptId, so the client looks the action up in activeProject.scripts and opens the preview from that.

Shared logic lives in the new apps/web/src/components/preview/openScriptPreview.ts, following the precedent of the sibling openDiscoveredPort.ts (openPreviewSession(...) then useRightPanelStore.getState().openBrowser(...)).

Behaviour details:

  • URLs go through normalizePreviewUrl from @t3tools/shared/preview — the same helper the browser URL bar uses — so localhost:5173 works as well as a fully-qualified URL.
  • A malformed previewUrl is swallowed and the preview skipped. The command is already running by the time we get there, so a bad URL must not read as a script failure — no toast, no thread error.
  • autoOpenPreview !== true opts out. A previewUrl on its own does not force the panel open; it stays what it already was, a suggestion source for the preview empty state.
  • Path B fires once per activity, keyed by activity id, and only for runs that have not finished — mirroring the server's own deriveUnfinishedSetupRuns. A reconnect or refetch that replays a past setup-script.started does not reopen the panel.
  • Path B does not steal focus: if the thread already has a preview tab, the run is marked handled and the user's tab is left alone.

Why

These two fields have been inert since they were introduced. They are validated by the contracts schema, editable in the Actions dialog, round-tripped through the t3.json importer, and rendered with a preview · desktop only badge in project settings — and then never read at runtime. Users configure a preview URL, tick auto-open, and nothing happens.

Path B is the case that matters most in practice: the dev server an action starts on worktree creation is exactly the thing you want the preview pointed at, and it is the path that never touched the client at all.

On "desktop only"

The badge and the schema comments say these fields are ignored on web. That turned out not to need a decision: the preview panel is already desktop-only everywhere — isPreviewSupportedInRuntime() (previewStateStore.ts:453) is Boolean(window.desktopBridge?.preview), and openFileInPreview early-returns on it. The new helper gates on the same check, so the badge and the schema wording stay accurate as written. No docs change.

UI Changes

No new UI. An existing panel opens in a case where it previously did not.

Testing

Automated, all from a clean install at this branch:

  • pnpm typecheck — clean across all 15 packages (Effect LSP suggestions only, all pre-existing).
  • pnpm lint — no errors; no warnings on any touched file.
  • pnpm fmt:check — clean.
  • pnpm --filter @t3tools/web test — 266 files / 2620 tests pass, including 11 new tests in openScriptPreview.test.ts covering URL normalisation, each opt-out, the malformed-URL swallow, the missing-bridge case, a failed preview session, and the unfinished-run filter.
  • pnpm --filter @t3tools/web build — succeeds.
  • Full-workspace pnpm test was compared against a stashed baseline and is byte-for-byte identical before and after: the one real failure (scripts/build-desktop-artifact.test.ts > skips the primary native probe for cross-architecture Windows payloads) is pre-existing and unrelated.

Manual, on a real desktop (Electron) build with the preview bridge live — all six steps below were executed and passed. Setup: a throwaway git repo added as a project, a static page served on `localhost:5199` with title `PREVIEW-TARGET-OK`, and the action created through the real Actions dialog. Panel state was confirmed out-of-band by listing the Electron webview targets over CDP, so "the panel opened" means the preview webContents actually loaded the target page, not just that a tab appeared.

  1. Path A, all three entry points. Action with `previewUrl: "localhost:5199"` (deliberately bare, to exercise normalisation) and `autoOpenPreview: true`. Toolbar button, actions menu, and keybinding (`Ctrl+Alt+P`) each opened the preview at `http://localhost:5199/\` — the bare host correctly normalised to a full `http://` URL. Target confirmed as `webview | PREVIEW-TARGET-OK | http://localhost:5199/\`. The panel was closed between runs so each open was independently observed.
  2. Path B. Same action with `runOnWorktreeCreate: true`; started a new thread in "New worktree" mode. Work log showed "Setup script started", the setup command ran, and the preview opened — with exactly one preview tab, i.e. once and only once.
  3. `autoOpenPreview: false` with a valid `previewUrl`: panel stayed closed, no error.
  4. `previewUrl: "not a url"` with `autoOpenPreview: true`: no panel, no error toast, and the script still ran (its command appended to a file, which was present afterwards).
  5. No `previewUrl`: unchanged — no panel, script ran, no error. The auto-open checkbox is correctly disabled while the URL field is empty.
  6. Replay. After the setup run completed, the preview tab was closed and the whole app reloaded (fresh renderer, empty seen-set). Reopening the worktree thread — whose `setup-script.started` is now in the past — did not reopen the panel.

Two environment notes, neither related to this change. Electron's binary had not downloaded in my sandbox and had to be installed before any of this was possible. And T3 takes the terminal shell from `$SHELL` (`terminal/Manager.ts:470`); my login zsh runs the powerlevel10k configuration wizard on startup, which parks every new terminal at an interactive prompt so no command ever executes. I re-ran the suite with `SHELL=/bin/bash` to get a usable shell. Worth knowing if setup-script behaviour is ever debugged against a zsh with an interactive rc.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes — no new UI is introduced; an existing panel opens where it previously did not. Verified visually (preview panel showing the target page beside the worktree thread).
  • I included a video for animation/interaction changes — n/a

🤖 Generated with Claude Code

incognitojam and others added 30 commits August 15, 2026 12:45
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci: stabilize fork nightly tests

* ci: isolate image compression test

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(ci): allow Apple notarization to finish

* fix(ci): avoid duplicate Apple notarizations

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci: build unsigned Windows x64 in fork nightly

* ci: add dry_run input to test fork nightly

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
- Run fork CI and nightly jobs for yngatech/t3code

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fix(branding): use yngatech fork identity

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci: migrate Ubuntu jobs to Blacksmith

* ci: keep lightweight jobs on GitHub runners

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat(web): show GitHub service outages

* fix(web): clarify GitHub status link affordance

* feat(web): make GitHub outage alerts opt-in

* refactor(web): remove GitHub status preview override

* test(desktop): include GitHub alert setting

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat(web): show unsent drafts in the sidebar

- Add a draft indicator to sidebar thread rows
- Surface drafts containing text or attachments, excluding settings-only state

* fix(web): brighten the sidebar draft pencil on row hover

The pencil sat at a flat secondary-label colour, so on a settled row it
read brighter than the row's own title and stayed put while the rest of
the row lifted. Match the PR badge's settled treatment: muted at rest,
transitioning with the row on hover. The pencil has no state colour of
its own, so it lifts to the same foreground the title does, and the
active row opts out like the PR badge already does.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* assets: apply yngatech brand colorway

* style(branding): remove redundant palette comments

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
incognitojam and others added 24 commits August 15, 2026 12:46
> [!NOTE]
> Tool rows in the timeline said "Tool call" and printed raw JSON. They
now say what the tool did, with a readable argument, consistently across
Claude, Codex and ACP providers.

## The problem

Every tool row's title came from a 7-value item-type table, one per
adapter, so a `Read`, a `Skill`, a `ToolSearch` and a `SendMessage` all
rendered as **Tool call** followed by their serialized input. The three
adapters also disagreed with each other: the same action read "Command
run" from Claude and "Ran command" from Codex in the same thread.
`TaskCreate` classified as a file write, so it rendered as **File
change** with an edit pencil.

The clients could not do better on their own: `projectActivityPayload`
rebuilt `payload.data` from an allowlist that kept `toolName` only for
`mcp_tool_call`, so for every other call the row genuinely did not know
which tool ran.

None of this is a regression — `titleForTool` and `summarizeToolRequest`
have been unchanged since the Claude adapter landed.

## Before / after

| Before | After |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/yngatech/t3code/assets/pr-tool-call-display/before.png"
width="460"> | <img
src="https://raw.githubusercontent.com/yngatech/t3code/assets/pr-tool-call-display/after.png"
width="460"> |

## How it works

`packages/shared/src/toolRowPresentation.ts` resolves a row in layers —
tool name, then item type, then the provider's own label — and returns a
heading plus a typed argument that the caller formats. The timeline, the
agents panel, the approval card and mobile all read from it, so the
vocabulary converges instead of gaining a fourth dialect.

- Well-typed actions share one verb across providers: **Ran command**,
**Edited file** / **Edited 3 files**, **Viewed image**.
- The unknown-tool bucket names itself: **Read**, **Skill**,
**ToolSearch**, **SendMessage**, **Monitor**. New tools stay correct
with no table entry.
- Three overrides where the tool name would mislead: **Created task** /
**Updated task** (they arrive typed as file changes) and **Saved
memory** / **Updated memory**.
- A title the provider chose for a specific call (an ACP tool's own
name, Codex's `server · tool`) outranks anything derived here.

The projection now ships `data.toolName` plus a short, length-capped
argument allowlist. Because the projection runs at read time over
persisted payloads, existing threads rename retroactively.

## Notes for review

- **Titles, labels and details are never rewritten.** They are the
identity tool rows collapse on, and the server's `toolLifecycleIdentity`
drops superseded rows using the same triple; changing one side without
the other would silently stop update rows folding into their
completions. Naming happens at render time only.
- **Wire size.** The argument allowlist is keyed *and* value-capped at
200 characters, with `content`, `new_string`, `old_string`, `prompt` and
message bodies deliberately excluded; a test asserts a 50 KB edit cannot
survive projection. MCP keeps its arbitrary argument names (an allowlist
would blank every server's expanded row) and caps values instead — that
path previously shipped `input` uncapped.
- **Icon ordering fix.** `image_view` is now tested before the
file-change branch; a viewed image discovers a path, which was stamping
a read-only row with an edit pencil.
- **Deliberately untouched:** `classifyToolItemType`. Its
misclassification is corrected at display time so the adapter stays
byte-identical to upstream. Filed #115 for the approval-routing half of
that bug, which is not cosmetic, and #116 for the uncapped MCP input,
which is upstream's.

## Verification

- `vp test run` across the touched packages: 1,035 passing, plus the
full `apps/server` suite at 2,563.
- `vp run -r typecheck` clean; targeted lint and `vp fmt --check` clean.
- Replayed 4,000 real persisted rows through the new naming to confirm
every category renames and Codex's already-good titles do not move.
- Screenshots above are a live client against seeded fixtures.

---
Written by an agent (Claude Code, claude-opus-5).
> [!NOTE]
> The repo is public, so standard GitHub-hosted runners are free. Moves
the two
> jobs that gain least from paid hardware onto free runners and
right-sizes a
> third. Runner changes only — follow-ups tracked at the bottom.

## Problem

An audit of the last 8 days of run history (all GitHub retains) put
Blacksmith
at roughly $176/mo, with Fork CI the largest bucket at ~$80/mo. Two jobs
were
paying for cores they could not use:

- **Fork Test** spends 209s of its 248s in the `Test` step. Only the
first ~30s
is parallel transform/import fan-out at 100% CPU; the remaining ~2.5min
at
15-20% is `apps/server` alone (`transform 4.85s, import 75s, tests 211s`
—
await-bound tests pinning one or two workers while every other package
has
finished). It is long-poled, not short of cores, so slower cores cost
far
  less than proportionally.
- **Nightly Windows x64** was the least CPU-bound of the three desktop
targets
  per the #51 metrics review.
- **Fork Check** peaks at 4-5 cores during Typecheck; only ~54s of its
97s is
  CPU work at all.

## Fix

- Fork Test → `ubuntu-24.04`, nightly Windows → `windows-2025` (free).
- Fork Check → `blacksmith-4vcpu-ubuntu-2404`.

macOS deliberately stays on Blacksmith — it is genuinely CPU-bound (P95
≥ 91%)
and paces the whole nightly.

Expected cost after this lands: ~$176/mo → ~$90/mo, leaving macOS
(~$29/mo) as
the largest Blacksmith item.

## Verification

`actionlint` reports no new findings, both workflows parse, and the
desktop
build matrix script still emits the expected runner for each target
(`macos-arm64` → `blacksmith-6vcpu-macos-15`, `linux-x64` →
`blacksmith-8vcpu-ubuntu-2404`, `windows-x64` → `windows-2025`).

Note this PR's own checks run under the **old** config; the new runner
choices
are not exercised until it lands on main.

## Risks

Two things only a real run can confirm: disk headroom for the NSIS build
on the
GitHub-hosted Windows runner, and Fork Test's actual wall time on 4
cores
(predicted ~250s → ~300s, since only the 30s phase scales with cores).

## Follow-ups

Split out of this PR to keep it to one concern:

1. **`MessagesTimeline.test.tsx` silently skips itself under load.** It
defers
`await import("./MessagesTimeline")` into `beforeAll(..., 30_000)`. In
isolation the file runs in 3s, but under full-suite contention that hook
hits
its timeout and the file reports `18 tests | 18 skipped` at 30100ms
*without
failing*. Moving the DOM stubs to `vi.hoisted` fixes it structurally.
This is
pre-existing, but the slower runner in this PR makes it more likely —
worth
   landing soon after.
2. **Drop the `merge_group` trigger**, now that the merge queue is
retired.

---
Written by an agent (Claude Code, claude-opus-5).
> [!NOTE]
> The fork's teal stage artwork was scoped to the T3 Chat theme, but the
default T3 Code theme has no `data-theme-id` for that selector to match.
Everyone on the default theme saw upstream's palette. This scopes the
fork pigments to the absence of `data-theme-id` and covers the Dev
blueprint as well as the Nightly sky.

`#96` moved the Yngatech colorway out of `index.css` into `yngatech.css`
and scoped it to `html[data-theme-id="t3-chat"] .stage-nightly`. The
default T3 Code theme never carries a `data-theme-id`:
`applyThemePalette` deletes the attribute whenever the preference does
not resolve to a stored palette, and the settings grid renders that
state as the T3 Code card. So the one theme most users run could not
match the fork's own selector, and fell through to upstream's pigments —
the purple Nightly sky and the blue Dev blueprint.

Scope the fork pigments to the absence of `data-theme-id` instead, and
cover both artworks. The pigments sit on the root rather than on the
artwork element because the sidebar focus rings offset against
`--stage-art-bottom` from outside the SVG, and would otherwise stay
upstream blue while the art went teal.

T3 Chat now derives its sky from its own pigments like Grove, Ocean,
Ember, and Iris. The fork identity belongs to the fork's default theme,
not to a named accent theme.

## Before and after

Both captures use the dark default T3 Code theme at the same sidebar
width and crop.

| | Before | After |
| --- | --- | --- |
| Nightly | ![Nightly sidebar with upstream's purple
sky](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-restore-default-theme-artwork/before-nightly.png)
| ![Nightly sidebar with the fork's teal
sky](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-restore-default-theme-artwork/after-nightly.png)
|
| Dev | ![Dev sidebar with upstream's blue
blueprint](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-restore-default-theme-artwork/before-dev.png)
| ![Dev sidebar with the fork's teal
blueprint](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-restore-default-theme-artwork/after-dev.png)
|

## Verification

- `vp test run src/components/SidebarStageBackdrop.test.tsx --project
unit` — 8 tests passed
- `vp fmt apps/web/src/yngatech.css --check`
- Computed custom properties in a running dev client: the default theme
resolves `--stage-art-top` / `--stage-night-top` to `#5cd6ce` /
`#10484f` in light and `#35939c` / `#10484f` in dark; Iris and T3 Chat
still resolve to their own OKLCH `color-mix` values, confirming the
named built-ins are untouched
- Captures above taken from that same client, forcing each stage variant
in turn

---
Written by an agent (T3 Code, claude-opus-5).
## Problem

The merge queue was retired because it duplicated CI work, but Fork CI
still
carries its plumbing: a `merge_group` trigger and
`github.event.merge_group.*`
fallbacks in the `changes` job's SHA resolution. Nothing fires it, so it
is dead
config that implies a workflow the repo no longer uses.

## Fix

Remove the trigger and the two fallbacks. `pull_request` and `push`
events
already resolve the same SHAs on their own:

- `BASE_SHA` → `pull_request.base.sha` or `github.event.before`
- `HEAD_SHA` → `pull_request.head.sha` or `github.sha`

## Verification

Confirmed no merge queue is configured, so nothing is stranded by this:

- The `PR + CI` ruleset on `main` contains only `pull_request` and
  `required_status_checks` rules — no `merge_queue` rule.
- All four required checks (`Fork Changes`, `Fork Check`, `Fork Release
Smoke`,
  `Fork Test`) run on `pull_request`, so they still report on every PR.

Workflow parses and `actionlint` reports no new findings.

Follow-up to #112.

---
Written by an agent (Claude Code, claude-opus-5).
…118)

## Problem

`MessagesTimeline.test.tsx` needs DOM globals stubbed before its module
graph is
evaluated, because the web `unit` project runs on the **node**
environment. It
did that by deferring the import into a hook:

```ts
let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline;

beforeAll(async () => {
  vi.stubGlobal("window", { /* ... */ });
  ({ MessagesTimeline } = await import("./MessagesTimeline"));
}, 30_000);
```

That puts the module graph's transform cost *inside a hook timeout*. Run
alone
the file takes 3s, but under full-suite contention the hook hit its 30s
timeout
and the file reported `18 tests | 18 skipped` at 30100ms — **without
failing**.
CI stayed green having run 18 fewer tests than it appeared to.

This is pre-existing, but #112 moved Fork Test to a 4-core free runner,
which
makes the contention that triggers it more likely.

## Fix

Move the stubs into `vi.hoisted`, which runs ahead of imports, so
`MessagesTimeline` is imported statically and no hook timeout wraps the
module
graph. This removes the failure mode structurally rather than by raising
the
timeout. `vi.hoisted` is already the idiom in ~10 other web test files.

## Verification

- File alone: 18/18 pass.
- Timing confirms the cost moved out of the hook phase: `tests 2.62s /
import 619ms` before, `tests 98ms / import 2.16s` after. No hook timeout
  applies to the import phase.
- Full `@t3tools/web` suite: 250 files / 2451 tests, **0 skipped**, exit
0.
Previously this run showed MessagesTimeline at `18 tests | 18 skipped`.
- `@t3tools/web` typecheck clean.

Worth noting the original stall was intermittent (1 of 2 full runs), so
a green
run is not by itself proof. The argument for the fix is structural:
there is no
longer a timed hook around the import.

---
Written by an agent (Claude Code, claude-opus-5).
The fork's scheduled release pipeline currently depends on Blacksmith
capacity even though startup latency does not matter for this workload.
During Blacksmith's August 13 us-west incident, the first nightly job
waited roughly 40 minutes for a runner.

Move Fork Nightly and the reusable desktop build to standard
GitHub-hosted runners, which are free for this public repository. Fork
CI's quick rebase check also moves to GitHub; the CPU-heavy check
retains Blacksmith for pull requests but uses GitHub on pushes,
preserving fast review feedback without paying for non-PR work.

Verification: `actionlint` passes for all three changed workflows, and
`git diff --check` is clean.

---
Written by an agent (T3 Code, gpt-5.6-sol).
> [!NOTE]
> Extraction is cached per pull request and runs in sequential batches,
so a nightly re-reads only
> what is actually new and no diff is dropped to fit the prompt budget.
Changelogs now generate with
> `gpt-5.6-terra`, which followed the style guidance more faithfully
than `gpt-5.6-sol` under repeated
> sampling while completing every run, and costs less.

## Problem

Extraction reads every commit the fork carries, and it ran in full on
all five nightly runs a day.
With 94 commits on the stack the evidence measured ~368k characters
against a 160k budget, so
`fitEvidenceToPromptBudget` zeroed the largest diffs first: 68 of 94
changes reached the model with
no code at all, every run, and it worsened with each merge. A single
whole-stack call also sat
against the schema's 120-record ceiling, where overflow drops changes
with no error to report.

## Fix

Cache extraction results per pull request and send only new evidence to
the model.

- **Key.** A hash of the evidence the model reads — PR title, body,
files — with `git patch-id`
standing in for the diff, since the nightly rebase moves context lines
and hunk headers while the
patch ID survives them. Combined with a hash of the extraction request
itself, so editing the
prompt, schema, model, reasoning effort, output limits, or diff budget
invalidates entries without
  anyone bumping a version.
- **Batching.** Pending evidence is split into chronological batches
sized by the records a response
may hold, not by prompt length. Batches run in order rather than
concurrently so each is given the
capability names every earlier change already used, which is what the
single call used to provide.
The cache is written after each batch, so a failed batch resumes there
next run.
- **Grounding.** With batching, `MAX_DIFF_LENGTH` rises from 4k to 10k.
Evidence read without its
  diff is left uncached, so a later batch reads it again whole.
- **Identity changes.** Extraction now flags changes that only alter how
this build identifies
itself. They stay in release highlights, which announce what shipped,
and leave the rolling issue,
which answers how the fork differs from upstream. The release commit
list is rendered from git and
  still lists them.
- **Voice.** Style guidance gained the missing bans and worked examples:
no padding adjectives, no
repository or release-channel vocabulary, and bug fixes named by the
symptom a user would report.

## Model and limits

Repeated sampling compared `gpt-5.6-sol`, `gpt-5.6-luna`, and
`gpt-5.6-terra`. On a focused probe the
three were indistinguishable at classifying identity changes (recall
7.6, 7.8, 7.6 of 8; no false
positives). Synthesising an identical 50-record set five times each
separated them:

| model | style examples reproduced verbatim (of 11) | runs completed |
| --- | --- | --- |
| `gpt-5.6-terra` | 8, 9, 7, 8, 7 | 5/5 |
| `gpt-5.6-sol` | 4, 6, 8 | 3/5 |
| `gpt-5.6-luna` | 4, 4, 5, 6 | 4/5 |

Terra becomes the default: better guidance adherence, no failures, lower
cost. `luna` reproduced one
example in none of its runs.

Sol's two failures were real limits rather than model quirks, and both
are fixed here:

- Reasoning tokens count against `max_output_tokens`, and synthesis
reasons over every record it
reconciles. A rolling summary truncated mid-JSON at 4000 and again at
8000, so synthesis now gets
  16000 while extraction keeps 8000.
- A truncated response still carries well-formed output text, so it used
to fail later as
`SyntaxError: Expected ',' or ']'`. The response's `incomplete` status
is now checked and named.
- Synthesis over a full record set has run past two minutes, so the
request timeout rises to four.

## Verification

`vp test run scripts/generate-fork-features-summary.test.ts` (27
passed), scripts typecheck, targeted
lint. Behaviour was checked end to end against the real stack with a
local stub for the model
endpoint:

| scenario | result |
| --- | --- |
| cold cache | batches with `0 left uncached` throughout — no diff
dropped anywhere |
| unchanged repository | `0 of 95 extracted, 95 reused`, byte-identical
output |
| five new merges | `5 of 94 extracted, 89 reused` |
| full stack rewritten by rebase | `3 of 95 extracted, 92 reused`; the 3
are commits with no PR number, keyed by SHA |
| forced failure on batch 3 | run failed with batches 1–2 durable; next
run `55 of 95, 40 reused` |

Real generations confirmed voice and the identity split — 8 of 57
records came back flagged as
identity, none reached the rolling summary, and the branding work
appeared in release highlights — and
a final cold run on terra completed all six batches with no degraded
evidence.

---
Written by an agent (Claude Code, claude-opus-5).
> [!NOTE]
> Cloning a GitHub fork now wires up the parent as `upstream` and pins
which repository `gh` targets, with the choice offered during the clone
and editable afterwards.

Cloning a fork left the parent repository unreachable — no `upstream`
remote — and, worse, `gh` resolves a two-remote checkout to the fork's
parent, so a pull request created from the workspace could land on
someone else's project without ever saying so.

A fork clone now adds `upstream`, fetches it the way `gh repo clone`
leaves it, and pins a default repository by writing the same
`remote.<name>.gh-resolved` config `gh repo set-default` uses, so T3
Code and the GitHub CLI always agree. Because two remotes mean two
possible targets, the clone flow asks which one; the cloned fork leads,
so the pin agrees with the remote a branch on the fork tracks, and the
parent stays one keystroke away for contributing upstream.

| Before | After |
| --- | --- |
| ![Lookup goes straight to the destination
step](https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/clone-before.png?v=064d1d3f9)
| ![A step asking which repository is the
default](https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/clone-after-step.png?v=064d1d3f9)
|

The destination step then states the relationship instead of repeating
the repository name as a URL:

![Destination step showing "forked from
CircuitLord/BigWalkVRInstaller"](https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/clone-after-destination.png?v=064d1d3f9)

| Before | After |
| --- | --- |
| ![Checkout settings without a default repository
row](https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/settings-before.png?v=064d1d3f9)
| ![Checkout settings with a Default repository
row](https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/settings-after.png?v=064d1d3f9)
|

Mobile gets the same choice at clone time, plus a **Default repository**
row in the thread git sheet so the phone is not a one-way door:

<img
src="https://raw.githubusercontent.com/yngatech/t3code/pr-assets/configure-fork-upstream-remote/mobile-after.png?v=064d1d3f9"
width="320" alt="The fork chooser on iOS with owner avatars">

- `gh-resolved` is not always `base`: with a single remote, `gh repo
set-default` writes an `owner/repo`. That value is parsed and shown
rather than silently overwritten, and the parse is now shared with
`RepositoryIdentityResolver` so the two cannot drift.
- The pin is written with `--replace-all`, since `gh` *adds* resolutions
and a plain write refuses to overwrite a multi-value key (exit 5).
- Only forks send `provider`/`repository` on clone, so every other clone
costs no extra `gh repo view` and behaves exactly as before against an
older server.
- Identity still follows the branch's tracked remote first, per #79; the
pin only decides when a branch has no upstream yet.

- `vp test run apps/server/src/sourceControl apps/server/src/project
packages/shared/src/git.test.ts packages/client-runtime` — 797 tests
passed, including new cases for the `owner/repo` pin, the unset path,
dotted remote names, and a fetch failure leaving the clone intact
- Targeted typecheck, lint, and formatting across server, contracts,
shared, client-runtime, web, and mobile
- Web: clone flow and Settings driven by hand and captured above
- iOS Simulator: fork chooser verified on device; the git sheet row is
typechecked but unexercised, since it needs a thread with real git state
- Two independent agent reviews; findings folded in

---
Written by an agent (Claude Code, claude-opus-5).
The destination and local-folder steps of Add Project had no navigation
title, so the sheet header showed their route names:
`AddProjectDestination` and `AddProjectLocal`. The repository step
already sets one, so this just applies the same pattern to its two
siblings.

Titles are now **Destination** and **Local Folder**.

Split out of #123, where the raw route name showed up in a screenshot.

### Verification

- `tsc --noEmit` for `apps/mobile`, targeted lint and formatting
- iOS Simulator: the destination sheet header reads "Destination" over
Fast Refresh

---
Written by an agent (Claude Code, claude-opus-5).
> [!NOTE]
> TL;DR: Keep PR badges and merged/closed thread settlement stable when
a pruning fetch removes the hosted branch ref.

Git status treated a missing remote-tracking ref as proof that a local
branch had never been published. When a host deleted a merged branch and
fetch pruning removed that ref, T3 stopped querying the provider,
dropped the PR badge, and could move the thread back into the active
list.

Use the surviving branch remote and merge configuration to recover the
hosted head branch after pruning. Local-only and genuinely unpublished
branches retain the zero-provider-call fast path, while config-read
failures fall back to querying instead of caching a false no-PR result.

## Verification

- vp test run apps/server/src/git/GitManager.test.ts (85 passed)
- vp lint apps/server/src/git/GitManager.ts
apps/server/src/git/GitManager.test.ts
- vp run t3#typecheck
- Independent reviewer follow-up: approved with no blocking findings

---
Written by an agent (T3 Code, gpt-5.6-sol).
Every conflict-resolved `source_ref` reshapes the patches it touched, so
the stale-stack guard from #95 flags them and
`allow_missing_main_patches` has to be set on essentially every dispatch
— and that boolean waives the *entire* missing list, including a patch
that merged to `main` after the review. The blanket override defeats the
guard in exactly the window it was built for (#94).

This replaces the boolean with `waived_main_patches`: the reviewed
commits themselves, space or comma separated. Waived patches are listed
in the log and skipped; any other missing patch still fails the
dispatch, so a PR that lands on `main` between review and re-dispatch is
caught instead of silently waived. A literal `true` is rejected with a
pointer to the new input, a waiver entry that does not resolve fails
loudly, and waivers that turn out to be unnecessary are noted without
failing. The runbook documents the new flow.

Verification:
- `vp test run scripts/check-source-stack.test.ts` (12 passed)
- `vp lint scripts/check-source-stack.test.ts`
- `vp fmt --check scripts/check-source-stack.test.ts
docs/operations/fork-nightly.md .github/workflows/fork-nightly.yml`
- `bash -n .github/scripts/check-source-stack.sh`
- `shellcheck .github/scripts/check-source-stack.sh`
- `actionlint -ignore 'label ".*" is unknown'
.github/workflows/fork-nightly.yml`

---
Written by an agent (T3 Code, claude-fable-5).
When `main` gains a commit while a nightly run is in flight, promotion
is skipped and the next candidate picks it up. That is right for
scheduled runs but wrong for `source_ref` dispatches: the run existed to
promote the resolved stack, and the skip is a single log line in a green
run — the maintainer finds out hours later when the next scheduled
rebase hits the same conflicts again.

This makes both promotion paths (the release job's promote step and
prepare's align step) fail loudly when `main` moved during a
`source_ref` run, so the existing `notify_failure` Discord alert fires
while the resolution branch is still warm to refresh and re-dispatch. An
already published release stands either way; scheduled runs keep the
benign skip. The runbook documents the behavior.

Verification:
- `actionlint -ignore 'label ".*" is unknown'
.github/workflows/fork-nightly.yml`
- `vp fmt --check docs/operations/fork-nightly.md
.github/workflows/fork-nightly.yml`
- `git diff --check`

---
Written by an agent (T3 Code, claude-fable-5).
> [!NOTE]
> Tool activity and markdown file chips now omit generated worktree
directory names from paths that are already scoped to the active thread.

File-change rows were formatting paths relative to the active workspace,
then adding the workspace basename back. Generated worktrees therefore
exposed labels such as `t3code-1fc35d1b/` even though the thread already
supplied that context. Markdown file chips repeated the same label in
tooltips and duplicate-name disambiguation.

This moves workspace path formatting into a shared, cross-client helper
and lets scoped UI omit the workspace label. Web, desktop, and mobile
tool rows now show repository-relative paths; expanded details and
copied relative paths follow the same rule. Absolute paths outside the
workspace remain unchanged, and file-opening targets still use the full
resolved path.

## Before and after

| Before | After |
| --- | --- |
| ![Edited-file row showing the generated worktree
directory](https://raw.githubusercontent.com/yngatech/t3code/77d0a87795f399adc67dfa363867ddc978dc0704/docs/user/images/edited-file-path-before.png)
| ![Edited-file row showing a workspace-relative
path](https://raw.githubusercontent.com/yngatech/t3code/77d0a87795f399adc67dfa363867ddc978dc0704/docs/user/images/edited-file-path-after.png)
|

## Verification

- `vp test run apps/web/src/markdown-links.test.ts
apps/web/src/filePathDisplay.test.ts
apps/web/src/components/chat/MessagesTimeline.test.tsx
apps/mobile/src/lib/threadActivity.test.ts
packages/shared/src/toolRowPresentation.test.ts` — 89 tests passed
- Web and shared typechecks passed
- Targeted lint, formatting, and `git diff --check` passed
- Verified the seeded Edited file row in the authenticated T3 preview;
preview navigation and DOM inspection passed
- Mobile activity tests passed. The package-wide mobile typecheck is
currently blocked on `origin/main` by three untouched
`showsSearchDismissButton` type errors in `HomeHeader.tsx`,
`NewTaskContextPickerScreens.tsx`, and `ThreadSettingsSheet.tsx`.

---
Written by an agent (T3 Code, gpt-5.6-sol).
Background update downloads landed in #110, but the sidebar control
still treated downloading as a call to action: it shared the
`isUpdateState` styling branch with the states that want a click, so an
automatic download lit the button and its changelog tooltip in update
blue while needing no input at all. The downloaded state then offered a
circular-arrow icon — the same metaphor this app uses for refresh and
retry everywhere else, including the check-for-updates state of this
very button.

This splits the tone decision into `resolveDesktopUpdateButtonTone`
(`"cta" | "quiet" | "idle"`), so call-to-action colour is reserved for
states that actually want a click: install, or retry a failed download.
The intended flow is now visually staged:

1. Check for updates in the background (existing pollers, unchanged)
2. Update available → download starts automatically (#110, unchanged)
3. **Downloading → muted progress ring around a download glyph, no CTA
colour**
4. **Downloaded → CTA colour with an upward arrow, matching the update
glyph in provider settings**

| | Downloading | Downloaded — ready to restart |
| --- | --- | --- |
| Before | ![Before: downloading rendered as a blue CTA with a spinning
refresh
icon](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-update-pill-quiet-download/before-downloading.png)
| ![Before: downloaded rendered as a blue CTA with a circular rotate
arrow](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-update-pill-quiet-download/before-downloaded.png)
|
| After | ![After: downloading rendered as a muted progress ring around
a download
glyph](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-update-pill-quiet-download/after-downloading.png)
| ![After: downloaded rendered as a blue CTA with an upward circle
arrow](https://raw.githubusercontent.com/yngatech/t3code/assets/pr-update-pill-quiet-download/after-downloaded.png)
|

Full motion — indeterminate spin before the first progress event,
gliding through the 10% progress broadcasts, then flipping to the CTA on
completion:

https://github.com/user-attachments/assets/80d64f3c-06d3-4f9e-aace-bfa2e29f1be8

Implementation notes:

- The ring follows the `ContextWindowMeter` construct (track +
dash-offset arc, `-rotate-90 transform-gpu`,
`motion-reduce:transition-none`), with its stroke transitioned over
500ms so the 10%-step broadcasts from the main process glide instead of
jumping.
- `reduceDesktopUpdateStateOnDownloadStart` reports `downloadPercent: 0`
until the first progress event, which would draw a zero-length arc, so
`0` renders as an indeterminate spinning arc instead.
- A manual retry keeps the button natively disabled for the whole
download; the quiet branch omits the `disabled:` dimming so background
and manual downloads render identically.
- The changelog tooltip stays available while downloading, just without
the update-tinted glass treatment.
- Tone is decided in `desktopUpdate.logic.ts` and covered by tests, so a
regression to the always-CTA behaviour would now fail the suite.

Tests:

- `vp test run apps/web/src/components/desktopUpdate.logic.test.ts
apps/web/src/state/desktopUpdate.test.ts
apps/web/src/components/desktopUpdate.toast.test.tsx
apps/desktop/src/updates/DesktopUpdates.test.ts`
- `vp run --filter @t3tools/web typecheck`
- Targeted lint for the changed TypeScript files

---
Written by an agent (T3 Code, claude-fable-5).
> [!NOTE]
> TL;DR: Run the serial server suite and the remaining workspace suites
on separate runners so their CI time overlaps.

The Fork Test job currently waits for every dependency package suite
before starting the server suite. Since server test files deliberately
run serially, those two long phases add together on the critical path.

This splits the job into server and workspace jobs. The workspace job
explicitly selects all 13 non-server test packages and retains the
isolated image-compression test, Electron setup, and Rust
resource-monitor tests.

Verification:
- actionlint .github/workflows/fork-ci.yml
- Confirmed the workspace selector resolves to exactly 13 non-server
packages
- Confirmed the server selector resolves only to apps/server

---
Written by an agent (T3 Code, gpt-5.6-sol).
> [!NOTE]
> Fixes a regression upstream where loading pull request details
exhausted the GitHub GraphQL API quota because reactions were queried
beneath two 100-item connections, making each refresh cost 104 points.

Opening a pull request detail view could exhaust GitHub’s 5,000-point
hourly GraphQL quota because review-comment reactions sat beneath two
100-wide connections. The activity query was refreshed once per minute
while the view remained active.

Narrow the initial review-thread query to 25 threads and 10 comments,
while retaining pagination and the existing 1,000-thread traversal
ceiling. Add a regression assertion that caps the nested connection
fan-out. The resulting live query costs 6 GraphQL points.

Tests:
- `vp test run apps/server/src/pullRequest/gitHubPullRequestJson.test.ts
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts`
- `vp run --filter t3 typecheck`
- targeted `vp lint` on the four changed files

---
Written by an agent (T3 Code, gpt-5.6-sol).
TSX diffs can render without syntax colors on their first pass. With the
current Shiki JavaScript regex engine, a fresh TSX `codeToTokens` call
returns broad default-color tokens; a second call on the same
highlighter produces the expected tokens. The engine emits no warning or
error. Disabling its lazy-regex compilation also fixes the first pass.

Configure Pierre's diff workers to use Shiki's Oniguruma/WASM
highlighter, which tokenizes TSX correctly on the first pass. This is a
focused workaround for the first-render engine behavior rather than a
claim that the JavaScript engine generally lacks TSX support.

Testing: targeted web lint, `vp run typecheck`, and an isolated
first-pass TSX tokenization comparison across the JavaScript and WASM
engines.

---
Written by an agent (T3 Code, gpt-5.6-sol).
Fork Check still used Blacksmith for pull-request runs even though the
server and workspace suites determine normal full-CI completion time.

Run Fork Check on GitHub-hosted Ubuntu for every trigger, removing the
final Blacksmith dependency from enabled fork workflows.

---
Written by an agent (T3 Code, gpt-5.6-terra).
GitHub outage alerts have been opt-in since the feature was introduced,
so users can miss useful context when GitHub operations fail.

Default the alerts to enabled when no preference has been stored, while
continuing to respect an explicit opt-out. Update the focused contract
coverage and user documentation to match.

Tested with `vp test run packages/contracts/src/settings.test.ts` (37
tests passed).

---
Written by an agent (T3 Code, gpt-5.6-sol).
> [!NOTE]
> `#123`, `GH-123` and `owner/repo#123` in a pull request body are now
links, the way they are on
> GitHub. What each number turns out to be decides where it opens: a
pull request in a project on
> this machine opens as a tab beside the one being read, an issue opens
in a browser, and a number
> the host has nothing under is underlined in red and still opens.

GitHub's autolinked references are its own extension rather than GFM, so
remark left them as they
were: every `pingdotgg#6039` an agent or a reviewer wrote in a description, a
comment or a review stayed
plain text, and following one meant retyping it into a browser.

Reading them is the easy half. Knowing what one *is* takes asking the
host, and a body can name a
dozen — so they are asked about together, in one GraphQL document,
aliased by repository and by
number. `issueOrPullRequest` answers which of the two each number turned
out to be, which is what
decides where it opens; a cache keeps a panel of many bodies from
spawning a process per body for
numbers a neighbour just resolved.

The link is addressed at `/issues/{n}` before any of that comes back,
because the host redirects
that to `/pull/{n}` for a pull request — so it is already right in a
browser, and resolving only
ever improves it. A reference clicked before its answer arrives follows
it as written.

Everything else follows from one rule: **only an answer marks a link as
broken.** A request that
failed on the way — rate limited, logged out, offline — leaves every
reference exactly as it was,
and a null is read as nothing-there only where the host filed
`NOT_FOUND` against it, since
`FORBIDDEN` is SAML or an IP allowlist standing between the reader and
something they can very
likely open themselves. A host having a bad minute must not repaint a
body full of good references
as mistakes.

Inert unless a surface passes the repository to read numbers against, so
`#2` in a conversation —
where it is far more likely to be a step than an issue — stays the plain
text it reads as.

The description of #126, which cites the stale-stack guard from `#95`
and the window it was built
for (`#94`).

| Before | After |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/yngatech/t3code/assets/github-reference-links/before.png"
alt="Reference numbers rendered as plain grey text" width="460"> | <img
src="https://raw.githubusercontent.com/yngatech/t3code/assets/github-reference-links/after.png"
alt="The same numbers rendered as links" width="460"> |

`#95` resolves to a pull request and opens as a tab here; `#94` resolves
to an issue and opens in a
browser, since there is no issue surface to open it in.

A reference the host has nothing under keeps its link colour and takes
the mark an unknown word
takes. No pull request in this repository happens to cite a number the
repository lacks, so the
state below was forced on a resolved reference to photograph it:

<img
src="https://raw.githubusercontent.com/yngatech/t3code/assets/github-reference-links/missing.png"
alt="A reference underlined with a red wavy line" width="460">

Three layers, each of which stands alone: the plugin that reads
references and renders nothing
differently on its own, the request that answers them and is called by
nobody, then the wiring that
turns both on.

- `vp test run` for the touched suites: `sourceControl` (147),
`GitManager`, and the web
  `markdown-github-references`, `chat` and `pullRequest` suites (469).
- Targeted `tsgo --noEmit` for `apps/web`, `apps/server` and
`packages/contracts`, at each of the
  three commits rather than only at the tip.
- The matching rules are GitHub's own, each checked against its renderer
before being written down:
`foo#123` and `#123abc` are not references, `(#123)`, `#123.`,
`#123-abc` and `/#123` are, `#0`
  is not, and code spans and link labels are left alone.
- Read live against this repository in `test-t3-app`: references
linkified in a real description,
`#95` re-addressed itself to `/pull/95` and opened here, `#94` stayed
addressed for the browser.
That pass is also what found two faults nothing else did — a memoized
renderer holding the first
render's answers, and a mark cancelled by `.chat-markdown a {
text-decoration: none }`.

---
Written by an agent (T3 Code, claude-opus-5).
The Files tab could only expand or collapse folders one at a time, even
though diff views already provide a bulk control.

This adds the matching collapse/expand button beside refresh. It follows
manual tree expansion state, collapses every directory when any are
open, and expands every directory when the tree is fully collapsed.

## Verification

- `vp run --filter @t3tools/web typecheck`
- Files-focused web unit suite: 32 tests passed
- Authenticated local web pass: collapse moved the visible tree from 5
expanded folders to 0 and changed the action to “Expand all files”;
expanding restored the folders and changed it back to “Collapse all
files”

---
Written by an agent (T3 Code, gpt-5.6-sol).
> [!NOTE]
> Moves pull request checks out of the Summary scroll and into a
first-class Checks tab, while preserving the existing check actions and
header rollup.

## What Changed

- Added a dedicated Checks tab between Summary and Timeline in both full
and condensed pull request chrome.
- Moved the existing check list, host links, status labels, and
failing-check Fix handoff into the new tab.
- Made the tab-bar check rollup a flat button that opens Checks, and
kept the status icon beside the rollup in the tab content.
- Added a discoverable empty state and a Checks-specific loading ghost.
- Removed the nested Checks section from Summary and added focused
component coverage.

## Why

Checks are a primary pull request state, but they were buried below the
description in Summary. Giving them a tab makes the result directly
reachable without making users scroll through unrelated content.

## UI Changes

| Before: nested under Summary | After: dedicated Checks tab |
| --- | --- |
| ![Checks nested under
Summary](https://raw.githubusercontent.com/yngatech/t3code/2ce6a48902e9f98715a32f34acdafc6018cfc409/assets/pr/pr-checks-tab-before.jpg)
| ![Dedicated Checks
tab](https://raw.githubusercontent.com/yngatech/t3code/2ce6a48902e9f98715a32f34acdafc6018cfc409/assets/pr/pr-checks-tab-after.jpg)
|

Screenshots use the Pull Requests page with host-reported checks from
repository PRs #137 and #138, respectively.

## Verification

- `vp test run
apps/web/src/components/pullRequest/PullRequestChecksTab.test.tsx
apps/web/src/components/pullRequest/pullRequestChecks.test.tsx`
- `vp run --filter @t3tools/web typecheck`
- Targeted formatting and lint checks for the changed pull request
components
- Authenticated local web preview against repository PR #138: the
tab-bar rollup opens Checks, which renders all nine host-reported runs
with the status icon beside the summary and no extra divider

## Checklist

- [x] This PR is small and focused
- [x] I explained what changed and why
- [x] I included before/after screenshots for the UI change
- [x] No video is needed because this change adds no motion or timing
behavior

---
Written by an agent (T3 Code, gpt-5.6-sol).
`previewUrl` and `autoOpenPreview` have been persisted, round-tripped
through the Actions dialog and the t3.json importer, and then ignored:
nothing read them at runtime, so ticking auto-open did nothing.

Both ways an action can start now honour them:

- Manual runs (toolbar button, actions menu, keybinding) go through
  `runProjectScript`, which opens the preview after the command reaches
  the terminal. The write-failure branch becomes an early return so the
  preview only follows a successful write.
- `runOnWorktreeCreate` scripts are launched by the server and never
  touch `runProjectScript`. No server change was needed: the existing
  `setup-script.started` activity already carries `scriptId`, so the
  client looks the action up and opens the preview from that. Keyed by
  activity id, and finished runs are filtered out, so a reconnect or
  refetch that replays the activity does not reopen the panel.

URLs go through `normalizePreviewUrl`, the same helper the browser URL
bar uses, so `localhost:5173` works. A malformed `previewUrl` is
swallowed rather than surfaced: the command is already running by then,
and a bad URL is not a script failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@incognitojam incognitojam left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the manual-run path here is in good shape, but I'd like to reduce this PR's scope to that path and split the runOnWorktreeCreate path into a follow-up. Two reasons, one small and one structural.

1. The preview URL isn't resolved against the thread's environment (applies to both paths, small fix)

resolveScriptPreviewUrl (apps/web/src/components/preview/openScriptPreview.ts:24) only runs normalizePreviewUrl, so localhost:5173 reaches the preview as-is. For a thread on a remote environment, the script runs on the remote machine while the preview loads the desktop's own localhost — potentially an unrelated local service. The codebase already solves this: resolveDiscoveredServerUrl (apps/web/src/browser/browserTargetResolver.ts:240) rewrites loopback URLs to the environment host, and openDiscoveredPort.ts — the exact precedent this PR's description cites — goes through it. openScriptPreview should resolve with threadRef.environmentId the same way (note openDiscoveredPort records the original URL in history and opens the resolved one — keep that split). The test at openScriptPreview.test.ts:90 currently asserts the unresolved URL, so it locks the bug in and needs updating with the fix.

2. Path B has no durable "already handled" state — several symptoms, one root cause

The dedup for setup-script.started is a per-React-mount ref (apps/web/src/components/ChatView.tsx:3131), and every preview.open unconditionally mints a new server-side session (newPreviewTabId(), apps/server/src/preview/Manager.ts:216). That combination produces a family of problems:

  • Duplicates: a second window or client viewing the same thread processes the same activity independently and opens its own preview session; a renderer reload resets the ref and can reopen.
  • Reopen-forever: a dev server never emits setup-script.completed, so its run stays "unfinished" indefinitely — every fresh mount with no active preview tab reopens the panel, including after the user deliberately closed it and restarted the app.
  • Never-opens: the inverse case — a script that launches a server in the background and exits can have its started and terminal activity arrive in one batch, so unfinishedSetupScriptStarts drops it and no preview opens at all.
  • No retry: the activity id is added to the handled set (ChatView.tsx:3136) before the script lookup and before preview.open resolves, so a transiently stale project snapshot or a failed RPC is consumed permanently.

I don't think these are four bugs to patch individually. They're one missing design decision: where does "this setup run's auto-open has been consumed" live? The client can't answer that from replayed activities plus in-memory state, no matter how the filtering is tuned. It most likely needs to be server-owned — a consumed marker on the setup run, or the server (which already owns the run lifecycle in ProjectSetupScriptRunner) driving the open — and that's a bigger change than this PR should absorb.

Suggested split

Reduce this PR to Path A plus the fix for issue 1. That path is sound as written: the write-failure early return is behavior-preserving, fire-and-forget is the right call, the opt-outs and malformed-URL swallow are sensible, and the unit tests are good. Path B moves to a follow-up whose PR description leads with the idempotency design, and unfinishedSetupScriptStarts and its tests move with it.


Written by an agent (Claude Code, claude-fable-5).

@yngatech-nightly
yngatech-nightly Bot force-pushed the main branch 3 times, most recently from 2698323 to 44e3d33 Compare August 16, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants