diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index c234484aa5d..53d2095d7b2 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -74,6 +74,12 @@ Fork Workflow section pointing to .fork/AGENTS.md, which defines the branch model (work from custom, never touch main) and the placement ladder for fork changes. + AGENTS.md also carries the fork-authored Change Scope section, fenced as + fork-change-scope, and it must sit ahead of upstream's first section + heading: it is the scope contract an agent reads before upstream's own + guidance, not an appendix. Upstream owns the rest of the document — a sync + takes upstream's rewrite wholesale and re-seats these two fenced sections, + Change Scope at the top and Fork Workflow at the bottom. CLAUDE.md is a Tier-4 inline edit the fork owns outright, and it deliberately diverges from the blob upstream ships. Upstream's committed symlink target carries a trailing newline, so it names a file that cannot @@ -370,6 +376,25 @@ - apps/web/src/__fork_guards__/sidebarV2Rain.test.ts - apps/web/src/__fork_guards__/sidebarV2CardRows.test.ts +- id: sidebar-v2-error-tooltip + intent: > + The thread tooltip shows the session's actual last error, not a literal. + Upstream's v0.0.30 tooltip restyle (dd5ea3248) flattened the line to the + string "Error occurred", which removed the only place the sidebar + surfaced what failed — diagnosing a dead session meant opening it, and + this fork's users drive many agents at once from that sidebar. The + message wraps rather than truncating: an error's tail (exit codes, + paths) is routinely the useful half, and the tooltip is already the + row's overflow surface. A sync that reintroduces the literal is a + regression even though it renders fine. + tier: 4 + files: [] + shadows: [] + watch: + - apps/web/src/components/SidebarV2.tsx + verify: + - apps/web/src/__fork_guards__/sidebarV2ErrorTooltip.test.ts + - id: sidebar-v2-dev-server-pulse intent: > The thread card's branch/worktree mark pulses working-green to foreground @@ -611,6 +636,21 @@ scroll gutter too; fork-sidebar-chrome now compensates for that gutter in the list's own end padding, so a scrollbar term reappearing here would double-count it and walk both icons back off the column. + + Upstream v0.0.30 reworked its own card's status slot toward the same goal + (an absolute actions overlay with focus-within:static and a min-w-8 slot) + and put the "Settle" text label back on the card action. Both were + declined at the sync: the fork's shared-grid-cell already solves the + overlap upstream was chasing — the cell is as wide as whichever state is + showing, so the title truncates against the real width — and the text + label is what this entry removed in the first place (at 282px it pushed + the actions over the title, which now shares their line). A sync that + reintroduces either is resolving the conflict backwards. Upstream's + matching restyle of sidebarMenuButtonVariants ([&>svg] muted at + opacity-60) was kept for upstream surfaces but is displaced on the fork's + chrome rows, whose buttons re-assert the /80 tint at full opacity — + parent-level [&>svg] outweighs the icon's own class, so without that the + fork tint is silently dead and the duotone glyphs double-fade. tier: 4 files: # Owned by fork-sidebar-chrome, which sets these rows' metrics; this entry @@ -747,6 +787,27 @@ The raster-vs-SVG history and the reason Nightly is unreachable in practice are in .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md. + + Upstream v0.0.30 layered an environmentIdentificationMode setting + (artwork / pill / none) over the same surfaces. The fork's header art + ignores it — brand chrome is not identification and must not gate on a + setting whose off states would strip the packaged app's header — but the + pill half is honored: "Version pill" renders upstream's badge in this + header, white-on-art rather than secondary-on-plain, because upstream only + ever drew the pill here and dropping it would make that option do nothing + anywhere. artwork/none keep governing the composer send button exactly as + upstream wrote it; the standalone auth screen never read the mode — + upstream gates that art on the build channel alone — so the setting's + only fork-visible effects are the composer button and this pill. The + same release also moved + upstream's search/scope controls into SidebarContent's fixedHeader slot; + the fork declines that mechanism — its own rows sit as siblings above the + self-scrolling list group, which pins them identically — and a sync must + not re-adopt fixedHeader here just to shrink a conflict. The header's + stage label now comes from upstream's useEnvironmentStageLabel, which + replaced the fork's local hook when upstream's became an exact equivalent + (both delegate to resolveServerBackedAppStageLabel with the fork's + APP_STAGE_LABEL fallback). tier: 4 files: - apps/web/src/custom/SidebarHeaderBackdrop.tsx diff --git a/.fork/notes/RELEASE-NOTES-PENDING.md b/.fork/notes/RELEASE-NOTES-PENDING.md new file mode 100644 index 00000000000..154fe0661da --- /dev/null +++ b/.fork/notes/RELEASE-NOTES-PENDING.md @@ -0,0 +1,11 @@ +# Pending release-note lines + +Lines to fold into the next fork release's notes, then delete from here. +Each entry names the sync or PR that created the obligation. + +- **v0.0.30 sync (PR #33):** Prompt stashes saved before this release are + cleared on first launch. Upstream moved the stash store to a new + storage key and deliberately deletes the old one at startup without + migrating (localStorage quota rationale, `promptStashStore.ts`); the + sync keeps that behavior. Users with stashed prompts they care about + should send or copy them before upgrading. diff --git a/AGENTS.md b/AGENTS.md index 73e5fbacc10..86d066bd723 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,10 @@ -# AGENTS.md +# T3 Code + +T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. + +You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. + + ## Change Scope @@ -9,56 +15,141 @@ - Prefer fixing over rewriting, and deleting over adding. If both a targeted fix and a refactor would work, ship the fix. - Do not mix unrelated fixes into one change. `CONTRIBUTING.md` describes the PR expectations; they apply to agent-authored changes too. -## Task Completion Requirements + + +## What makes T3 Code special? + +We have over 100,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. + +### 1. Open at the core + +T3 Code is truly open. We share our roadmap, we share how we think about things, and of course we share all our code. A large number of our users run forks. We work in the open, and should strive to stay that way. + +### 2. Performance without compromise + +Lots of apps have gotten bogged down with bad tech decisions and "slop". We have not, and we're proud of the performance of T3 Code. We regularly audit for performance regressions, often caused by sending too much data over websockets, css animations causing gpu spikes, lists being hard to render, and more. Make sure all changes are considerate of performance impact. + +### 3. Remote ready + +The architecture of T3 Code's websocket layer (npx t3) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. + +### 4. Multi-surface + +T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. + +**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. + +**Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. + +**Mobile** is a React native app for both iOS and Android. The mobile app allows for connecting to any T3 Code server to control work remotely. It is still in early access (Testflight), but it is pretty close to shipping globally. + +## A note from Theo + +I like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising. + +Channel both "measure twice, cut once" and "yagni". Fight scope creep. Try to honor the dev's intent in both a minimal and realistic fashion. + +The rest of this document is meant to help you navigate the codebase and make changes effectively. Think of these instructions less as "hard rules", more as "good defaults". The developer's preferences should be able to override anything here. + +Of note: Most T3 Code contributions will come from T3 Code itself, often controlled remotely. This means you should be careful about accessing data, killing dev servers, and other things that may damage the T3 Code instance that the contributor is using. + +## A small glossary + +We need to be on the same page with terminology. When communicating, use this language: + +- **you** means the agent reading this file and changing T3 Code. +- **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. +- **user** means the person using T3 Code to direct coding agents. +- **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. +- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. +- **client** means the web, desktop, or mobile UI. +- **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. +- **project** means an environment-local workspace record rooted at a directory. +- **thread** means the durable conversation and work history for a project. +- **turn** means one user-to-agent cycle, including follow-up work such as checkpointing. +- **T3 home** means the base data directory. Runtime state normally lives below its userdata directory. + +## The three ways to hurt yourself + +1. **Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or `kill` a PID you found by matching a name, path, or worktree string. Your own agent process has this worktree's path in its argv, and this machine runs several other dev servers at once. Kill only a PID you captured at spawn, or the owner of your port from `ss -H -ltnp` after confirming `/proc//cwd` is your worktree. +2. **Touching the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Read-only inspection is fine. Never start a server against it, never open it read-write, never clean it up. +3. **Baking in origins.** Never set `VITE_HTTP_URL` or `VITE_WS_URL` for dev. Dev is single-origin and Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known`. Setting them bakes localhost into the bundle and silently breaks every remote browser. + +## Hit every surface + +The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: + +- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. +- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` +- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". +- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. +- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. +- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. +- **Docs.** `docs/` mirrors this structure. Behavior changes that a user would notice belong in `docs/user/`; architecture changes in `docs/architecture/`; new vocabulary in `docs/reference/encyclopedia.md`. + +## Dev servers + +- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. +- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. +- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. +- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url +- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. +- Stop what you started, by the PID you tracked. See rule 1. + +## Test data + +An empty database is a bad test. Seed your worktree's `.t3` instead of pointing at live state: + +- Copy from `~/.t3/dev`, never from `~/.t3/userdata`. +- Copy `state.sqlite` together with its `-wal` and `-shm` siblings, and only while no server has the source open. A live copy is a corrupt copy. +- Bring `secrets` and `settings.json` only if the flow under test needs them. +- Copy in, never symlink. Data flows one way: into your sandbox, never back out. + +## Verifying + +- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. +- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. +- Backend behavior changes ship with focused tests for that behavior. +- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. +- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. -- Keep local verification focused on the files and packages changed. Run the smallest relevant test set; do not run the full workspace test suite as a routine completion step. - - Use `vp test run ` for focused built-in Vite+ tests. Use `vp run test` only when the affected package specifically requires its `test` script. - - Backend changes must include and run focused tests for the changed behavior. - - Run targeted formatting, lint, and type checks for the affected scope when available. -- Do not run repo-wide `vp check`, `vp run typecheck`, `vp run test`, or equivalent full-suite commands locally unless the user explicitly requests them. CI is responsible for the full verification suite. -- After frontend feature development or any user-visible frontend behavior change, the primary agent must run one integrated verification pass for each affected client surface after integrating the work: - - Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed pairing URL, and verify the affected flow in the controlled browser. - - Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android Emulator available on the host to one isolated environment and verify the affected flow. On compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in the T3 Code in-app browser or another available agent browser; use Android when it is the affected or viable platform. - - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it. - - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete. +## Pull requests -## Dev Servers +- Never make a PR unless the developer explicitly asks you to do so. +- Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. +- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. +- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. +- UI changes need before/after images. Motion or timing needs a short video. +- One concern per PR. If the description says "also", split it. +- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. -- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. -- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. -- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. -- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. -- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports. +## How it works -## Package Roles +Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. -- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. -- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. -- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. -- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. -- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. +Full glossary with file links: `docs/reference/encyclopedia.md` -## Reference Repos +## Where code lives -- Open-source Codex repo: https://github.com/openai/codex +- `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` and `docs/operations/effect-fn-checklist.md` before writing Effect code. +- `apps/web` - React/Vite UI. `apps/desktop` wraps it, `apps/mobile` is React Native, `apps/marketing` is the site. +- `packages/contracts` - Effect/Schema contracts. Schema only, no runtime logic. +- `packages/shared` - shared runtime utils, subpath exports, no barrel. +- `packages/client-runtime` - client code shared by web and mobile. +- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. -Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. +## Taste -## Vendored Repositories +- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- Inferred types over annotations. `any` is the enemy. +- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. +- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. +- If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. -This project vendors external repositories under `.repos/` as read-only reference material for coding -agents. +## Additional tips -- Prefer examples and patterns from the vendored source code over generated guesses or web search results. -- Do not edit files under `.repos/` unless explicitly asked. -- Do not import from `.repos/`; application code must continue importing from normal package dependencies. -- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo ` to sync one configured repository. -- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so - `.repos/` matches the installed dependency version. -- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for - examples of idiomatic usage, tests, module structure, and API design. -- When writing relay infrastructure code with Alchemy, inspect `.repos/alchemy-effect/` for examples of - idiomatic usage, tests, module structure, and API design. +- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. +- Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. diff --git a/README.md b/README.md index 0fbbe90ee66..9b63f4ac75d 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,36 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, Cursor, and OpenCode, more coming soon). +T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app, [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). + +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. + +## "Wait, what are you selling me?" + +Nothing. We built T3 Code because we wanted the best possible development experience with agents. We were inspired by existing solutions like the Codex desktop app, Conductor, Claude Desktop and Cursor Glass, but none met our bar. + +We wanted something performant, remote-ready, and truly open. If we ever go the wrong direction, we want you to have everything you need to fork and build the editor that you want. ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, and OpenCode. -> Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `cursor-agent login` +> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` -### Run without installing +### Try it out (install-free) + +The easiest way to test T3 Code is to run the server in your terminal: ```bash npx t3@latest ``` +This will launch T3 Code's backend on your machine as well as the local web app to control your agents. + Tip: Use `npx t3@latest --help` for the full CLI reference. ### Desktop app @@ -47,7 +59,7 @@ yay -S t3code-bin We are very very early in this project. Expect bugs. -We are not accepting contributions yet. +We are (mostly) not accepting contributions yet. Small fixes may be considered. Big features will not be. There's no public docs site yet, checkout the miscellaneous markdown files in [docs](./docs). diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 40f12a0304a..f4aafb06441 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.29", + "version": "0.0.30", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 6933badcba7..52dccea7321 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -63,6 +63,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setDockIcon.push(iconPath); }), appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts new file mode 100644 index 00000000000..107ee21d232 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -0,0 +1,123 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +describe("DesktopLifecycle", () => { + for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { + it.effect(`lets the updater's quit event proceed on ${platform}`, () => { + const appListeners = new Map void>(); + + const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { + metadata: Effect.die("unexpected metadata read"), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + appListeners.delete("before-quit-for-update"); + }), + ).pipe(Effect.asVoid), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + appListeners.set( + eventName, + listener as unknown as (...args: readonly unknown[]) => void, + ); + }), + () => + Effect.sync(() => { + appListeners.delete(eventName); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronApp.ElectronApp["Service"]); + + const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + setSource: () => Effect.void, + onUpdated: () => Effect.void, + }); + + const desktopWindowLayer = Layer.succeed(DesktopWindow.DesktopWindow, { + createMain: Effect.die("unexpected window creation"), + ensureMain: Effect.die("unexpected window creation"), + revealOrCreateMain: Effect.die("unexpected window creation"), + activate: Effect.void, + createMainIfBackendReady: Effect.void, + showConnectingSplash: Effect.void, + handleBackendReady: () => Effect.void, + handleBackendNotReady: Effect.void, + flushMainWindowBounds: Effect.void, + dispatchMenuAction: () => Effect.void, + syncAppearance: Effect.void, + }); + + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform, + isDevelopment: false, + } as DesktopEnvironment.DesktopEnvironment["Service"]); + + const layer = DesktopLifecycle.layer.pipe( + Layer.provideMerge(electronAppLayer), + Layer.provideMerge(electronThemeLayer), + Layer.provideMerge(desktopWindowLayer), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(DesktopShutdown.layer), + Layer.provideMerge(DesktopState.layer), + ); + + return Effect.scoped( + Effect.gen(function* () { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + yield* lifecycle.register; + + appListeners.get("before-quit-for-update")?.(); + + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + } as Electron.Event; + appListeners.get("before-quit")?.(event); + + assert.isFalse( + prevented, + "cancelling this event prevents the updater from completing its relaunch", + ); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + }), + ).pipe(Effect.provide(layer)); + }); + } +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..ab03d18f38d 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -176,16 +176,28 @@ export const make = DesktopLifecycle.of({ const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); let quitAllowed = false; + let updaterQuitAllowed = false; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), ); }); + yield* electronApp.onBeforeQuitForUpdate(() => { + // Electron's updater owns the remaining quit/install/relaunch sequence. + // Cancelling the following app "before-quit" event breaks that sequence, + // most visibly on macOS where the native updater performs the relaunch. + updaterQuitAllowed = true; + void runEffect( + logLifecycleInfo("allowing updater-controlled quit").pipe( + Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), + ), + ); + }); yield* electronApp.on("before-quit", (event: Electron.Event) => { handleBeforeQuit( event, runEffect, - () => quitAllowed, + () => quitAllowed || updaterQuitAllowed, () => { quitAllowed = true; }, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index f3ce3b4b5f4..077b343959c 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -4,6 +4,8 @@ import { beforeEach, vi } from "vite-plus/test"; const { appendSwitchMock, + autoUpdaterOnMock, + autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, getVersionMock, @@ -23,6 +25,8 @@ const { whenReadyMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), + autoUpdaterOnMock: vi.fn(), + autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), getVersionMock: vi.fn(() => "1.2.3"), @@ -43,6 +47,10 @@ const { })); vi.mock("electron", () => ({ + autoUpdater: { + on: autoUpdaterOnMock, + removeListener: autoUpdaterRemoveListenerMock, + }, app: { commandLine: { appendSwitch: appendSwitchMock, @@ -77,6 +85,8 @@ import * as ElectronApp from "./ElectronApp.ts"; describe("ElectronApp", () => { beforeEach(() => { appendSwitchMock.mockClear(); + autoUpdaterOnMock.mockClear(); + autoUpdaterRemoveListenerMock.mockClear(); exitMock.mockClear(); onMock.mockClear(); quitMock.mockClear(); @@ -153,4 +163,22 @@ describe("ElectronApp", () => { assert.deepEqual(removeListenerMock.mock.calls, [["activate", listener]]); }).pipe(Effect.provide(ElectronApp.layer)), ); + + it.effect("scopes native updater quit listeners", () => + Effect.gen(function* () { + const listener = vi.fn(); + + yield* Effect.scoped( + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + yield* electronApp.onBeforeQuitForUpdate(listener); + }), + ); + + assert.deepEqual(autoUpdaterOnMock.mock.calls, [["before-quit-for-update", listener]]); + assert.deepEqual(autoUpdaterRemoveListenerMock.mock.calls, [ + ["before-quit-for-update", listener], + ]); + }).pipe(Effect.provide(ElectronApp.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0af8691f6c4..933f40e1705 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -66,6 +66,9 @@ export class ElectronApp extends Context.Service< readonly setDesktopName: (desktopName: string) => Effect.Effect; readonly setDockIcon: (iconPath: string) => Effect.Effect; readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; + readonly onBeforeQuitForUpdate: ( + listener: () => void, + ) => Effect.Effect; readonly on: >( eventName: string, listener: (...args: Args) => void, @@ -178,6 +181,16 @@ export const make = ElectronApp.of({ } Electron.app.commandLine.appendSwitch(switchName, value); }), + onBeforeQuitForUpdate: (listener) => + Effect.acquireRelease( + Effect.sync(() => { + Electron.autoUpdater.on("before-quit-for-update", listener); + }), + () => + Effect.sync(() => { + Electron.autoUpdater.removeListener("before-quit-for-update", listener); + }), + ).pipe(Effect.asVoid), on: addScopedAppListener, }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..8d76ea83a33 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, + environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, providerModelPreferences: {}, @@ -30,6 +31,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, sidebarV2Enabled: false, + sidebarV2ConfiguredByUser: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..0d48ab04ceb 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -45,6 +45,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, + onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..3aedb4e4e3e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({ }, }), SettingsWaitlist: createNativeStackScreen({ - screen: SettingsWaitlistRouteScreen, + // Keep the old deep link working after the Connect GA launch. + screen: SettingsAuthRouteScreen, linking: "waitlist", options: { - title: "Join the waitlist", + title: "Sign in", }, }), }, diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index 0caf2b41592..ecb706f9720 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -42,6 +42,12 @@ function makeDatabase() { removed.push(id); values.delete(id); }), + clearCacheKind: (environmentId, kind) => + Effect.sync(() => { + for (const key of values.keys()) { + if (key.startsWith(`${environmentId}:${kind}:`)) values.delete(key); + } + }), clearEnvironmentCache: (environmentId) => Effect.sync(() => { for (const key of values.keys()) { @@ -80,6 +86,36 @@ describe("mobile SQLite environment cache store", () => { }), ); + it.effect("removes one persisted VCS ref snapshot", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + + yield* store.removeVcsRefs(ENVIRONMENT_ID, "/repo"); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(memory.removed).toContain(cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo")); + }), + ); + + it.effect("clears every persisted VCS ref snapshot in one environment", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo-worktree", REFS); + yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); + + yield* store.clearVcsRefs(ENVIRONMENT_ID); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo-worktree")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); + }), + ); + it.effect("clears one environment without touching another", () => Effect.gen(function* () { const memory = makeDatabase(); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 04729ad21df..6573c9e1187 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -223,6 +223,16 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); }, ), + removeVcsRefs: Effect.fn("MobileEnvironmentCache.removeVcsRefs")((environmentId, cwd) => + database + .removeCache(environmentId, "vcs-refs", cwd) + .pipe(Effect.mapError(mapDatabaseError("remove-vcs-refs"))), + ), + clearVcsRefs: Effect.fn("MobileEnvironmentCache.clearVcsRefs")((environmentId) => + database + .clearCacheKind(environmentId, "vcs-refs") + .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), + ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => database .clearEnvironmentCache(environmentId) diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx deleted file mode 100644 index 1a7020fd0ed..00000000000 --- a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useWaitlist } from "@clerk/expo"; -import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native"; -import { useState } from "react"; - -import { cn } from "../../lib/cn"; -import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin"; - -export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { - const { errors, fetchStatus, waitlist } = useWaitlist(); - const [emailAddress, setEmailAddress] = useState(""); - const [requestError, setRequestError] = useState(null); - const isSubmitting = fetchStatus === "fetching"; - const fieldError = errors.fields.emailAddress?.longMessage; - - const joinWaitlist = async () => { - const normalizedEmailAddress = emailAddress.trim(); - if (!normalizedEmailAddress || isSubmitting) { - return; - } - - setRequestError(null); - try { - await joinCloudWaitlist(waitlist, normalizedEmailAddress); - } catch (error) { - console.error(error); - setRequestError( - error instanceof CloudWaitlistJoinRejectedError - ? "Could not join the waitlist. Check your email address and try again." - : "Could not join the waitlist. Check your connection and try again.", - ); - } - }; - - if (waitlist.id) { - return ( - - - You are on the waitlist - - - We will email you when your T3 Connect access is ready. - - - - ); - } - - return ( - - - Enter your email and we will let you know when access is ready. - - - - Email address - { - setEmailAddress(value); - setRequestError(null); - }} - onSubmitEditing={() => void joinWaitlist()} - placeholder="Enter your email address" - placeholderTextColorClassName="accent-placeholder" - returnKeyType="join" - textContentType="emailAddress" - value={emailAddress} - /> - {fieldError || requestError ? ( - - {fieldError ?? requestError} - - ) : null} - - - void joinWaitlist()} - className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" - > - {isSubmitting ? ( - - ) : null} - - {isSubmitting ? "Joining" : "Join the waitlist"} - - - - - - ); -} - -function SignInAction(props: { readonly onPress: () => void }) { - return ( - - Already have access? - - Sign in - - - ); -} diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts deleted file mode 100644 index 582cb40ffbf..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - CloudWaitlistJoinRejectedError, - CloudWaitlistJoinRequestError, - joinCloudWaitlist, -} from "./cloudWaitlistJoin"; - -describe("joinCloudWaitlist", () => { - it("submits the provided email address", async () => { - const join = vi.fn().mockResolvedValue({ error: null }); - - await joinCloudWaitlist({ join }, "person@example.com"); - - expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" }); - }); - - it("preserves Clerk rejection details without exposing the email address", async () => { - const cause = Object.assign(new Error("The enrollment was rejected."), { - code: "form_identifier_invalid", - }); - const join = vi.fn().mockResolvedValue({ error: cause }); - - const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError); - expect(failure).toMatchObject({ - code: "form_identifier_invalid", - cause, - }); - expect(String(failure)).not.toContain("secret@example.com"); - }); - - it("distinguishes request failures from rejected enrollments", async () => { - const cause = new Error("network unavailable"); - const join = vi.fn().mockRejectedValue(cause); - - const failure = await joinCloudWaitlist({ join }, "person@example.com").catch( - (error: unknown) => error, - ); - - expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError); - expect(failure).toMatchObject({ cause }); - expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError); - }); -}); diff --git a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts b/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts deleted file mode 100644 index 4a467a19e4b..00000000000 --- a/apps/mobile/src/features/cloud/cloudWaitlistJoin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as Schema from "effect/Schema"; - -interface CloudWaitlistJoiner { - readonly join: (input: { emailAddress: string }) => Promise<{ - readonly error: { readonly code: string } | null; - }>; -} - -export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRejectedError", - { - code: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Cloud waitlist enrollment was rejected with code "${this.code}".`; - } -} - -export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass()( - "CloudWaitlistJoinRequestError", - { - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Cloud waitlist enrollment request failed."; - } -} - -export async function joinCloudWaitlist( - waitlist: CloudWaitlistJoiner, - emailAddress: string, -): Promise { - const result = await waitlist.join({ emailAddress }).catch((cause) => { - throw new CloudWaitlistJoinRequestError({ cause }); - }); - - if (result.error) { - throw new CloudWaitlistJoinRejectedError({ - code: result.error.code, - cause: result.error, - }); - } -} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 608d43b2acb..173d093d849 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -24,13 +24,7 @@ import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironment import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; -/** - * "T3 Connect" section: every environment published to the signed-in account, - * with connect switches, availability status, refresh, and loading/error - * states. Shared between the Settings environments screen and the T3 Connect - * onboarding sheet. - */ -export function CloudEnvironmentRows(props: { +interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; readonly showcaseAvailableEnvironments?: ReadonlyArray; @@ -41,8 +35,31 @@ export function CloudEnvironmentRows(props: { * pull-to-refresh). */ readonly showHeader?: boolean; -}) { +} + +/** + * "T3 Connect" section: every environment published to the signed-in account, + * with connect switches, availability status, refresh, and loading/error + * states. Shared between the Settings environments screen and the T3 Connect + * onboarding sheet. + */ +export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { + // Showcase captures run without a Clerk publishable key, so `ClerkProvider` + // is never mounted and any `useAuth` call throws — the fixture states whether + // the rows are signed in instead of asking Clerk. + if (props.showcaseSignedIn !== undefined) { + return props.showcaseSignedIn ? : null; + } + return ; +} + +function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + if (!isSignedIn) return null; + return ; +} + +function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); const availableCloudEnvironments = @@ -67,8 +84,6 @@ export function CloudEnvironmentRows(props: { const showHeader = props.showHeader ?? true; - if (!(props.showcaseSignedIn ?? isSignedIn)) return null; - return ( {showHeader ? ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..4265107912b 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,7 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useCallback, useMemo, useRef } from "react"; import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; @@ -12,7 +10,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; @@ -59,21 +57,14 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } -/** Thread List v2 lays the list out in fixed creation order, so the - sort/group filter controls would be silently ignored — hide them and - key the "customized" icon state off the environment filter alone. */ -function useThreadListV2FilterGate() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - return ( - AsyncResult.isSuccess(preferencesResult) && preferencesResult.value.threadListV2Enabled === true - ); -} - function AndroidHomeHeader(props: HomeHeaderProps) { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); const mutedColor = useThemeColor("--color-foreground-muted"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); @@ -291,7 +282,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useThemeColor("--color-icon"); - const threadListV2Enabled = useThreadListV2FilterGate(); + // Thread List v2 lays the list out in fixed creation order, so the + // sort/group filter controls would be silently ignored — hide them and + // key the "customized" icon state off the environment filter alone. + const threadListV2Enabled = useThreadListV2Enabled(); const hasCustomListOptions = threadListV2Enabled ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null : hasCustomHomeListOptions(props); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 54e242c2166..0263e74eade 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -27,6 +27,7 @@ import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -35,12 +36,13 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { ThreadListV2PendingRow, ThreadListV2Row } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, + buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2Item, + type ThreadListV2ListItem, } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { @@ -181,9 +183,7 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -550,50 +550,100 @@ export function HomeScreen(props: HomeScreenProps) { // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); - const threadListV2Items = threadListV2Layout.items; + // Queued tasks are not thread shells, so the v2 partition never sees them; + // they are spliced in below the active block and stay visible and deletable + // while their environment is offline. Same environment scope and search + // filter as the list itself. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = useMemo( + () => + props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && + (v2SearchQuery.length === 0 || + pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ), + [props.pendingTasks, props.selectedEnvironmentId, v2ScopedProjectKeys, v2SearchQuery], + ); + const threadListV2Items = useMemo( + () => + buildThreadListV2ListItems({ + items: threadListV2Layout.items, + pendingTasks: v2PendingTasks, + }), + [threadListV2Layout.items, v2PendingTasks], + ); const renderV2Item = useCallback( - ({ item }: { readonly item: ThreadListV2Item }) => ( - - provider.instanceId === - (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), - )?.driver ?? null - } - environmentLabel={ - Object.keys(props.savedConnectionsById).length > 1 - ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) - : null - } - onSelectThread={props.onSelectThread} - onDeleteThread={handleDeleteThread} - onArchiveThread={props.onArchiveThread} - settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} - onSettleThread={handleSettleThread} - onUnsettleThread={handleUnsettleThread} - onChangeRequestState={handleChangeRequestState} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? - null - } - onSwipeableClose={handleSwipeableClose} - onSwipeableWillOpen={handleSwipeableWillOpen} - /> - ), + ({ item }: { readonly item: ThreadListV2ListItem }) => { + if (item.type === "v2-pending") { + const pendingScopeKey = scopedProjectKey( + item.pendingTask.message.environmentId, + item.pendingTask.creation.projectId, + ); + return ( + 1 + ? (props.savedConnectionsById[item.pendingTask.message.environmentId] + ?.environmentLabel ?? null) + : null + } + showPendingDivider={item.showPendingDivider} + onSelectPendingTask={props.onSelectPendingTask} + onDeletePendingTask={props.onDeletePendingTask} + /> + ); + } + const thread = item.item.thread; + return ( + + provider.instanceId === + (thread.session?.providerInstanceId ?? thread.modelSelection.instanceId), + )?.driver ?? null + } + environmentLabel={ + Object.keys(props.savedConnectionsById).length > 1 + ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ); + }, [ handleChangeRequestState, handleDeleteThread, @@ -604,6 +654,8 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, projectCwdByKey, props.onArchiveThread, + props.onDeletePendingTask, + props.onSelectPendingTask, props.onSelectThread, props.savedConnectionsById, serverConfigs, @@ -611,10 +663,7 @@ export function HomeScreen(props: HomeScreenProps) { v2ProjectTitleByProjectKey, ], ); - const v2KeyExtractor = useCallback( - (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, - [], - ); + const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), @@ -789,41 +838,9 @@ export function HomeScreen(props: HomeScreenProps) { ); - // v2 renders queued offline tasks above the thread cards — they are not - // thread shells, so the v2 item builder never sees them, but they must - // stay visible and deletable while their environment is offline. They - // respect the same environment scope and search filter as the list. - const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); - const v2PendingTasks = props.pendingTasks.filter( - (pendingTask) => - (props.selectedEnvironmentId === null || - pendingTask.message.environmentId === props.selectedEnvironmentId) && - (v2ScopedProjectKeys === null || - v2ScopedProjectKeys.has( - scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), - )) && - (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), - ); // Project scoping lives in the header filter menu (no inline chip row on // mobile — the menu is the one filter surface). - const v2ListHeader = ( - <> - {listHeader} - {v2PendingTasks.map((pendingTask, index) => ( - - ))} - - ); + const v2ListHeader = listHeader; const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -847,37 +864,33 @@ export function HomeScreen(props: HomeScreenProps) { // is empty. Search outranks the scope — "No results" names the actionable // fact when a query is active. Snoozed threads outrank the rest: "No // threads yet" over an inbox that is merely all-snoozed reads as data - // loss. Pending tasks render in the header, so the list showing them - // isn't empty in the user's eyes. + // loss. const v2SnoozedCount = threadListV2Layout.snoozedCount; - const v2ListEmpty = - v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - v2SnoozedCount > 0 ? ( - // The snoozed threads already passed this search filter: "No - // results" would claim nothing matched when matches are merely - // parked. - - ) : ( - - ) - ) : v2SnoozedCount > 0 ? ( + const v2ListEmpty = hasSearchQuery ? ( + v2SnoozedCount > 0 ? ( + // The snoozed threads already passed this search filter: "No + // results" would claim nothing matched when matches are merely + // parked. - ) : v2ScopedProjectGroup !== null ? ( - ) : ( - listEmpty - ); + + ) + ) : v2SnoozedCount > 0 ? ( + + ) : v2ScopedProjectGroup !== null ? ( + + ) : ( + listEmpty + ); if (threadListV2Enabled) { return ( @@ -889,6 +902,8 @@ export function HomeScreen(props: HomeScreenProps) { keyExtractor={v2KeyExtractor} extraData={{ projectByKey, + projectCwdByKey, + projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, }} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4f9d994a6a7..eef40382b24 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -10,16 +10,16 @@ import { sortAddProjectProviderSources, type AddProjectRemoteSource, } from "@t3tools/client-runtime/operations/projects"; +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, - getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, - hasTrailingPathSeparator, inferProjectTitleFromPath, - isFilesystemBrowseQuery, } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; @@ -45,7 +45,10 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; -import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { + useRemoteEnvironmentRuntime, + useSavedRemoteConnections, +} from "../../state/use-remote-environment-registry"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -61,11 +64,6 @@ const environmentOptionOrder = Order.mapInput( (environment: EnvironmentOption) => ({ label: environment.label }), ); -const browseEntryOrder = Order.mapInput( - Order.String, - (entry: { readonly name: string }) => entry.name, -); - function platformFromOs(os: string | null | undefined): string { if (os === "windows") return "Win32"; if (os === "darwin") return "MacIntel"; @@ -228,6 +226,63 @@ function ProjectPathInput(props: { ); } +function useBrowsePathInput(environment: EnvironmentOption | null) { + const [pathInput, commitPathInput] = useState(() => + getAddProjectInitialQuery(environment?.baseDirectory), + ); + const environmentRuntime = useRemoteEnvironmentRuntime(environment?.environmentId ?? null); + const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { + reportFailure: false, + reportDefect: false, + }); + const [browseNavigation] = useState(createBrowseNavigationCoordinator); + const [isBrowseNavigating, setIsBrowseNavigating] = useState(false); + const setPathInput = useCallback( + (path: string) => { + browseNavigation.invalidate(); + setIsBrowseNavigating(false); + commitPathInput(path); + }, + [browseNavigation], + ); + const navigateToBrowsePath = useCallback( + async (path: string) => { + setIsBrowseNavigating(true); + const committed = await browseNavigation.run( + async () => { + if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) { + await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: path }, + }); + } + }, + () => commitPathInput(path), + ); + if (committed) { + setIsBrowseNavigating(false); + } + return committed; + }, + [browseNavigation, environment, environmentRuntime?.connectionState, loadBrowsePath], + ); + + useEffect(() => { + if (environment) { + setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); + } + }, [environment, setPathInput]); + + useEffect( + () => () => { + browseNavigation.invalidate(); + }, + [browseNavigation], + ); + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; +} + function useEnvironmentOptions(): ReadonlyArray { const serverConfigByEnvironmentId = useServerConfigs(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -592,22 +647,16 @@ function FolderBrowser(props: { readonly environment: EnvironmentOption; readonly pathInput: string; readonly setPathInput: (path: string) => void; + readonly navigateToBrowsePath: (path: string) => Promise; }) { const accentColor = useThemeColor("--color-icon-muted"); - const browseDirectoryPath = useMemo( - () => - isFilesystemBrowseQuery(props.pathInput, props.environment.platform) - ? getBrowseDirectoryPath(props.pathInput) - : "", + const browsePath = useMemo( + () => getFilesystemBrowsePath(props.pathInput, props.environment.platform), [props.environment.platform, props.pathInput], ); - const browseFilterQuery = - browseDirectoryPath.length > 0 && !hasTrailingPathSeparator(props.pathInput) - ? getBrowseLeafPathSegment(props.pathInput).toLowerCase() - : ""; const browseInput = useMemo( - () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), - [browseDirectoryPath], + () => (browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null), + [browsePath.directoryPath], ); const browseState = useEnvironmentQuery( browseInput === null @@ -617,20 +666,10 @@ function FolderBrowser(props: { input: browseInput, }), ); - const visibleBrowseEntries = useMemo( - () => - Arr.sort( - Arr.filter( - browseState.data?.entries ?? [], - (entry) => - !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(browseFilterQuery), - ), - browseEntryOrder, - ), - [browseFilterQuery, browseState.data?.entries], + const { visibleEntries: visibleBrowseEntries } = useMemo( + () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery), + [browsePath.filterQuery, browseState.data?.entries], ); - const parentBrowsePath = getBrowseParentPath(browseDirectoryPath); - const canBrowseUpPath = canNavigateUp(browseDirectoryPath); return ( <> @@ -642,7 +681,7 @@ function FolderBrowser(props: { ) : null} - {canBrowseUpPath ? ( + {browsePath.canBrowseUp ? ( { - if (parentBrowsePath) props.setPathInput(parentBrowsePath); + if (browsePath.parentPath) { + void props.navigateToBrowsePath(browsePath.parentPath); + } }} /> ) : null} @@ -665,15 +706,15 @@ function FolderBrowser(props: { key={entry.fullPath} title={entry.name} icon={} - isFirst={index === 0 && !canBrowseUpPath} + isFirst={index === 0 && !browsePath.canBrowseUp} right={null} - onPress={() => - props.setPathInput( - browseDirectoryPath.length > 0 - ? appendBrowsePathSegment(browseDirectoryPath, entry.name) - : ensureBrowseDirectoryPath(entry.fullPath), - ) - } + onPress={() => { + const nextPath = + browsePath.directoryPath.length > 0 + ? appendBrowsePathSegment(browsePath.directoryPath, entry.name) + : ensureBrowseDirectoryPath(entry.fullPath); + void props.navigateToBrowsePath(nextPath); + }} /> ))} @@ -684,19 +725,13 @@ function FolderBrowser(props: { export function AddProjectLocalFolderScreen(props: { readonly environmentId?: string | string[] }) { const environment = useEnvironmentFromParam(props.environmentId); const createProject = useCreateProject(environment); - const [pathInput, setPathInput] = useState(() => - getAddProjectInitialQuery(environment?.baseDirectory), - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!environment) return; - setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); - }, [environment]); - const submitPath = useCallback(async () => { - if (!environment || isSubmitting) return; + if (!environment || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -714,7 +749,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st setError(errorMessage(Cause.squash(result.cause))); } setIsSubmitting(false); - }, [createProject, environment, isSubmitting, pathInput]); + }, [createProject, environment, isBrowseNavigating, isSubmitting, pathInput]); return ( @@ -728,12 +763,13 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st /> void submitPath()} loading={isSubmitting} /> @@ -757,19 +793,13 @@ export function AddProjectDestinationScreen(props: { const createProject = useCreateProject(environment); const remoteUrl = stringParam(props.remoteUrl); const repositoryTitle = stringParam(props.repositoryTitle); - const [pathInput, setPathInput] = useState(() => - getAddProjectInitialQuery(environment?.baseDirectory), - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!environment) return; - setPathInput(getAddProjectInitialQuery(environment.baseDirectory)); - }, [environment]); - const submitPath = useCallback(async () => { - if (!environment || !remoteUrl || isSubmitting) return; + if (!environment || !remoteUrl || isBrowseNavigating || isSubmitting) return; setError(null); const resolved = resolveAddProjectPath({ rawPath: pathInput, @@ -798,7 +828,15 @@ export function AddProjectDestinationScreen(props: { } } setIsSubmitting(false); - }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); + }, [ + cloneRepository, + createProject, + environment, + isBrowseNavigating, + isSubmitting, + pathInput, + remoteUrl, + ]); return ( @@ -820,12 +858,13 @@ export function AddProjectDestinationScreen(props: { /> void submitPath()} loading={isSubmitting} /> diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index ab167d50550..f354bcd29ac 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -165,7 +166,7 @@ function ConfiguredSettingsRouteScreen() { const environmentCount = connections.length; const accountLabel = useMemo(() => { if (!isLoaded) return "Checking"; - if (!isSignedIn) return "Request access"; + if (!isSignedIn) return "Sign in"; return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); @@ -272,13 +273,13 @@ function ConfiguredSettingsRouteScreen() { const promptSignIn = useCallback(() => { Alert.alert( - "Request T3 Connect access", - "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.", + "Sign in to T3 Connect", + "Live Activity updates require T3 Connect so relay can deliver updates to this device.", [ { text: "Cancel", style: "cancel" }, { text: "Continue", - onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }), + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), }, ], ); @@ -440,7 +441,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; if (!isSignedIn) { - navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }); + expandClerkSheet(); + navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); return; } expandClerkSheet(); @@ -555,11 +557,8 @@ function GeneralSettingsSection() { * the counterpart of web's Settings → Beta backed by mobile preferences. */ function BetaSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.threadListV2Enabled === true - : false; + const threadListV2Enabled = useThreadListV2Enabled(); return ( diff --git a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx deleted file mode 100644 index f5182307ebb..00000000000 --- a/apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAuth } from "@clerk/expo"; -import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; -import { useCallback } from "react"; -import { ScrollView } from "react-native"; - -import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; - -export function SettingsWaitlistRouteScreen() { - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [navigation]), - ); - - return hasCloudPublicConfig() ? : null; -} - -function ConfiguredSettingsWaitlistRouteScreen() { - const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { expand } = useClerkSettingsSheetDetent(); - const navigation = useNavigation(); - - useFocusEffect( - useCallback(() => { - if (isLoaded && isSignedIn) { - navigation.dispatch(StackActions.replace("Settings")); - } - }, [isLoaded, isSignedIn, navigation]), - ); - - return ( - <> - - { - expand(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }} - /> - - - ); -} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 799d969da3e..3d413f9c487 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -6,7 +6,6 @@ import type { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; @@ -25,9 +24,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; -import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; +import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; @@ -63,26 +62,21 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2Row } from "./thread-list-v2-items"; +import { ThreadListV2PendingRow, ThreadListV2Row } from "./thread-list-v2-items"; import { buildThreadListV2Items, + buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2Item, + type ThreadListV2ListItem, } from "./threadListV2"; /** The sidebar list serves both lists: v1 grouped items or, when the Thread - List v2 beta is on, queued offline tasks, flat v2 rows, and a settled + List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled "Show more" pager. */ type SidebarListItem = | HomeListItem - | { - readonly type: "v2-pending-task"; - readonly key: string; - readonly pendingTask: PendingNewTask; - readonly isLast: boolean; - } - | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } + | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; /** @@ -196,10 +190,7 @@ function ThreadNavigationSidebarPane( const sidebarScrollGesture = useMemo(() => Gesture.Native(), []); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -477,11 +468,11 @@ function ThreadNavigationSidebarPane( }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; - // Queued offline tasks render above the thread rows (mirrors the - // compact Home v2 list): they are not thread shells, so the v2 item - // builder never sees them, but they must stay visible and deletable - // while their environment is offline. Same environment scope and - // search filter as the list. + // Queued offline tasks are not thread shells, so the v2 item builder + // never sees them; the shared splice puts them below the active block + // (mirrors the compact Home v2 list) where they stay visible and + // deletable while their environment is offline. Same environment scope + // and search filter as the list. const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); const v2PendingTasks = pendingTasks.filter( (pendingTask) => @@ -494,19 +485,10 @@ function ThreadNavigationSidebarPane( (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); - const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ - type: "v2-pending-task" as const, - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - isLast: index === v2PendingTasks.length - 1, - })); - for (const item of threadListV2Layout.items) { - items.push({ - type: "v2-thread" as const, - key: scopedThreadKey(item.thread.environmentId, item.thread.id), - item, - }); - } + const items: SidebarListItem[] = buildThreadListV2ListItems({ + items: threadListV2Layout.items, + pendingTasks: v2PendingTasks, + }); if (threadListV2Layout.hiddenSettledCount > 0) { items.push({ type: "v2-show-more", @@ -694,13 +676,26 @@ function ThreadNavigationSidebarPane( onScroll: handleScroll, onScrollBeginDrag: handleScrollBeginDrag, }); + // Project shells load after the first rows draw, so the maps they feed have + // to bust the recycler's memoization — otherwise a row keeps the blank + // favicon and fallback title it was first rendered with. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", + projectByKey, + projectCwdByKey, + projectTitleByProjectKey, savedConnectionsById, serverConfigs, }), - [props.selectedThreadKey, savedConnectionsById, serverConfigs], + [ + props.selectedThreadKey, + projectByKey, + projectCwdByKey, + projectTitleByProjectKey, + savedConnectionsById, + serverConfigs, + ], ); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { @@ -715,16 +710,19 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } - if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { - return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; + if (previous.type === "v2-pending" && item.type === "v2-pending") { + return ( + previous.pendingTask === item.pendingTask && + previous.showPendingDivider === item.showPendingDivider + ); } if ( previous.type === "v2-thread" || previous.type === "v2-show-more" || - previous.type === "v2-pending-task" || + previous.type === "v2-pending" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending-task" + item.type === "v2-pending" ) { return false; } @@ -752,20 +750,29 @@ function ThreadNavigationSidebarPane( const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { - case "v2-pending-task": + case "v2-pending": { + const pendingScopeKey = scopedProjectKey( + item.pendingTask.message.environmentId, + item.pendingTask.creation.projectId, + ); return ( - 1 + ? (savedConnectionsById[item.pendingTask.message.environmentId] + ?.environmentLabel ?? null) + : null } - isLast={item.isLast} + pane="sidebar" + showPendingDivider={item.showPendingDivider} onSelectPendingTask={openPendingTask} onDeletePendingTask={confirmDeletePendingTask} /> ); + } case "v2-thread": { const thread = item.item.thread; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); 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 69729cb6469..2ab7e6cf9f4 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -14,6 +14,7 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; @@ -68,7 +69,9 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ /** Rounded-row radius shared with the v1 sidebar rows. */ const SIDEBAR_V2_ROW_RADIUS = 12; -export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider(props: { +/** Section label + rule: the only structure in an otherwise flat list. */ +export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivider(props: { + readonly label: string; readonly pane?: "screen" | "sidebar"; }) { const borderColor = useThemeColor("--color-border"); @@ -79,12 +82,127 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid props.pane === "sidebar" ? "px-3" : "px-5", )} > - Settled + {props.label} ); }); +const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ + { id: "delete", title: "Delete", 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. + */ +export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props: { + readonly pendingTask: PendingNewTask; + readonly project: EnvironmentProject | null; + readonly projectTitle?: string; + readonly environmentLabel: string | null; + readonly pane?: "screen" | "sidebar"; + /** Draws the "Pending" divider above the first queued row. */ + readonly showPendingDivider: boolean; + readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; + readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; +}) { + const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; + const drawerColor = useThemeColor("--color-drawer"); + const pressedBackgroundColor = useThemeColor("--color-subtle"); + const sidebarPane = props.pane === "sidebar"; + const projectTitle = + props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? ""; + const branch = pendingTask.creation.branch; + + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); + }, + [onDeletePendingTask, pendingTask], + ); + + const rowContent = ( + <> + + {props.project ? ( + + ) : null} + + {projectTitle} + + Queued + + {/* 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 + second line is usually a stray word or emoji rather than meaning. */} + + {pendingTask.title} + + {branch || props.environmentLabel ? ( + + {branch ? ( + + {branch} + + ) : null} + {branch && props.environmentLabel ? " · " : null} + {props.environmentLabel ? ( + {props.environmentLabel} + ) : null} + + ) : null} + + ); + + return ( + <> + {props.showPendingDivider ? ( + + ) : null} + + onSelectPendingTask(pendingTask)} + style={ + sidebarPane + ? ({ pressed }) => ({ + backgroundColor: pressed ? pressedBackgroundColor : drawerColor, + borderRadius: SIDEBAR_V2_ROW_RADIUS, + paddingHorizontal: 12, + paddingVertical: 10, + }) + : ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + } + > + {sidebarPane ? ( + rowContent + ) : ( + + {rowContent} + + + )} + + + + ); +}); + export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -423,7 +541,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { return ( <> - {props.showSettledDivider ? : null} + {props.showSettledDivider ? ( + + ) : null} { + it("defaults on when the device has never chosen", () => { + expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: true })).toBe( + true, + ); + }); + + it("honors an explicit device opt-out", () => { + expect(resolveThreadListV2Enabled({ preference: false, preferencesLoaded: true })).toBe(false); + expect(resolveThreadListV2Enabled({ preference: true, preferencesLoaded: true })).toBe(true); + }); + + it("holds the default while preferences are still loading so the list does not remount", () => { + expect(resolveThreadListV2Enabled({ preference: undefined, preferencesLoaded: false })).toBe( + true, + ); + }); +}); + describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ @@ -320,3 +350,88 @@ describe("buildThreadListV2Items settled paging", () => { ]); }); }); + +function makePendingTask(id: string): PendingNewTask { + return { + message: { + environmentId, + threadId: ThreadId.make(`thread-${id}`), + messageId: MessageId.make(id), + commandId: CommandId.make(`command-${id}`), + 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, + }, + title: id, + }; +} + +describe("buildThreadListV2ListItems", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + it("splices queued tasks between the active block and the settled tail", () => { + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("queued-1"), makePendingTask("queued-2")], + }); + + expect( + items.map((item) => + item.type === "v2-pending" ? item.pendingTask.title : item.item.thread.id, + ), + ).toEqual(["active", "queued-1", "queued-2", "settled"]); + // Only the leading queued row labels the section, exactly like Settled. + expect( + items.filter((item) => item.type === "v2-pending" && item.showPendingDivider), + ).toHaveLength(1); + }); + + it("ends the list with queued tasks when nothing has settled yet", () => { + const activeOnly = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "active" })], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: activeOnly.items, + pendingTasks: [makePendingTask("queued-1")], + }); + + expect(items.map((item) => item.type)).toEqual(["v2-thread", "v2-pending"]); + }); + + it("leaves the thread order untouched when nothing is queued", () => { + const items = buildThreadListV2ListItems({ items: layout.items, pendingTasks: [] }); + + expect(items.map((item) => item.key)).toEqual([ + `v2-thread:${environmentId}:active`, + `v2-thread:${environmentId}:settled`, + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index efa68153a3f..ab955d16d4d 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -2,6 +2,8 @@ import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/stat import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; + /** * Thread List v2 model, ported from the web sidebar v2 * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). @@ -18,6 +20,26 @@ export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | " export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; +/** + * Thread List v2 is on by default on every app variant; the Settings → Beta + * toggle is an opt-out. Preferences persist as sparse patches, so `undefined` + * genuinely means "never chosen". + * + * `preferencesLoaded` guards the startup window: preferences load + * asynchronously, and rendering one list before the stored choice arrives would + * remount the whole thing a tick later. While loading, hold the default — that + * is where every device without an explicit opt-out lands anyway. + */ +export function resolveThreadListV2Enabled(input: { + readonly preference: boolean | undefined; + readonly preferencesLoaded: boolean; +}): boolean { + if (!input.preferencesLoaded) { + return true; + } + return input.preference ?? true; +} + export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { @@ -93,6 +115,60 @@ export interface ThreadListV2Layout { readonly nextSnoozeWakeAt: string | null; } +export interface ThreadListV2ThreadListItem { + readonly type: "v2-thread"; + readonly key: string; + readonly item: ThreadListV2Item; +} + +export interface ThreadListV2PendingListItem { + readonly type: "v2-pending"; + readonly key: string; + readonly pendingTask: PendingNewTask; + /** First queued row after the active block draws the PENDING divider. */ + readonly showPendingDivider: boolean; +} + +export type ThreadListV2ListItem = ThreadListV2ThreadListItem | ThreadListV2PendingListItem; + +/** + * Splices queued tasks between the active block and the settled tail, so the + * list reads active → pending → settled. Queued work sits below the live + * threads because nothing can happen to it until its environment returns: + * it is waiting, not asking. Shared by the compact Home list and the iPad + * sidebar so both order and label the sections identically. + */ +export function buildThreadListV2ListItems(input: { + readonly items: ReadonlyArray; + readonly pendingTasks: ReadonlyArray; +}): ThreadListV2ListItem[] { + const threadItems = input.items.map( + (item): ThreadListV2ListItem => ({ + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + }), + ); + if (input.pendingTasks.length === 0) return threadItems; + + const pendingItems = input.pendingTasks.map( + (pendingTask, index): ThreadListV2ListItem => ({ + type: "v2-pending", + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + showPendingDivider: index === 0, + }), + ); + // The settled tail begins at the row that draws the SETTLED divider; with + // no settled rows the queued block simply ends the list. + const settledStart = threadItems.findIndex( + (entry) => entry.type === "v2-thread" && entry.item.showSettledDivider, + ); + return settledStart === -1 + ? [...threadItems, ...pendingItems] + : [...threadItems.slice(0, settledStart), ...pendingItems, ...threadItems.slice(settledStart)]; +} + /** * Partitions visible threads into the active card block (creation order) and * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts new file mode 100644 index 00000000000..266bda944ae --- /dev/null +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "../../state/preferences"; +import { resolveThreadListV2Enabled } from "./threadListV2"; + +/** + * Resolved Thread List v2 state: the device-local preference if the user has + * set one, otherwise the default (on). Every consumer must read through this + * rather than the raw preference, which is undefined until explicitly chosen. + */ +export function useThreadListV2Enabled(): boolean { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const loaded = AsyncResult.isSuccess(preferencesResult); + return resolveThreadListV2Enabled({ + preference: loaded ? preferencesResult.value.threadListV2Enabled : undefined, + preferencesLoaded: loaded, + }); +} diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 33eb50f640c..3d50a2dbc55 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -46,6 +46,7 @@ const MobileDatabaseOperation = Schema.Literals([ "load-cache", "save-cache", "remove-cache", + "clear-cache-kind", "clear-environment-cache", "clear-all-caches", "inspect-caches", @@ -203,6 +204,10 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect; + readonly clearCacheKind: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + ) => Effect.Effect; readonly clearEnvironmentCache: ( environmentId: EnvironmentId, ) => Effect.Effect; @@ -322,6 +327,17 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("remove-cache"), }).pipe(Effect.asVoid), ), + clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")((environmentId, kind) => + Effect.tryPromise({ + try: () => + database.runAsync( + "DELETE FROM client_cache WHERE environment_id = ? AND kind = ?", + environmentId, + kind, + ), + catch: databaseError("clear-cache-kind"), + }).pipe(Effect.asVoid), + ), clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) => Effect.tryPromise({ try: () => @@ -392,6 +408,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] loadCache: () => fail, saveCache: () => fail, removeCache: () => fail, + clearCacheKind: () => fail, clearEnvironmentCache: () => fail, clearAllCaches: fail, inspectCaches: fail, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..6b1018e2a0a 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -25,8 +25,9 @@ export interface Preferences { readonly projectGroupingEnabled?: boolean; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no - * client-settings sync, so the flat v2 thread list is opted into per - * device. + * client-settings sync, so the flat v2 thread list is opted out of per + * device. Undefined means the user has never chosen, which resolves to on — + * see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; } diff --git a/apps/server/package.json b/apps/server/package.json index 2a17f48bdef..46049ab9f70 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.29", + "version": "0.0.30", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 95e5affc210..da002df5e6c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1794,7 +1794,7 @@ export const make = Effect.gen(function* () { resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { - const result = yield* gitCore.listRefs({ cwd }); + const result = yield* gitCore.listRefs({ cwd, refresh: true }); const localBranch = result.refs.find( (branch) => !branch.isRemote && branch.name === localPullRequestBranch, ); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index a1d4230e75c..4af2ecb6457 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "@effect/vitest"; +import { describe } from "vite-plus/test"; import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 77eef955d55..5a380be8fe2 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -14,6 +14,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { cast } from "effect/Function"; import { + Headers, HttpBody, HttpClient, HttpClientResponse, @@ -29,6 +30,7 @@ import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, @@ -42,6 +44,73 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const GZIP_MIN_BYTES = 1024; + +function acceptsGzip(value: string | undefined): boolean { + if (!value) return false; + + const accepted = new Map( + value.split(",").map((entry) => { + const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";"); + const quality = parameters + .map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]) + .find((parameter) => parameter !== undefined); + return [coding, quality === undefined ? 1 : Number(quality)] as const; + }), + ); + return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0; +} + +function varyByAcceptEncoding(value: string | undefined): string { + if (!value) return "Accept-Encoding"; + const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase())); + return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`; +} + +const compressHttpResponse = Effect.fnUntraced(function* ( + response: HttpServerResponse.HttpServerResponse, + acceptEncoding: string | undefined, +) { + const body = response.body; + if ( + body._tag !== "Uint8Array" || + body.contentLength < GZIP_MIN_BYTES || + !body.contentType.startsWith("application/json") || + response.headers["content-encoding"] + ) { + return response; + } + + const variedResponse = HttpServerResponse.setHeader( + response, + "vary", + varyByAcceptEncoding(response.headers.vary), + ); + if (!acceptsGzip(acceptEncoding)) return variedResponse; + + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const headers = Headers.set( + Headers.remove(variedResponse.headers, "content-length"), + "content-encoding", + "gzip", + ); + return compression.gzip(body.body, { + status: response.status, + statusText: response.statusText, + headers, + cookies: response.cookies, + contentType: body.contentType, + }); +}); + +export const httpCompressionLayer = HttpRouter.middleware( + (httpEffect) => + Effect.flatMap( + Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]), + ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"]), + ), + { global: true }, +); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/httpCompression/HttpResponseCompression.test.ts b/apps/server/src/httpCompression/HttpResponseCompression.test.ts new file mode 100644 index 00000000000..b608720d8dd --- /dev/null +++ b/apps/server/src/httpCompression/HttpResponseCompression.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeStream from "node:stream"; +import * as Effect from "effect/Effect"; + +import * as HttpResponseCompression from "./HttpResponseCompression.ts"; + +it.effect("uses a native Node stream", () => + Effect.gen(function* () { + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const response = compression.gzip(new Uint8Array(), {}); + + expect(response.body._tag).toBe("Raw"); + if (response.body._tag === "Raw") { + expect(response.body.body).toBeInstanceOf(NodeStream.Readable); + } + }).pipe(Effect.provide(HttpResponseCompression.layerNode)), +); + +it.effect("uses a native Web stream for Bun", () => + Effect.gen(function* () { + const compression = yield* HttpResponseCompression.HttpResponseCompression; + const response = compression.gzip(new Uint8Array(), {}); + + expect(response.body._tag).toBe("Raw"); + if (response.body._tag === "Raw") { + expect(response.body.body).toBeInstanceOf(ReadableStream); + } + }).pipe(Effect.provide(HttpResponseCompression.layerBun)), +); diff --git a/apps/server/src/httpCompression/HttpResponseCompression.ts b/apps/server/src/httpCompression/HttpResponseCompression.ts new file mode 100644 index 00000000000..f00c4e1a123 --- /dev/null +++ b/apps/server/src/httpCompression/HttpResponseCompression.ts @@ -0,0 +1,36 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +export class HttpResponseCompression extends Context.Service< + HttpResponseCompression, + { + readonly gzip: ( + body: Uint8Array, + options: HttpServerResponse.Options, + ) => HttpServerResponse.HttpServerResponse; + } +>()("t3/httpCompression/HttpResponseCompression") {} + +export const layerNode = Layer.effect( + HttpResponseCompression, + Effect.gen(function* () { + const NodeZlib = yield* Effect.promise(() => import("node:zlib")); + return { + gzip: (body, options) => { + const stream = NodeZlib.createGzip(); + stream.end(body); + return HttpServerResponse.raw(stream, options); + }, + }; + }), +); + +export const layerBun = Layer.succeed(HttpResponseCompression, { + gzip: (body, options) => + HttpServerResponse.raw( + new Response(body).body!.pipeThrough(new CompressionStream("gzip")), + options, + ), +}); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 5fd0cc8984b..67896961b38 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -201,6 +201,52 @@ export function projectActivityPayload( }; } +/** + * Matches the validity rule in the web client's + * `deriveLatestContextWindowSnapshot`: rows without a finite, non-negative + * `usedTokens` are skipped during its backward walk, so they must not shadow + * an earlier resolvable row here. + */ +function isResolvableContextWindowActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "context-window.updated") { + return false; + } + const payload = asRecord(activity.payload); + const usedTokens = payload?.usedTokens; + return typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0; +} + +/** + * Drops all but the last resolvable context-window activity per turn from a + * snapshot. Clients only ever read the latest usage value (walking the array + * backwards), so shipping the full history — often thousands of rows on long + * threads — buys nothing. Retention is per turn rather than per thread because + * a live `thread.reverted` makes the client discard whole turns; keeping each + * turn's latest row means the meter can still resolve a value from the turns + * that survive. Malformed rows pass through untouched rather than shadowing a + * valid earlier row. Live `thread.activity-appended` events are untouched: + * newer updates still stream through and supersede the retained rows on the + * client. + */ +function dropStaleContextWindowActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const latestIndexByTurn = new Map(); + for (let index = 0; index < activities.length; index += 1) { + if (isResolvableContextWindowActivity(activities[index]!)) { + latestIndexByTurn.set(activities[index]!.turnId, index); + } + } + if (latestIndexByTurn.size === 0) { + return activities; + } + return activities.filter( + (activity, index) => + !isResolvableContextWindowActivity(activity) || + latestIndexByTurn.get(activity.turnId) === index, + ); +} + export function projectThreadDetailSnapshot( snapshot: OrchestrationThreadDetailSnapshot, ): OrchestrationThreadDetailSnapshot { @@ -208,7 +254,9 @@ export function projectThreadDetailSnapshot( ...snapshot, thread: { ...snapshot.thread, - activities: snapshot.thread.activities.map(projectActivityPayload), + activities: dropStaleContextWindowActivities(snapshot.thread.activities).map( + projectActivityPayload, + ), }, }; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a3b2e3715fd..cb68c0bba8a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -72,6 +72,7 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -815,6 +816,7 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(HttpResponseCompression.layerNode), Layer.provide(layerConfig), ); @@ -1287,6 +1289,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("compresses large JSON responses through the composed routes", () => + Effect.gen(function* () { + const descriptor = { + ...testEnvironmentDescriptor, + label: "Test environment".repeat(100), + }; + yield* buildAppUnderTest({ + layers: { + serverEnvironment: { + getDescriptor: Effect.succeed(descriptor), + }, + }, + }); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* fetchEffect(url, { + headers: { + "accept-encoding": "gzip", + }, + }); + const body = yield* responseJsonEffect(response); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-encoding"], "gzip"); + assert.equal(response.headers.vary, "Accept-Encoding"); + assert.deepEqual(body, descriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on public environment descriptor responses", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3081,6 +3112,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("negotiates permessage-deflate with clients that offer it", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { cookie, url } = parseSessionCookieFromWsUrl(yield* getWsServerUrl("/ws")); + const openSocket = (perMessageDeflate: boolean) => + Effect.acquireRelease( + Effect.callback((resume) => { + const socket = new NodeSocket.NodeWS.WebSocket(url, { + perMessageDeflate, + ...(cookie ? { headers: { cookie } } : {}), + }); + socket.on("open", () => resume(Effect.succeed(socket))); + socket.on("error", (error) => resume(Effect.fail(error))); + }), + (socket) => Effect.sync(() => socket.close()), + ); + + const compressed = yield* openSocket(true); + // The ws client records the negotiated extension only when the server's + // 101 response accepted the offer. + assert.include(compressed.extensions, "permessage-deflate"); + + const plain = yield* openSocket(false); + assert.notInclude(plain.extensions, "permessage-deflate"); + }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -5633,15 +5692,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); assert.equal(fullDiffResult.diff, "full-diff"); - - const replayResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.replayEvents]({ - fromSequenceExclusive: 0, - }), - ), - ); - assert.deepEqual(replayResult, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -6304,73 +6354,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("enriches replayed project events with repository identity metadata", () => - Effect.gen(function* () { - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:T3Tools/t3code.git", - }, - displayName: "T3Tools/t3code", - provider: "github", - owner: "T3Tools", - name: "t3code", - }; - - yield* buildAppUnderTest({ - layers: { - orchestrationEngine: { - readEvents: (_fromSequenceExclusive) => - Stream.make({ - sequence: 1, - eventId: EventId.make("event-1"), - aggregateKind: "project", - aggregateId: defaultProjectId, - occurredAt: "2026-04-05T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type: "project.created", - payload: { - projectId: defaultProjectId, - title: "Default Project", - workspaceRoot: "/tmp/default-project", - defaultModelSelection, - scripts: [], - createdAt: "2026-04-05T00:00:00.000Z", - updatedAt: "2026-04-05T00:00:00.000Z", - }, - } satisfies Extract), - }, - repositoryIdentityResolver: { - resolve: () => Effect.succeed(repositoryIdentity), - }, - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws"); - const replayResult = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[ORCHESTRATION_WS_METHODS.replayEvents]({ - fromSequenceExclusive: 0, - }), - ), - ); - - const replayedEvent = replayResult[0]; - assert.equal(replayedEvent?.type, "project.created"); - assert.deepEqual( - replayedEvent && replayedEvent.type === "project.created" - ? replayedEvent.payload.repositoryIdentity - : null, - repositoryIdentity, - ); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("stops the provider session and closes thread terminals after archive", () => Effect.gen(function* () { const threadId = ThreadId.make("thread-archive"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0412d910b8c..0b7c8ccd074 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,12 +5,14 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as ServerConfig from "./config.ts"; +import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { otlpTracesProxyRouteLayer, assetRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, + httpCompressionLayer, } from "./http.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; @@ -150,6 +152,9 @@ const HttpServerLive = Layer.unwrap( }), ); +const HttpResponseCompressionLive = + typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode; + const PlatformServicesLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -372,6 +377,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(browserApiCorsLayer), + Layer.provide(httpCompressionLayer), ); export const makeServerLayer = Layer.unwrap( @@ -514,6 +520,7 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), Layer.provideMerge(serverRelayBrokerTracingLayer), + Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..941d92cdbff 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,18 +1,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, describe } from "@effect/vitest"; +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"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -38,6 +42,21 @@ const makeNonRepositoryHandle = () => getOutputFd: () => Stream.empty, }); +const makeSuccessfulHandle = (stdout: string) => + 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 makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -129,11 +148,434 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); }); +it.effect("coalesces concurrent ref pages into one repository snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs = yield* Ref.make>>([]); + const firstWorktreeScanStarted = yield* Deferred.make(); + const remoteNamesScanCompleted = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const countingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + const shouldDelay = + isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); + if (shouldDelay) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Effect.sleep("8 seconds"); + } + const handle = yield* delegate.spawn(command); + const isRemoteNamesScan = + command.args.length === 3 && + command.args[0] === "--git-dir" && + command.args[2] === "remote"; + return isRemoteNamesScan + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), + ); + const cwd = yield* makeTmpDir(); + const runGit = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.coalescedListRefs", + cwd, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(["config", "user.email", "test@test.com"]); + yield* runGit(["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(["add", "."]); + yield* runGit(["commit", "-m", "initial commit"]); + yield* Ref.set(spawnedArgs, []); + + const initialRequest = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(remoteNamesScanCompleted); + yield* TestClock.adjust("6 seconds"); + const laterRequests = yield* Effect.all( + Array.from({ length: 30 }, (_, index) => + driver.listRefs({ + cwd, + refresh: true, + query: `missing-${index}`, + limit: 100, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(initialRequest); + yield* Fiber.join(laterRequests); + yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); + + const firstSnapshotCommands = yield* Ref.get(spawnedArgs); + const snapshotRefScans = firstSnapshotCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ); + const worktreeScans = firstSnapshotCommands.filter( + (args) => args.includes("worktree") && args.includes("--porcelain"), + ); + assert.equal(snapshotRefScans.length, 1); + assert.equal(worktreeScans.length, 1); + + yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); + const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); + assert.equal( + refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), + true, + ); + const allCommands = yield* Ref.get(spawnedArgs); + assert.equal( + allCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ).length, + 2, + ); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const firstWorktreeScanStarted = yield* Deferred.make(); + const firstRefScanCompleted = yield* Deferred.make(); + const releaseFirstWorktreeScan = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const refScans = yield* Ref.make(0); + const coordinatingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false))) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Deferred.await(releaseFirstWorktreeScan); + } + const handle = yield* delegate.spawn(command); + const isRefScan = + command.args.includes("for-each-ref") && + command.args.includes("refs/heads") && + command.args.includes("refs/remotes"); + if (!isRefScan) return handle; + const scan = yield* Ref.updateAndGet(refScans, (count) => count + 1); + return scan === 1 + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(firstRefScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const inFlight = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(firstRefScanCompleted); + + yield* driver.createRef({ cwd, refName: "feature/during-refresh" }); + yield* Deferred.succeed(releaseFirstWorktreeScan, undefined); + + const refs = yield* Fiber.join(inFlight); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); + assert.equal(yield* Ref.get(refScans), 2); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const partiallyFailingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") { + const handle = yield* delegate.spawn(command); + yield* handle.exitCode; + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* driver.listRefs({ cwd, refresh: true }); + + yield* driver.createRef({ cwd, refName: "feature/partial-failure" }).pipe(Effect.flip); + + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const snapshotAttempts = yield* Ref.make(0); + const failingSnapshotSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("for-each-ref")) { + yield* Ref.update(snapshotAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listRefs.snapshotRefs", + detail: "Git ref snapshot enumeration failed.", + exitCode: 128, + }); + assert.equal(yield* Ref.get(snapshotAttempts), 1); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("marks the current branch when worktree metadata is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeRoot = + command.args.includes("rev-parse") && command.args.includes("--show-toplevel"); + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeRoot || isWorktreeList) { + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.isTrue(refs.isRepo); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("ignores worktree metadata for directories that no longer exist", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const missingWorktreePath = "/missing/deleted-worktree"; + const staleWorktreeSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeList) { + return makeSuccessfulHandle( + `worktree ${missingWorktreePath}\0HEAD deadbeef\0branch refs/heads/stale-worktree\0\0`, + ); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* git(cwd, ["branch", "stale-worktree"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("refreshes the current branch after an external checkout", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "external-checkout"]); + + const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); + + // Raw execute intentionally bypasses the driver's mutation invalidation, + // matching a checkout performed by another process. + yield* driver.execute({ + operation: "GitVcsDriver.test.externalCheckout", + cwd, + args: ["checkout", "external-checkout"], + timeoutMs: 10_000, + }); + yield* TestClock.adjust("6 seconds"); + + const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); + assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("backs off failed upstream refreshes across linked worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchAttempts = yield* Ref.make(0); + const failingFetchSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("fetch") && command.args.includes("--quiet")) { + yield* Ref.update(fetchAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), + ); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked"); + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.upstreamRefreshBackoff", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); + yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* runGit(worktreePath, [ + "branch", + "--set-upstream-to", + `origin/${initialBranch}`, + "feature/linked", + ]); + const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); + const linkedCommonDir = (yield* runGit(worktreePath, [ + "rev-parse", + "--git-common-dir", + ])).stdout.trim(); + assert.equal( + yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)), + yield* fileSystem.realPath(pathService.resolve(worktreePath, linkedCommonDir)), + ); + yield* Ref.set(fetchAttempts, 0); + + yield* driver.statusDetailsRemote(cwd); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("29 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("59 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => @@ -661,6 +1103,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("preserves newline characters in worktree paths when listing refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find( + (ref) => ref.name === "feature/newline-path", + )?.worktreePath; + + if (typeof listedPath !== "string") { + return assert.fail("expected the linked branch to include its worktree path"); + } + assert.equal( + yield* fileSystem.realPath(listedPath), + yield* fileSystem.realPath(worktreePath), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..f739c98da29 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -50,8 +50,17 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN = Duration.seconds(30); +const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048; +const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10); +const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1); +const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; +const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); +const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); +const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -95,6 +104,35 @@ class StatusRemoteRefreshCacheKey extends Data.Class<{ remoteName: string; }> {} +function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const cooldownMs = + Duration.toMillis(STATUS_UPSTREAM_REFRESH_FAILURE_BASE_COOLDOWN) * Math.pow(2, exponent); + return Duration.min(Duration.millis(cooldownMs), STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN); +} + +class GitRefsSnapshotCacheKey extends Data.Class<{ + gitCommonDir: string; + epoch: number; +}> {} + +class GitRefsRefreshCacheKey extends Data.Class<{ + gitCommonDir: string; + generation: number; +}> {} + +interface GitRepositoryPaths { + readonly gitCommonDir: string; + readonly worktreeRoot: string | null; + readonly currentBranch: string | null; +} + +interface GitRefsSnapshot { + readonly localBranches: ReadonlyArray; + readonly remoteBranches: ReadonlyArray; + readonly hasPrimaryRemote: boolean; +} + interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | undefined; @@ -161,21 +199,6 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } -function parseBranchLine(line: string): { name: string; current: boolean } | null { - const trimmed = line.trim(); - if (trimmed.length === 0) return null; - - const name = trimmed.replace(/^[*+]\s+/, ""); - // Exclude symbolic refs like: "origin/HEAD -> origin/main". - // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". - if (name.includes(" -> ") || name.startsWith("(")) return null; - - return { - name, - current: trimmed.startsWith("* "), - }; -} - function filterBranchesForListQuery( refs: ReadonlyArray, query?: string, @@ -210,6 +233,37 @@ function paginateBranches(input: { }; } +function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { + const worktreePaths = new Map(); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let currentPrunable = false; + + const flush = () => { + if (currentPath !== null && currentBranch !== null && !currentPrunable) { + worktreePaths.set(currentBranch, currentPath); + } + currentPath = null; + currentBranch = null; + currentPrunable = false; + }; + + for (const field of stdout.split("\0")) { + if (field === "") { + flush(); + } else if (field.startsWith("worktree ")) { + currentPath = field.slice("worktree ".length); + } else if (field.startsWith("branch refs/heads/")) { + currentBranch = field.slice("branch refs/heads/".length); + } else if (field === "prunable" || field.startsWith("prunable ")) { + currentPrunable = true; + } + } + flush(); + + return worktreePaths; +} + function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); if (parts.length === 0) return []; @@ -937,35 +991,183 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { - allowNonZeroExit: true, env: STATUS_UPSTREAM_REFRESH_ENV, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), }, ).pipe(Effect.asVoid); }; + const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( + cwd: string, + ) { + const commonDirResult = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ); + if (commonDirResult.exitCode !== 0) { + const stderr = commonDirResult.stderr.trim(); + if (isNonRepositoryGitStderr(stderr)) { + return null; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveRepositoryPaths.commonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Failed to resolve the Git common directory.", + exitCode: commonDirResult.exitCode, + stdoutLength: commonDirResult.stdout.length, + stderrLength: commonDirResult.stderr.length, + }); + } + + const commonDirOutput = commonDirResult.stdout.trim(); + const resolvedGitCommonDir = path.isAbsolute(commonDirOutput) + ? path.normalize(commonDirOutput) + : path.resolve(cwd, commonDirOutput); + const gitCommonDir = yield* fileSystem + .realPath(resolvedGitCommonDir) + .pipe(Effect.orElseSucceed(() => resolvedGitCommonDir)); + const [worktreeRootResult, currentBranchResult] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.resolveRepositoryPaths.worktreeRoot", + cwd, + ["rev-parse", "--show-toplevel"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.resolveRepositoryPaths.currentBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + ], + { concurrency: 2 }, + ); + const worktreeRootOutput = worktreeRootResult.stdout.trim(); + const worktreeRoot = + worktreeRootResult.exitCode === 0 && worktreeRootOutput.length > 0 + ? path.normalize( + path.isAbsolute(worktreeRootOutput) + ? worktreeRootOutput + : path.resolve(cwd, worktreeRootOutput), + ) + : null; + const currentBranchOutput = currentBranchResult.stdout.trim(); + const currentBranch = + currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 + ? currentBranchOutput + : null; + + return { + gitCommonDir, + worktreeRoot, + currentBranch, + } satisfies GitRepositoryPaths; + }); + + const repositoryPathsCache = yield* Cache.makeWith( + (cwd: string) => resolveRepositoryPathsUncached(cwd), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null ? NON_REPOSITORY_PATHS_CACHE_TTL : REPOSITORY_PATHS_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const repositoryPathsRefreshCache = yield* Cache.makeWith( + (cwd: string) => + Cache.invalidate(repositoryPathsCache, cwd).pipe( + Effect.andThen(Cache.get(repositoryPathsCache, cwd)), + ), + { + capacity: REPOSITORY_PATHS_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (repositoryPaths) => + repositoryPaths === null + ? NON_REPOSITORY_PATHS_CACHE_TTL + : REPOSITORY_PATHS_REFRESH_COALESCE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd)); + const resolveRepositoryPaths = (cwd: string, refresh = false) => { + const cacheKey = normalizeRepositoryPathsCacheKey(cwd); + return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); + }; + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ - "rev-parse", - "--git-common-dir", - ]).pipe(Effect.map((stdout) => stdout.trim())); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + const repositoryPaths = yield* resolveRepositoryPaths(cwd); + if (repositoryPaths !== null) { + return repositoryPaths.gitCommonDir; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolveGitCommonDir", + cwd, + args: ["rev-parse", "--git-common-dir"], + }), + detail: "Cannot resolve a Git common directory outside a repository.", + }); }); + const statusRemoteRefreshFailureCounts = new Map(); + const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => + `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; + const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { + const key = statusRemoteRefreshFailureKey(cacheKey); + const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; + statusRemoteRefreshFailureCounts.delete(key); + statusRemoteRefreshFailureCounts.set(key, nextCount); + if (statusRemoteRefreshFailureCounts.size > STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY) { + const oldestKey = statusRemoteRefreshFailureCounts.keys().next().value; + if (oldestKey !== undefined) { + statusRemoteRefreshFailureCounts.delete(oldestKey); + } + } + }; + const clearStatusRemoteRefreshFailures = (cacheKey: StatusRemoteRefreshCacheKey) => { + statusRemoteRefreshFailureCounts.delete(statusRemoteRefreshFailureKey(cacheKey)); + }; const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( cacheKey: StatusRemoteRefreshCacheKey, ) { - yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); - return true as const; + return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( + Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), + Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), + Effect.as(true as const), + ); }); const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. - timeToLive: (exit) => + // A failed background fetch is intentionally cached and exponentially + // backed off. Status reads swallow this failure and use the last fetched + // refs, so repeated thread mounts cannot turn a slow or unavailable remote + // into a repository-wide Git subprocess storm. + timeToLive: (exit, cacheKey) => Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL - : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, + : statusUpstreamRefreshFailureCooldown( + statusRemoteRefreshFailureCounts.get(statusRemoteRefreshFailureKey(cacheKey)) ?? 1, + ), }); const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( @@ -1273,42 +1475,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { - const branchRecency = yield* executeGit( - "GitVcsDriver.readBranchRecency", - cwd, - [ - "for-each-ref", - "--format=%(refname:short)%09%(committerdate:unix)", - "refs/heads", - "refs/remotes", - ], - { - timeoutMs: 15_000, - allowNonZeroExit: true, - }, - ); - - const branchLastCommit = new Map(); - if (branchRecency.exitCode !== 0) { - return branchLastCommit; - } - - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; - } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; - } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } - - return branchLastCommit; - }); - const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", @@ -1987,257 +2153,283 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( - function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.orElseSucceed(() => new Map()), - ); - const localBranchResult = yield* executeGitWithStableDiagnostics( - "GitVcsDriver.listRefs.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, + const readGitRefsSnapshot = Effect.fn("readGitRefsSnapshot")(function* (gitCommonDir: string) { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + const gitDirArgs = ["--git-dir", gitCommonDir] as const; + const [refsResult, defaultRefResult, worktreeListResult, remoteNamesResult] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.listRefs.snapshotRefs", + fetchCwd, + [ + ...gitDirArgs, + "for-each-ref", + "--format=%(refname)%09%(committerdate:unix)%09%(symref)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024 * 1024, + fallbackErrorDetail: "Git ref snapshot enumeration failed.", + }, + ), + executeGit( + "GitVcsDriver.listRefs.defaultRef", + fetchCwd, + [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + fetchCwd, + [...gitDirArgs, "worktree", "list", "--porcelain", "-z"], + { + timeoutMs: 30_000, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + }, + ), + executeGit("GitVcsDriver.listRefs.remoteNames", fetchCwd, [...gitDirArgs, "remote"], { + timeoutMs: 5_000, allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - isMissingGitCwdError(error) - ? Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(128), - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }) - : Effect.fail(error), }), - ); + ], + { concurrency: 2 }, + ); - if (localBranchResult.exitCode !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (isNonRepositoryGitStderr(stderr)) { - return { - refs: [], - isRepo: false, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.listRefs", - cwd: input.cwd, - args: ["branch", "--no-color", "--no-column"], - }), - detail: "Git branch listing failed.", - exitCode: localBranchResult.exitCode, - stdoutLength: localBranchResult.stdout.length, - stderrLength: localBranchResult.stderr.length, + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${gitCommonDir}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + const parsedWorktreeEntries = + worktreeListResult.exitCode === 0 + ? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map( + ([branchName, worktreePath]) => + [branchName, path.normalize(path.resolve(worktreePath))] as const, + ) + : []; + const existingWorktreeEntries = yield* Effect.filter( + parsedWorktreeEntries, + ([, worktreePath]) => + fileSystem.stat(worktreePath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + { concurrency: 16 }, + ); + const worktreeMap = new Map(existingWorktreeEntries); + const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = []; + + for (const line of refsResult.stdout.split("\n")) { + if (line.length === 0) continue; + const [fullRefName, lastCommitRaw, symbolicTarget] = line.split("\t"); + if (!fullRefName || symbolicTarget) continue; + const parsedLastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + const lastCommit = Number.isFinite(parsedLastCommit) ? parsedLastCommit : 0; + + if (fullRefName.startsWith("refs/heads/")) { + const name = fullRefName.slice("refs/heads/".length); + localBranches.push({ + ref: { + name, + current: false, + isRemote: false, + isDefault: name === defaultBranch, + worktreePath: worktreeMap.get(name) ?? null, + }, + lastCommit, }); + continue; } + if (!fullRefName.startsWith("refs/remotes/")) continue; + + const name = fullRefName.slice("refs/remotes/".length); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(name, remoteNames); + const remoteBranch: VcsRef = { + name, + current: false, + isRemote: true, + isDefault: + defaultBranch !== null && + parsedRemoteRef?.remoteName === "origin" && + parsedRemoteRef.branchName === defaultBranch, + worktreePath: null, + ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}), + }; + remoteBranches.push({ ref: remoteBranch, lastCommit }); + } - const remoteBranchResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - Effect.logWarning( - "Git remote ref lookup failed; falling back to an empty remote ref list.", - { - operation: error.operation, - command: error.command, - cwd: error.cwd, - detail: error.detail, - cause: error, - }, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - }), - ); - - const remoteNamesResultEffect = executeGit( - "GitVcsDriver.listRefs.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchTags({ - GitCommandError: (error) => - Effect.logWarning( - "Git remote name lookup failed; falling back to an empty remote name list.", - { - operation: error.operation, - command: error.command, - cwd: error.cwd, - detail: error.detail, - cause: error, - }, - ).pipe( - Effect.as({ - exitCode: ChildProcessSpawner.ExitCode(1), - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - } satisfies GitVcsDriver.ExecuteGitResult), - ), - }), - ); + const byRecencyThenName = ( + left: { readonly ref: VcsRef; readonly lastCommit: number }, + right: { readonly ref: VcsRef; readonly lastCommit: number }, + ) => + left.lastCommit !== right.lastCommit + ? right.lastCommit - left.lastCommit + : left.ref.name.localeCompare(right.ref.name); - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitVcsDriver.listRefs.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitVcsDriver.listRefs.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, - ); + return { + localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), + hasPrimaryRemote: remoteNames.includes("origin"), + } satisfies GitRefsSnapshot; + }); - const remoteNames = - remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, - ); + const listRefsEpochByCommonDir = new Map(); + let listRefsEpochSequence = 0; + const bumpListRefsEpoch = (gitCommonDir: string): number => { + const nextEpoch = ++listRefsEpochSequence; + listRefsEpochByCommonDir.delete(gitCommonDir); + listRefsEpochByCommonDir.set(gitCommonDir, nextEpoch); + if (listRefsEpochByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsEpochByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsEpochByCommonDir.delete(oldestKey); } - if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, - ); + } + return nextEpoch; + }; + const listRefsGenerationByCommonDir = new Map(); + let listRefsGenerationSequence = 0; + const setListRefsGeneration = (gitCommonDir: string, generation: number): number => { + listRefsGenerationByCommonDir.delete(gitCommonDir); + listRefsGenerationByCommonDir.set(gitCommonDir, generation); + if (listRefsGenerationByCommonDir.size > LIST_REFS_SNAPSHOT_CACHE_CAPACITY) { + const oldestKey = listRefsGenerationByCommonDir.keys().next().value; + if (oldestKey !== undefined) { + listRefsGenerationByCommonDir.delete(oldestKey); } - - const defaultBranch = - defaultRef.exitCode === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - const worktreeMap = new Map(); - if (worktreeList.exitCode === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.orElseSucceed(() => false), + } + return generation; + }; + const currentListRefsGeneration = (gitCommonDir: string): number => { + const current = listRefsGenerationByCommonDir.get(gitCommonDir); + return current === undefined + ? setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence) + : setListRefsGeneration(gitCommonDir, current); + }; + const bumpListRefsGeneration = (gitCommonDir: string): number => + setListRefsGeneration(gitCommonDir, ++listRefsGenerationSequence); + const listRefsSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsSnapshotCacheKey) => readGitRefsSnapshot(cacheKey.gitCommonDir), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? LIST_REFS_SNAPSHOT_CACHE_TTL : Duration.zero), + }, + ); + const listRefsRefreshSnapshotCache = yield* Cache.makeWith( + (cacheKey: GitRefsRefreshCacheKey) => + Effect.suspend(() => { + const epoch = bumpListRefsEpoch(cacheKey.gitCommonDir); + return Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir: cacheKey.gitCommonDir, epoch }), + ); + }), + { + capacity: LIST_REFS_SNAPSHOT_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) ? LIST_REFS_REFRESH_COALESCE_TTL : LIST_REFS_REFRESH_FAILURE_COOLDOWN, + }, + ); + const resolveListRefsSnapshot = Effect.fn("resolveListRefsSnapshot")(function* ( + gitCommonDir: string, + refresh: boolean, + ) { + while (true) { + const generation = currentListRefsGeneration(gitCommonDir); + const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); + const snapshot = + refresh || currentEpoch === undefined + ? // The refresh cache owns the complete snapshot read, rather than only the + // epoch bump. Slow repositories therefore remain singleflight for the + // entire Git scan even when more refresh requests arrive after the + // coalescing TTL would otherwise have elapsed. + yield* Cache.get( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ gitCommonDir, generation }), + ) + : yield* Cache.get( + listRefsSnapshotCache, + new GitRefsSnapshotCacheKey({ gitCommonDir, epoch: currentEpoch }), ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } - } + if (currentListRefsGeneration(gitCommonDir) === generation) { + return snapshot; } + } + }); + const invalidateListRefsSnapshot = Effect.fn("invalidateListRefsSnapshot")(function* ( + cwd: string, + ) { + const repositoryPathsCacheKey = normalizeRepositoryPathsCacheKey(cwd); + const repositoryPaths = yield* Cache.get(repositoryPathsCache, repositoryPathsCacheKey); + if (repositoryPaths === null) return; + const previousGeneration = currentListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsGeneration(repositoryPaths.gitCommonDir); + bumpListRefsEpoch(repositoryPaths.gitCommonDir); + yield* Cache.invalidate( + listRefsRefreshSnapshotCache, + new GitRefsRefreshCacheKey({ + gitCommonDir: repositoryPaths.gitCommonDir, + generation: previousGeneration, + }), + ); + yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey); + yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey); + }); - const localBranches = Arr.filterMap(localBranchResult.stdout.split("\n"), (line) => { - const refName = parseBranchLine(line); - return refName === null - ? Result.failVoid - : Result.succeed({ - name: refName.name, - current: refName.current, - isRemote: false, - isDefault: refName.name === defaultBranch, - worktreePath: worktreeMap.get(refName.name) ?? null, - }); - }).toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - - const remoteBranches = - remoteBranchResult.exitCode === 0 - ? Arr.filterMap(remoteBranchResult.stdout.split("\n"), (line) => { - const refName = parseBranchLine(line); - if (refName === null) { - return Result.failVoid; - } - const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: refName.name, - current: false, - isRemote: true, - // origin/HEAD's target is the repo default even when no local - // copy of the default branch exists. - isDefault: - defaultBranch !== null && - parsedRemoteRef?.remoteName === "origin" && - parsedRemoteRef.branchName === defaultBranch, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return Result.succeed(remoteBranch); - }).toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; + const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")( + function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + const snapshot = yield* resolveListRefsSnapshot( + repositoryPaths.gitCommonDir, + input.refresh === true, + ); + const hasCurrentWorktreeBranch = + repositoryPaths.worktreeRoot !== null && + snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot); + const localBranches = snapshot.localBranches.map((ref) => ({ + ...ref, + current: hasCurrentWorktreeBranch + ? ref.worktreePath === repositoryPaths.worktreeRoot + : ref.name === repositoryPaths.currentBranch, + })); const combinedBranches = input.includeMatchingRemoteRefs - ? [...localBranches, ...remoteBranches] - : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]); + ? [...localBranches, ...snapshot.remoteBranches] + : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); // Keep current/default refs on the first page even when the default // only exists as origin/ (remote refs sort after all locals). - const allBranches = combinedBranches.toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - return aPriority - bPriority; + const allBranches = combinedBranches.toSorted((left, right) => { + const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; + const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; + return leftPriority - rightPriority; }); const branchesForKind = input.refKind === "local" @@ -2254,7 +2446,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { refs: [...refs.refs], isRepo: true, - hasPrimaryRemote: remoteNames.includes("origin"), + hasPrimaryRemote: snapshot.hasPrimaryRemote, nextCursor: refs.nextCursor, totalCount: refs.totalCount, }; @@ -2548,6 +2740,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const withListRefsInvalidation = ( + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); + const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( + input, + ) => + initRepo(input).pipe( + Effect.ensuring( + Effect.gen(function* () { + const cacheKey = normalizeRepositoryPathsCacheKey(input.cwd); + yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey); + yield* Cache.invalidate(repositoryPathsCache, cacheKey); + yield* invalidateListRefsSnapshot(input.cwd).pipe(Effect.ignore); + }), + ), + ); + return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -2555,27 +2766,31 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetailsLocal, statusDetailsRemote, prepareCommitContext, - commit, - pushCurrentBranch, - pullCurrentBranch, + commit: (cwd, subject, body, options) => + withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), + pushCurrentBranch: (cwd, fallbackBranch, options) => + withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), + pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), readRangeContext, getReviewDiffPreview, readConfigValue, listRefs, - createWorktree, - fetchPullRequestBranch, - ensureRemote, + createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), + fetchPullRequestBranch: (input) => + withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), + ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, - fetchRemote, + fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), resolveRemoteTrackingCommit, - fetchRemoteBranch, - fetchRemoteTrackingBranch, - setBranchUpstream, - removeWorktree, - renameBranch, - createRef, - switchRef, - initRepo, + fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), + fetchRemoteTrackingBranch: (input) => + withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), + setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), + removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), + renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), + createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), + switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a8be25a728..744e48661bf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,7 +46,6 @@ import { ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, - OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -60,7 +59,6 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; -import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -99,7 +97,6 @@ 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 RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -302,7 +299,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], @@ -441,8 +437,6 @@ const makeWsRpcLayer = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const repositoryIdentityResolver = - yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; @@ -592,53 +586,6 @@ const makeWsRpcLayer = ( }); }; - const enrichProjectEvent = ( - event: OrchestrationEvent, - ): Effect.Effect => { - switch (event.type) { - case "project.created": - return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( - Effect.map((repositoryIdentity) => ({ - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - })), - ); - case "project.meta-updated": - return Effect.gen(function* () { - const workspaceRoot = - event.payload.workspaceRoot ?? - Option.match( - yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId), - { - onNone: () => null, - onSome: (project) => project.workspaceRoot, - }, - ) ?? - null; - if (workspaceRoot === null) { - return event; - } - - const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); - return { - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - } satisfies OrchestrationEvent; - }).pipe(Effect.orElseSucceed(() => event)); - default: - return Effect.succeed(event); - } - }; - - const enrichOrchestrationEvents = (events: ReadonlyArray) => - Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); - const toShellStreamEvent = ( event: OrchestrationEvent, ): Effect.Effect, never, never> => { @@ -1215,30 +1162,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { - maximum: Number.MAX_SAFE_INTEGER, - minimum: 0, - }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.map((events) => events.map(projectActivityEvent)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 6552dfb140c..d6098937e7f 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -11,6 +11,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, type ThreadFeedActivity } from "../../mobile/src/lib/threadActivity.ts"; +import { deriveLatestContextWindowSnapshot } from "../../web/src/lib/contextWindow.ts"; import { deriveWorkLogEntries } from "../../web/src/session-logic.ts"; import { projectActivityEvent, @@ -231,3 +232,132 @@ describe("projectActivityPayload", () => { expect(event.payload.activity).toBe(activity); }); }); + +describe("context-window snapshot dedup", () => { + function makeContextWindowActivity( + id: string, + usedTokens: number, + turn = `turn-${id}`, + ): OrchestrationThreadActivity { + return { + id: EventId.make(id), + tone: "info", + kind: "context-window.updated", + summary: "Context window updated", + payload: { usedTokens, maxTokens: 200_000 }, + turnId: TurnId.make(turn), + createdAt: "2026-07-27T00:00:00.000Z", + }; + } + + it("keeps only the latest context-window activity per turn in snapshots", () => { + const stale1 = makeContextWindowActivity("ctx-1", 1_000, "turn-a"); + const latestA = makeContextWindowActivity("ctx-2", 2_000, "turn-a"); + const latestB = makeContextWindowActivity("ctx-3", 3_000, "turn-b"); + const tool = fixtures[0]!; + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([stale1, tool, latestA, latestB]), + }); + + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + tool.id, + latestA.id, + latestB.id, + ]); + // The retained rows keep their payloads untouched — the tool-data + // projection only rewrites payloads with a `data` record. + expect(projected.thread.activities[2]?.payload).toEqual(latestB.payload); + }); + + it("still resolves a meter value after the client reverts the newest turn", () => { + // A live thread.reverted makes the client drop all activities from + // discarded turns; each surviving turn must keep a usable row. + const olderTurn = makeContextWindowActivity("ctx-old", 1_500, "turn-kept"); + const revertedTurn = makeContextWindowActivity("ctx-new", 9_000, "turn-reverted"); + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([olderTurn, revertedTurn]), + }); + const afterRevert = projected.thread.activities.filter( + (activity) => activity.turnId === TurnId.make("turn-kept"), + ); + + expect(deriveLatestContextWindowSnapshot(afterRevert)).toEqual( + deriveLatestContextWindowSnapshot([olderTurn]), + ); + }); + + it("matches what the web client derives from the full history", () => { + const activities = [ + makeContextWindowActivity("ctx-1", 1_000), + makeContextWindowActivity("ctx-2", 2_000), + ]; + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread(activities), + }); + + expect(deriveLatestContextWindowSnapshot(projected.thread.activities)).toEqual( + deriveLatestContextWindowSnapshot(activities), + ); + }); + + it("does not let a malformed row shadow an earlier valid row in the same turn", () => { + const valid = makeContextWindowActivity("ctx-valid", 5_000, "turn-a"); + const malformed: OrchestrationThreadActivity = { + ...makeContextWindowActivity("ctx-broken", 0, "turn-a"), + payload: { usedTokens: null }, + }; + + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([valid, malformed]), + }); + + // The malformed row passes through, the valid row survives, and the + // client's backward walk resolves the same value as with full history. + expect(projected.thread.activities.map((activity) => activity.id)).toEqual([ + valid.id, + malformed.id, + ]); + expect(deriveLatestContextWindowSnapshot(projected.thread.activities)).toEqual( + deriveLatestContextWindowSnapshot([valid, malformed]), + ); + }); + + it("leaves snapshots without context-window activities untouched", () => { + const projected = projectThreadDetailSnapshot({ + snapshotSequence: 7, + thread: makeThread([fixtures[4]!]), + }); + expect(projected.thread.activities).toEqual([fixtures[4]]); + }); + + it("does not filter live activity-appended events", () => { + const activity = makeContextWindowActivity("ctx-live", 4_000); + const event = { + sequence: 9, + eventId: EventId.make("event-ctx"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-projection"), + occurredAt: "2026-07-27T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { + threadId: ThreadId.make("thread-projection"), + activity, + }, + } satisfies Extract; + + const projected = projectActivityEvent(event); + expect( + projected.type === "thread.activity-appended" ? projected.payload.activity : undefined, + ).toEqual(activity); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index f9c8697ad89..9aa0a4d0001 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.29", + "version": "0.0.30", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/__fork_guards__/customizationsManifest.test.ts b/apps/web/src/__fork_guards__/customizationsManifest.test.ts index 6c8567d60ca..12f6f851d8a 100644 --- a/apps/web/src/__fork_guards__/customizationsManifest.test.ts +++ b/apps/web/src/__fork_guards__/customizationsManifest.test.ts @@ -39,6 +39,23 @@ const manifestText = NodeFS.readFileSync( const entries = parseCustomizations(manifestText) as ManifestEntry[]; +// One candidate pass shared by the fence tests below: only files that carry a +// marker at all (~40 of ~15k tracked — a full ls-files + readFileSync walk +// here measured ~3.8s per run, most of it reading vendored .repos/). --text, +// because ChatComposer.tsx contains raw NUL bytes and git grep would +// otherwise classify the fork's densest fenced file as binary and skip it — +// the same trap .fork/AGENTS.md documents for plain grep. Guard tests are +// excluded everywhere: they quote fence markers as prose. +const FENCE_MARKER = /fork:(begin|end) ([a-z0-9-]+)/gu; +const fencedFiles = NodeChildProcess.execSync('git grep --text -lE "fork:(begin|end) [a-z0-9-]+"', { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, +}) + .split("\n") + .filter(Boolean) + .filter((path) => !path.includes("__fork_guards__")); + describe("fork guard: customizations manifest", () => { it("parses at least one customization", () => { expect(entries.length).toBeGreaterThan(0); @@ -74,20 +91,17 @@ describe("fork guard: customizations manifest", () => { new Set([...entry.files, ...entry.shadows, ...entry.watch, ...entry.verify]), ]), ); - const tracked = NodeChildProcess.execSync("git ls-files", { - cwd: repoRoot, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }) - .split("\n") - .filter((path) => /\.(?:ts|tsx|mts|cts|js|mjs|cjs|css|sh|ya?ml)$/u.test(path)) - .filter((path) => !path.includes("__fork_guards__")); - const violations: string[] = []; - for (const path of tracked) { + for (const path of fencedFiles) { + // The extension whitelist predates the shared candidate pass and is + // load-bearing: markdown and workflow fences (AGENTS.md's + // fork-workflow, fork-change-scope) deliberately use fence ids that + // are anchors into the manifest rather than manifest ids themselves. + if (!/\.(?:ts|tsx|mts|cts|js|mjs|cjs|css|sh|ya?ml)$/u.test(path)) continue; const content = NodeFS.readFileSync(NodePath.join(repoRoot, path), "utf8"); - for (const match of content.matchAll(/fork:begin ([a-z0-9-]+)/gu)) { - const id = match[1] ?? ""; + for (const match of content.matchAll(FENCE_MARKER)) { + if (match[1] !== "begin") continue; + const id = match[2] ?? ""; if (!knownIds.has(id)) { violations.push(`${path}: fence references unknown customization "${id}"`); } else if (!claimed.get(id)?.has(path)) { @@ -97,4 +111,34 @@ describe("fork guard: customizations manifest", () => { } expect(violations).toEqual([]); }); + + it("balances every fork:begin with a fork:end of the same id", () => { + // An open fence with no boundary marks nothing: the next sync cannot tell + // where the customization stops, so upstream's side of a conflict can be + // taken right through it and every fence-based audit stays quiet. The + // sidebar-v2-card-rows padding hunk shipped exactly this way for several + // syncs before it was noticed by hand. Balance is checked per file — a + // begin in one file cannot be closed from another. `.fork/` is excluded + // with the guards: both quote fence markers as prose, not as fences. + const violations: string[] = []; + for (const path of fencedFiles) { + if (path.startsWith(".fork/")) continue; + const content = NodeFS.readFileSync(NodePath.join(repoRoot, path), "utf8"); + const begins = new Map(); + const ends = new Map(); + for (const match of content.matchAll(FENCE_MARKER)) { + const counts = match[1] === "begin" ? begins : ends; + const id = match[2] ?? ""; + counts.set(id, (counts.get(id) ?? 0) + 1); + } + for (const id of new Set([...begins.keys(), ...ends.keys()])) { + const opened = begins.get(id) ?? 0; + const closed = ends.get(id) ?? 0; + if (opened !== closed) { + violations.push(`${path}: "${id}" has ${opened} begin(s) and ${closed} end(s)`); + } + } + } + expect(violations).toEqual([]); + }); }); diff --git a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts index a598dea175a..f6446aeeabd 100644 --- a/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts +++ b/apps/web/src/__fork_guards__/forkSidebarChrome.test.ts @@ -13,7 +13,9 @@ import * as NodeFS from "node:fs"; import * as NodeURL from "node:url"; import { describe, expect, it } from "vite-plus/test"; +import { cn } from "../lib/utils"; import { resolveForkSidebarHeaderArt } from "../custom/SidebarHeaderBackdrop"; +import { CHROME_ROW_ICON_TINT } from "../custom/SidebarV2ChromeRows"; function readSibling(relativePath: string): string { return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); @@ -198,4 +200,48 @@ describe("fork guard: fork-sidebar-chrome", () => { expect(chrome).toMatch(/sidebar-brand[^"]*ml-auto/u); expect(chrome).not.toContain("ml-[var(--workspace-titlebar-content-left)]"); }); + + it("ignores the identification setting for the art and honors it for the pill", () => { + // Upstream's environmentIdentificationMode gates its own header art; the + // fork's header art is brand chrome and must never consult it — a sync + // that re-adopts upstream's gate turns the packaged app's header bare + // whenever the setting isn't "artwork". The pill half is the converse: + // "Version pill" must actually produce a pill, and upstream only ever + // rendered one here, so if this header drops it the option does nothing + // anywhere and the setting's own description becomes false in the fork. + expect(chrome).not.toContain('=== "artwork"'); + expect(chrome).not.toMatch(/\?\s*")); + expect(pill).toContain("text-white"); + }); + + it("keeps the chrome-row icon tint displacing the menu button's base dim", () => { + // Upstream v0.0.30 gave sidebarMenuButtonVariants a parent-level icon pair + // (muted-foreground at opacity-60) that outweighs an icon's own class, so + // the fork rows counter it with a later same-slot pair that twMerge keeps. + // Asserting the merged outcome rather than the source string is the point: + // if upstream reshapes the selector ([&_svg], a data-slot rule, an ! + // modifier) so the pair stops conflicting, the dim survives the merge and + // this reds while every source-string check stays green. The base is read + // out of the cva call because upstream does not export it, and importing + // the component just for its class string would drag the whole sidebar + // module graph into this test. + const sidebar = readSibling("../components/ui/sidebar.tsx"); + const base = /const sidebarMenuButtonVariants = cva\(\s*"([^"]+)"/u.exec(sidebar)?.[1]; + expect(base, "sidebarMenuButtonVariants base class not found").toBeTruthy(); + const merged = cn(base, CHROME_ROW_ICON_TINT); + expect(merged).not.toMatch(/svg\]:opacity-60/u); + expect(merged).toMatch(/svg\]:text-sidebar-muted-foreground\/80/u); + expect(merged).toMatch(/svg\]:opacity-100/u); + // One spelling: both chrome-row buttons reference the shared const, and + // the literal exists only in its definition — a second paste is how the + // two drifted apart before it was hoisted. + const rows = readSibling("../custom/SidebarV2ChromeRows.tsx"); + expect(rows.split("[&>svg]:opacity-100").length - 1).toBe(1); + expect(rows.split("CHROME_ROW_ICON_TINT").length - 1).toBeGreaterThanOrEqual(3); + }); }); diff --git a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts index 49d62574939..2f80231a868 100644 --- a/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts +++ b/apps/web/src/__fork_guards__/forkWorkflowDocs.test.ts @@ -30,6 +30,26 @@ describe("fork guard: fork-workflow-docs", () => { expect(agents).toContain(".fork/AGENTS.md"); }); + it("keeps Change Scope fenced and ahead of upstream's first section", () => { + // Fork-authored guidance in an upstream-owned file, so an upstream rewrite + // deletes it in a clean merge unless something reds. Position is asserted + // because position is the point: this is the scope contract every agent + // must hit before upstream's own guidance, not an appendix. The v0.0.30 + // sync initially re-attached it at the bottom of the rewritten doc, which + // is what this catches. + const agents = NodeFS.readFileSync(NodePath.join(repoRoot, "AGENTS.md"), "utf8"); + expect(agents).toContain("fork:begin fork-change-scope"); + expect(agents).toContain("fork:end fork-change-scope"); + const changeScope = agents.indexOf("## Change Scope"); + expect(changeScope).toBeGreaterThanOrEqual(0); + const firstUpstreamSection = agents + .split("\n") + .findIndex((line) => line.startsWith("## ") && line !== "## Change Scope"); + const firstUpstreamIndex = agents.split("\n").slice(0, firstUpstreamSection).join("\n").length; + expect(firstUpstreamSection).toBeGreaterThanOrEqual(0); + expect(changeScope).toBeLessThan(firstUpstreamIndex); + }); + it("keeps CLAUDE.md aliased to AGENTS.md so Claude agents get the same rules", () => { // Assert the committed object, not the working tree. What an agent gets // is whatever the clone materializes from this blob, and a checkout with diff --git a/apps/web/src/__fork_guards__/sidebarV2ErrorTooltip.test.ts b/apps/web/src/__fork_guards__/sidebarV2ErrorTooltip.test.ts new file mode 100644 index 00000000000..5ba86fb5e8c --- /dev/null +++ b/apps/web/src/__fork_guards__/sidebarV2ErrorTooltip.test.ts @@ -0,0 +1,39 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/customizations.yaml#sidebar-v2-error-tooltip`. + * + * Upstream's tooltip restyle replaced the session's real last error with the + * literal "Error occurred". The fork restores the message; this asserts a + * sync cannot quietly take upstream's literal back, which would render fine + * and pass every other test while removing the sidebar's only diagnostic. + */ + +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +const sidebarV2 = NodeFS.readFileSync( + NodeURL.fileURLToPath(new URL("../components/SidebarV2.tsx", import.meta.url)), + "utf8", +); + +describe("fork guard: sidebar-v2-error-tooltip", () => { + it("renders the session's actual last error in the thread tooltip", () => { + expect(sidebarV2).toContain("{thread.session.lastError}"); + // The literal upstream substituted; bounded to a JSX text position so + // prose in a comment cannot trip it. + expect(sidebarV2).not.toMatch(/>\s*Error occurred\s* { + // A truncated error hides the tail — exit codes and paths — which is the + // half that diagnoses anything. Scoped to the element carrying the + // message rather than the file, so other truncate uses stay free. + const start = sidebarV2.indexOf("{thread.session.lastError}"); + expect(start).toBeGreaterThanOrEqual(0); + const elementOpen = sidebarV2.lastIndexOf(" { ).toBe("T3 Code (Alpha)"); }); }); + +describe("resolveSidebarV2Default", () => { + it.each(["Nightly", "Dev", "nightly", " dev "])("enables the beta for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(true); + }); + + it.each(["Alpha", "Latest", ""])("leaves the beta off for %s builds", (stage) => { + expect(resolveSidebarV2Default(stage)).toBe(false); + }); +}); + +describe("resolveSidebarV2Enabled", () => { + const hydrated = { settingsHydrated: true } as const; + + it.each(["Alpha", "Latest"])( + "keeps a legacy opt-in on %s builds even without the companion flag", + (stageLabel) => { + // `true` was never the schema default, so it can only be an explicit + // opt-in from settings written before `sidebarV2ConfiguredByUser` existed. + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: true, + configuredByUser: false, + stageLabel, + }), + ).toBe(true); + }, + ); + + it("applies the stage default when the beta was never enabled or configured", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Nightly", + }), + ).toBe(true); + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: false, + stageLabel: "Latest", + }), + ).toBe(false); + }); + + it("honors an explicit opt-out over the stage default", () => { + expect( + resolveSidebarV2Enabled({ + ...hydrated, + enabled: false, + configuredByUser: true, + stageLabel: "Nightly", + }), + ).toBe(false); + }); + + it("holds v1 until settings hydrate so the sidebar does not remount", () => { + expect( + resolveSidebarV2Enabled({ + enabled: true, + configuredByUser: true, + settingsHydrated: false, + stageLabel: "Nightly", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index ac6d85b824a..a2d2c8db097 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,7 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useClientSettings } from "../hooks/useSettings"; +import { useSidebarV2Enabled } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; import ThreadSidebarV2 from "./SidebarV2"; import { @@ -120,7 +120,7 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarV2Enabled = useSidebarV2Enabled(); // Settings routes render the settings nav, which lives in the v1 component // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c4ae50a9e98..9b4cbf2b4a4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -24,6 +24,7 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -230,7 +231,7 @@ export function BranchToolbarBranchSelector({ const refs = branchRefState.refs; const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; + const isFetchingNextPage = branchRefState.isFetchingNextPage; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; @@ -505,19 +506,16 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Combobox / list plumbing // --------------------------------------------------------------------------- - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; - } - branchRefState.refresh(); - }, - [branchRefState.refresh], - ); - const branchListScrollElementRef = useRef(null); + const previousBranchListScrollTopRef = useRef(null); + const handleOpenChange = useCallback((open: boolean) => { + previousBranchListScrollTopRef.current = null; + setIsBranchMenuOpen(open); + if (!open) { + setBranchQuery(""); + } + }, []); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { @@ -528,18 +526,24 @@ export function BranchToolbarBranchSelector({ branchRefState.loadNext(); }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { - if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { - return; - } - const scrollElement = branchListScrollElementRef.current; if (!scrollElement) { return; } - const distanceFromBottom = - scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - if (distanceFromBottom > 96) { + const previousScrollTop = previousBranchListScrollTopRef.current; + previousBranchListScrollTopRef.current = scrollElement.scrollTop; + if ( + !isBranchMenuOpen || + !hasNextPage || + isFetchingNextPage || + !shouldLoadNextBranchPageAfterScroll({ + previousScrollTop, + scrollTop: scrollElement.scrollTop, + scrollHeight: scrollElement.scrollHeight, + clientHeight: scrollElement.clientHeight, + }) + ) { return; } @@ -589,10 +593,6 @@ export function BranchToolbarBranchSelector({ void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); - useEffect(() => { - maybeFetchNextBranchPage(); - }, [refs.length, maybeFetchNextBranchPage]); - const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -791,14 +791,10 @@ export function BranchToolbarBranchSelector({ renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} onLayout={() => { updateBranchListScrollFades(); - maybeFetchNextBranchPage(); + previousBranchListScrollTopRef.current = + branchListScrollElementRef.current?.scrollTop ?? null; }} onScroll={() => { updateBranchListScrollFades(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..d86fe39a77f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,8 +67,10 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], }, protocols: { ...defaultSchema.protocols, @@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -253,6 +257,33 @@ function remarkPreserveCodeMeta() { }; } +/** + * Fenced code also lands on the `code` component, and inline vs block is no + * longer distinguishable there once both render `` — so inline spans are + * tagged on the mdast, where the distinction still exists. Code inside a link + * label stays untagged: linkifying it would nest an anchor inside the link's + * anchor and steal its clicks. + */ +function remarkTagInlineCode() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if (node.type === "inlineCode" && !insideLink) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataInlineCode: "", + }, + }; + } + const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + node.children?.forEach((child) => visit(child, childInsideLink)); + }; + + visit(tree, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -276,11 +307,17 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || - onlyChild.type !== "code" + !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( + onlyChild, + ) ) { return null; } + // With a custom `code` component the child's type is that component, not + // the "code" tag — the hast node react-markdown attaches still names it. + if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { + return null; + } return { className: onlyChild.props.className, @@ -816,6 +853,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map< return suffixByPath; } +const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; +const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; + +function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); + for (let index = 0; index < segments.length; index += 2) { + for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { + const span = match[1]?.trim(); + if (span) spans.push(span); + } + } + return spans; +} + function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { @@ -1281,10 +1333,24 @@ function ChatMarkdown({ } return metaByHref; }, [cwd, text]); + const inlineCodeFileLinkMetaByText = useMemo(() => { + const metaByText = new Map(); + for (const span of extractInlineCodeSpans(text)) { + if (metaByText.has(span)) continue; + const meta = resolveInlineCodeFileLinkMeta(span, cwd); + if (meta) { + metaByText.set(span, meta); + } + } + return metaByText; + }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + const filePaths = [ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); + }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -1339,8 +1405,49 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); - const markdownComponents = useMemo( - () => ({ + const markdownComponents = useMemo(() => { + const fileLinkChip = ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + className?: string, + ) => { + const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); + const labelParts = [fileLinkMeta.basename]; + if (typeof parentSuffix === "string" && parentSuffix.length > 0) { + labelParts.push(parentSuffix); + } + if (fileLinkMeta.line) { + labelParts.push( + `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, + ); + } + + return ( + openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } + className={className} + /> + ); + }; + + return { p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1455,39 +1562,26 @@ function ChatMarkdown({ ); } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); + return fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, + ); + }, + code({ node, children, className, ...props }) { + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd); + if (fileLinkMeta) { + return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + } } - return ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> + + {children} + ); }, table({ node: _node, ...props }) { @@ -1524,22 +1618,23 @@ function ChatMarkdown({ ); }, - }), - [ - diffThemeName, - fileLinkParentSuffixByPath, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - openInPreferredEditor, - openExternalLinkInPreview, - openMarkdownFileInPreview, - resolvedTheme, - skills, - text, - threadRef, - ], - ); + }; + }, [ + cwd, + diffThemeName, + fileLinkParentSuffixByPath, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + resolvedTheme, + skills, + text, + threadRef, + ]); return (
{ expect(items.map((item) => item.value)).toEqual(["thread:thread-active"]); }); }); + +describe("buildBrowseGroups", () => { + it("waits for asynchronous browse navigation actions", async () => { + let finishNavigation: (() => void) | undefined; + const browseTo = vi.fn( + () => + new Promise((resolve) => { + finishNavigation = resolve; + }), + ); + const groups = buildBrowseGroups({ + browseEntries: [{ name: "Downloads", fullPath: "/Users/test/Downloads" }], + browseQuery: "~/", + canBrowseUp: false, + upIcon: null, + directoryIcon: null, + browseUp: vi.fn(), + browseTo, + }); + const item = groups[0]?.items[0]; + if (!item || item.kind !== "action") { + throw new Error("Expected a browse action"); + } + + let actionSettled = false; + const action = item.run().then(() => { + actionSettled = true; + }); + await Promise.resolve(); + + expect(browseTo).toHaveBeenCalledWith("Downloads"); + expect(actionSettled).toBe(false); + + finishNavigation?.(); + await action; + expect(actionSettled).toBe(true); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..058322744bb 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,6 @@ import { - type KeybindingCommand, type FilesystemBrowseEntry, + type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; @@ -70,38 +70,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function filterBrowseEntries(input: { - browseEntries: ReadonlyArray; - browseFilterQuery: string; - highlightedItemValue: string | null; -}): { - filteredEntries: FilesystemBrowseEntry[]; - highlightedEntry: FilesystemBrowseEntry | null; - exactEntry: FilesystemBrowseEntry | null; -} { - const lowerFilter = input.browseFilterQuery.toLowerCase(); - const showHidden = input.browseFilterQuery.startsWith("."); - - const filteredEntries = input.browseEntries.filter( - (entry) => - entry.name.toLowerCase().startsWith(lowerFilter) && - (showHidden || !entry.name.startsWith(".")), - ); - - let highlightedEntry: FilesystemBrowseEntry | null = null; - if (input.highlightedItemValue?.startsWith("browse:")) { - const highlightedPath = input.highlightedItemValue.slice("browse:".length); - highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; - } - - const exactEntry = - input.browseFilterQuery.length > 0 - ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) - : null; - - return { filteredEntries, highlightedEntry, exactEntry }; -} - export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } @@ -302,8 +270,8 @@ export function buildBrowseGroups(input: { canBrowseUp: boolean; upIcon: ReactNode; directoryIcon: ReactNode; - browseUp: () => void; - browseTo: (name: string) => void; + browseUp: () => void | Promise; + browseTo: (name: string) => void | Promise; }): CommandPaletteGroup[] { const items: CommandPaletteActionItem[] = []; @@ -316,7 +284,7 @@ export function buildBrowseGroups(input: { icon: input.upIcon, keepOpen: true, run: async () => { - input.browseUp(); + await input.browseUp(); }, }); } @@ -330,7 +298,7 @@ export function buildBrowseGroups(input: { icon: input.directoryIcon, keepOpen: true, run: async () => { - input.browseTo(entry.name); + await input.browseTo(entry.name); }, }); } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b4e874017d4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,12 @@ "use client"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + canPreloadBrowsePath, + createBrowseNavigationCoordinator, + filterFilesystemBrowseEntries, + getFilesystemBrowsePath, +} from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, settlePromise, @@ -61,16 +67,12 @@ import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, - canNavigateUp, ensureBrowseDirectoryPath, findProjectByPath, getBrowseDirectoryPath, - getBrowseLeafPathSegment, - getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isExplicitRelativeProjectPath, - isFilesystemBrowseQuery, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, } from "../lib/projectPaths"; @@ -96,7 +98,6 @@ import { type CommandPaletteActionItem, type CommandPaletteSubmenuItem, type CommandPaletteView, - filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, getCommandPaletteMode, @@ -486,6 +487,10 @@ function OpenCommandPaletteDialog(props: { const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); + const loadBrowsePath = useAtomQueryRunner(filesystemEnvironment.browse, { + reportFailure: false, + reportDefect: false, + }); const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); @@ -502,6 +507,13 @@ function OpenCommandPaletteDialog(props: { const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); + const browseNavigationRef = useRef | null>( + null, + ); + if (browseNavigationRef.current === null) { + browseNavigationRef.current = createBrowseNavigationCoordinator(); + } + const browseNavigation = browseNavigationRef.current; const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -671,8 +683,12 @@ function OpenCommandPaletteDialog(props: { ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; - const isBrowsing = - !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const browsePath = useMemo( + () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), + [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + ); + const isBrowsing = browsePath.isBrowsing; + const browseDirectoryPath = browsePath.directoryPath; const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { @@ -710,20 +726,22 @@ function OpenCommandPaletteDialog(props: { browseEnvironmentId && currentProjectEnvironmentId === browseEnvironmentId ? currentProjectCwd : null; + const getBrowseCwdForEnvironment = useCallback( + (environmentId: EnvironmentId | null): string | null => + environmentId && currentProjectEnvironmentId === environmentId ? currentProjectCwd : null, + [currentProjectCwd, currentProjectEnvironmentId], + ); const relativePathNeedsActiveProject = isExplicitRelativeProjectPath(query.trim()) && currentProjectCwdForBrowse === null; - const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; - const browseFilterQuery = - isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; const browseQuery = useEnvironmentQuery( isBrowsing && - browseDirectoryPath.length > 0 && + browsePath.directoryPath.length > 0 && browseEnvironmentId !== null && !relativePathNeedsActiveProject ? filesystemEnvironment.browse({ environmentId: browseEnvironmentId, input: { - partialPath: browseDirectoryPath, + partialPath: browsePath.directoryPath, ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), }, }) @@ -732,9 +750,43 @@ function OpenCommandPaletteDialog(props: { const browseResult = browseQuery.data; const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; - const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( - () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), - [browseEntries, browseFilterQuery, highlightedItemValue], + const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( + () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery), + [browseEntries, browsePath.filterQuery], + ); + + const prefetchBrowsePath = useCallback( + async ( + partialPath: string, + environmentId: EnvironmentId | null = browseEnvironmentId, + cwd: string | null = currentProjectCwdForBrowse, + ): Promise => { + if (!environmentId) { + return; + } + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!canPreloadBrowsePath(environment?.connection.phase)) { + return; + } + + await loadBrowsePath({ + environmentId, + input: { + partialPath, + ...(cwd ? { cwd } : {}), + }, + }); + }, + [browseEnvironmentId, currentProjectCwdForBrowse, environments, loadBrowsePath], + ); + + useEffect( + () => () => { + browseNavigation.invalidate(); + }, + [browseNavigation], ); const openProjectFromSearch = useMemo( @@ -865,18 +917,22 @@ function OpenCommandPaletteDialog(props: { ); const recentThreadItems = allThreadItems.slice(0, RECENT_THREAD_LIMIT); - function pushPaletteView(view: CommandPaletteView): void { - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); - setHighlightedItemValue(null); - setQuery(view.initialQuery ?? ""); - } + const pushPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => [ + ...previousViews, + { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }, + ]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); function pushView(item: CommandPaletteSubmenuItem): void { pushPaletteView({ @@ -887,6 +943,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); @@ -897,6 +954,7 @@ function OpenCommandPaletteDialog(props: { } function handleQueryChange(nextQuery: string): void { + browseNavigation.invalidate(); setHighlightedItemValue(null); setQuery(nextQuery); if (nextQuery === "" && currentView?.initialQuery) { @@ -905,16 +963,35 @@ function OpenCommandPaletteDialog(props: { } const startAddProjectBrowse = useCallback( - (environmentId: EnvironmentId): void => { - setAddProjectEnvironmentId(environmentId); - setAddProjectCloneFlow(null); - pushPaletteView({ + async (environmentId: EnvironmentId): Promise => { + const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); + const initialBrowsePath = getBrowseDirectoryPath(initialQuery); + const browseCwd = getBrowseCwdForEnvironment(environmentId); + const view: CommandPaletteView = { addonIcon: , groups: [], - initialQuery: getAddProjectInitialQueryForEnvironment(environmentId), - }); + initialQuery, + }; + + await browseNavigation.run( + () => + initialBrowsePath.length > 0 + ? prefetchBrowsePath(initialBrowsePath, environmentId, browseCwd) + : Promise.resolve(), + () => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); + pushPaletteView(view); + }, + ); }, - [getAddProjectInitialQueryForEnvironment], + [ + browseNavigation, + getAddProjectInitialQueryForEnvironment, + getBrowseCwdForEnvironment, + prefetchBrowsePath, + pushPaletteView, + ], ); const startAddProjectClone = useCallback( @@ -927,7 +1004,7 @@ function OpenCommandPaletteDialog(props: { initialQuery: "", }); }, - [], + [pushPaletteView], ); const openSourceControlSettings = useCallback(() => { @@ -950,7 +1027,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(environmentId); + await startAddProjectBrowse(environmentId); }, }, ]; @@ -1043,7 +1120,12 @@ function OpenCommandPaletteDialog(props: { ), }); }, - [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], + [ + browseEnvironmentId, + buildAddProjectSourceGroups, + pushPaletteView, + sourceControlDiscovery.data, + ], ); const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( @@ -1098,6 +1180,7 @@ function OpenCommandPaletteDialog(props: { addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + pushPaletteView, startAddProjectSourceSelection, ]); @@ -1114,6 +1197,7 @@ function OpenCommandPaletteDialog(props: { return; } clearOpenIntent(); + browseNavigation.invalidate(); setAddProjectCloneFlow(null); setViewStack([]); setQuery(""); @@ -1139,10 +1223,12 @@ function OpenCommandPaletteDialog(props: { }); }, [ clearOpenIntent, + browseNavigation, currentProjectEnvironmentId, currentProjectId, openIntent, projectThreadItems, + pushPaletteView, ]); const actionItems: Array = []; @@ -1225,7 +1311,7 @@ function OpenCommandPaletteDialog(props: { icon: , keepOpen: true, run: async () => { - startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); + await startAddProjectBrowse(wslAddProjectEnvironmentOption.environmentId); }, }); } @@ -1541,23 +1627,36 @@ function OpenCommandPaletteDialog(props: { await handleAddProject(cloneResult.value.cwd); } - function browseTo(name: string): void { - const nextQuery = appendBrowsePathSegment(query, name); - setHighlightedItemValue(null); - setQuery(nextQuery); - setBrowseGeneration((generation) => generation + 1); - } + const browseTo = useCallback( + async (name: string): Promise => { + const nextQuery = appendBrowsePathSegment(query, name); + await browseNavigation.run( + () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)), + () => { + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + }, + ); + }, + [browseNavigation, prefetchBrowsePath, query], + ); - function browseUp(): void { - const parentPath = getBrowseParentPath(query); + const browseUp = useCallback(async (): Promise => { + const parentPath = browsePath.parentPath; if (parentPath === null) { return; } - setHighlightedItemValue(null); - setQuery(parentPath); - setBrowseGeneration((generation) => generation + 1); - } + await browseNavigation.run( + () => prefetchBrowsePath(parentPath), + () => { + setHighlightedItemValue(null); + setQuery(parentPath); + setBrowseGeneration((generation) => generation + 1); + }, + ); + }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]); // Resolve the add-project path from browse data when available. When the // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the @@ -1567,11 +1666,10 @@ function OpenCommandPaletteDialog(props: { ? (browseResult?.parentPath ?? query.trim()) : (exactBrowseEntry?.fullPath ?? query.trim()); - const canBrowseUp = - isBrowsing && !relativePathNeedsActiveProject && canNavigateUp(browseDirectoryPath); + const canBrowseUp = !relativePathNeedsActiveProject && browsePath.canBrowseUp; const browseGroups = buildBrowseGroups({ - browseEntries: filteredBrowseEntries, + browseEntries: visibleBrowseEntries, browseQuery: query, canBrowseUp, upIcon: , diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index d4d31af3682..532d546df9a 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -133,13 +133,13 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} {props.item.timestamp ? ( - + {props.item.timestamp} ) : null} {shortcutLabel ? {shortcutLabel} : null} {props.item.kind === "submenu" ? ( - + ) : null} ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 2142d898269..d10cb39f0e3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -877,7 +877,7 @@ export default function DiffPanel({
diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc3e8ee832f..201241731fa 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; +import { cn } from "~/lib/utils"; const loadedProjectFaviconSrcs = new Set(); @@ -40,7 +41,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ @@ -64,7 +65,11 @@ function ProjectFaviconImage({ { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b05b3a39d90..98b5dcf84ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2218,9 +2218,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
- - - - - } - > - - Search - {commandPaletteShortcutLabel ? ( - - {commandPaletteShortcutLabel} - - ) : null} - - - - + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + } + > {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index e5edcd99ae1..b9a65035897 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -1,9 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; import { renderToStaticMarkup } from "react-dom/server"; -import { StageBackdropArt, StageBackdropButtonArt } from "./SidebarStageBackdrop"; +import { + resolveEnvironmentIdentificationPillLabel, + resolveSidebarStageBackdropVariant, + StageBackdropArt, + StageBackdropButtonArt, +} from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { + it("resolves stage artwork only when enabled", () => { + expect(resolveSidebarStageBackdropVariant("Dev")).toBe("dev"); + expect(resolveSidebarStageBackdropVariant("Nightly")).toBe("nightly"); + expect(resolveSidebarStageBackdropVariant("Dev", false)).toBeNull(); + expect(resolveSidebarStageBackdropVariant("Alpha")).toBeNull(); + }); + + it("resolves supported environment pill labels", () => { + expect(resolveEnvironmentIdentificationPillLabel("Dev")).toBe("Dev"); + expect(resolveEnvironmentIdentificationPillLabel("nightly")).toBe("Nightly"); + expect(resolveEnvironmentIdentificationPillLabel("Latest")).toBeNull(); + expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); + }); + it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index b465a3214f8..b4ff7455918 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -9,6 +9,7 @@ import { SidebarStageDitherArt } from "~/custom/SidebarStageDitherArt"; /* fork:end fork-sidebar-chrome */ export type SidebarStageBackdropVariant = "nightly" | "dev"; +export type EnvironmentIdentificationPillLabel = "Dev" | "Nightly"; // A wide viewBox keeps the 96-unit art height at a fixed scale while sidebar resizing reveals // more horizontal canvas instead of zooming the scene. @@ -16,23 +17,36 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; export function resolveSidebarStageBackdropVariant( stageLabel: string, + enabled = true, ): SidebarStageBackdropVariant | null { + if (!enabled) return null; const normalized = stageLabel.trim().toLowerCase(); if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; return null; } -export function useSidebarStageBackdropVariant(): SidebarStageBackdropVariant | null { +export function resolveEnvironmentIdentificationPillLabel( + stageLabel: string, +): EnvironmentIdentificationPillLabel | null { + const normalized = stageLabel.trim().toLowerCase(); + if (normalized === "dev") return "Dev"; + if (normalized === "nightly") return "Nightly"; + return null; +} + +export function useEnvironmentStageLabel(): string { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - return resolveSidebarStageBackdropVariant( - resolveServerBackedAppStageLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }), - ); + return resolveServerBackedAppStageLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null { + return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); } /** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index eec49f7c89d..b8840aa6378 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -21,6 +21,7 @@ import { CircleAlertIcon, ClockIcon, CopyIcon, + FolderIcon, GitBranchIcon, MessageSquareIcon, PlusIcon, @@ -253,7 +254,11 @@ function WorkingDuration(props: { startedAt: string | null }) { return () => window.clearInterval(id); }, [startedMs]); if (Number.isNaN(startedMs)) return null; - return {formatWorkingDurationLabel(Date.now() - startedMs)}; + return ( + + {formatWorkingDurationLabel(Date.now() - startedMs)} + + ); } function SidebarV2ThreadTooltip({ @@ -291,33 +296,35 @@ function SidebarV2ThreadTooltip({ -
-
{thread.title}
-
+
+
+ {thread.title} +
+
{projectTitle ? (
-
{projectTitle}
+
{projectTitle}
) : null} {environmentLabel ? (
- -
{environmentLabel}
+ +
{environmentLabel}
) : null} {thread.branch ? (
- -
{thread.branch}
+ +
{thread.branch}
) : null} {/* fork:begin sidebar-v2-dev-server-pulse — see .fork/customizations.yaml#sidebar-v2-dev-server-pulse */} @@ -330,7 +337,7 @@ function SidebarV2ThreadTooltip({ {/* fork:end sidebar-v2-dev-server-pulse */} {branchMismatch ? (
- +
You're currently checked out on another branch.
@@ -341,15 +348,25 @@ function SidebarV2ThreadTooltip({ -
{modelLabel}
+
{modelLabel}
) : null} {thread.session?.lastError ? ( -
- -
{thread.session.lastError}
+
+ + {/* fork:begin sidebar-v2-error-tooltip — see .fork/customizations.yaml#sidebar-v2-error-tooltip + Upstream's restyle flattened this to the literal "Error + occurred", which removed the only place the sidebar surfaced + what actually failed — diagnosing a dead session meant opening + it. The message returns, wrapping rather than truncating: an + error's tail (exit codes, file paths) is routinely the useful + half, and this tooltip is already the row's overflow surface. */} +
+ {thread.session.lastError} +
+ {/* fork:end sidebar-v2-error-tooltip */}
) : null}
@@ -898,7 +915,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className={SIDEBAR_V2_SLIM_ROW_ACTION_CLASS} /* fork:end sidebar-v2-row-action-hit-area */ > - + ) : ( ) : null} @@ -2768,7 +2784,7 @@ export default function SidebarV2() { onClick={openAddProjectCommandPalette} className="inline-flex items-center gap-1.5 rounded-md border border-sidebar-border px-2.5 py-1 text-[11px] font-medium text-sidebar-muted-foreground transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - + Add project @@ -2788,48 +2804,52 @@ export default function SidebarV2() { }} > - + Project settings - - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 - ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` - : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + Manage project names, grouping rules, and environments. +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+ + + {member.workspaceRoot} + + + + + + {member.environmentLabel ?? "Current environment"} + + +
+ ))} +
{projectActionsTarget?.memberProjects.map((member) => (
-
- -
-
- -

- {member.environmentLabel ?? "Current environment"} -

-
-

- {member.workspaceRoot} -

-
-
-
+
-
- - -
+ {projectActionsTarget.memberProjects.length > 1 ? ( +
+ +
+ ) : null}
))}
@@ -2948,8 +2958,26 @@ export default function SidebarV2() {
) : null} - - + + {projectActionsTarget?.memberProjects.length === 1 ? ( + + ) : null} + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3254272767..94d086f5988 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -57,10 +57,8 @@ import { useEffectiveComposerModelState, } from "../../composerDraftStore"; import { - EMPTY_PROMPT_STASH_QUEUE, - MAX_STASH_ENTRIES_PER_QUEUE, + MAX_STASH_ENTRIES, partitionStashAttachments, - promptStashScopeKey, usePromptStashStore, type PromptStashEntry, } from "../../promptStashStore"; @@ -101,6 +99,7 @@ import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import { ComposerControl, ComposerControlIcon, ComposerSelectControl } from "./ComposerControl"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { @@ -173,7 +172,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ); } import { Button } from "../ui/button"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Select, SelectItem, SelectPopup, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { @@ -309,15 +308,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {props.interactionMode === "plan" ? ( - + ) : ( - + )} {props.interactionMode === "plan" ? "Plan" : "Build"} @@ -348,16 +345,9 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop onValueChange={(value) => props.onRuntimeModeChange(value!)} > - } + render={} > - + {runtimeModeOption.label} @@ -393,22 +383,21 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop } > - {props.planSidebarLabel} @@ -759,7 +748,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const clearComposerDraftPromptAndImages = useComposerDraftStore( (store) => store.clearComposerPromptAndImages, ); - const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); @@ -1936,23 +1924,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt stash (⌘S) // ------------------------------------------------------------------ - const stashScopeInstanceId = noProviderAvailable ? null : selectedInstanceId; - const stashScope = promptStashScopeKey(stashScopeInstanceId); - const stashQueue = usePromptStashStore( - (state) => state.queuesByScopeKey[stashScope] ?? EMPTY_PROMPT_STASH_QUEUE, - ); - const stashOtherScopesCount = usePromptStashStore((state) => - Object.entries(state.queuesByScopeKey).reduce( - (total, [key, queue]) => (key === stashScope ? total : total + queue.length), - 0, - ), - ); + // One global queue. Stashed prompts carry only text + images so they can be + // restored into any thread or provider — stash, switch, restore is the + // whole point. + const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); const finalizeStashEntryImages = usePromptStashStore((state) => state.finalizeEntryImages); - const stashProviderLabel = noProviderAvailable - ? "No provider" - : getProviderDisplayName(providerStatuses, selectedProvider); useEffect(() => { return () => { @@ -1978,10 +1956,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { // Remove first so a double activation (click + Enter) can't restore twice. - const { entry: taken, durable } = takeStashEntry( - promptStashScopeKey(entry.providerInstanceId), - entry.id, - ); + const { entry: taken, durable } = takeStashEntry(entry.id); if (!taken) return; if (!durable) { toastManager.add({ @@ -2044,21 +2019,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } } - const restorableSelection = - entry.modelSelection && - providerInstanceEntries.some( - (candidate) => - candidate.instanceId === entry.modelSelection?.instanceId && - candidate.enabled && - candidate.isAvailable, - ) - ? entry.modelSelection - : null; - if (restorableSelection) { - setComposerDraftModelSelection(composerDraftTarget, restorableSelection, { - replaceOptions: true, - }); - } + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. // Each cause gets its own sentence so "too large" is never blamed for a // file that actually failed to decode, or for one the composer simply @@ -2100,8 +2063,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, promptRef, - providerInstanceEntries, - setComposerDraftModelSelection, setComposerDraftPrompt, takeStashEntry, ], @@ -2109,7 +2070,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - const { durable } = takeStashEntry(promptStashScopeKey(entry.providerInstanceId), entry.id); + const { durable } = takeStashEntry(entry.id); if (!durable) { toastManager.add({ type: "warning", @@ -2145,30 +2106,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashTarget = composerDraftTarget; const entryId = randomUUID(); - const scopeKey = promptStashScopeKey(stashScopeInstanceId); try { // Persist the text-only entry *first*, then clear. Ordering matters in // both directions: writing before clearing means a crash or closed tab // mid-encode still leaves the prompt recoverable, while clearing before // the async image work means edits typed during encoding are not wiped. // Images are appended to the stored entry as they finish encoding. - const { evicted, durable } = stashEntryToQueue({ + const { evicted, written, durable } = stashEntryToQueue({ id: entryId, createdAt: new Date().toISOString(), prompt, attachments: [], - providerInstanceId: stashScopeInstanceId, - modelSelection: noProviderAvailable ? null : selectedModelSelection, droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, }); - // Clearing the composer is only safe once the entry is durable. If the - // write was rejected (quota, blocked storage) the store has already - // rolled itself back, so leave the composer untouched rather than - // making it the second casualty of a reload. - if (!durable) { + // Clearing the composer is only safe once the write actually landed. + // If it was rejected (quota) the store has already rolled itself back, + // so leave the composer untouched rather than making it the second + // casualty of a reload. + if (!written) { toastManager.add({ type: "error", title: "Could not stash this prompt", @@ -2178,6 +2136,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } + // Written but only into the in-memory fallback (localStorage blocked): + // the entry is visible and restorable this session, so proceed with the + // clear, but say it won't survive a reload. + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stashed prompt will not survive a reload", + description: + "Browser storage is unavailable, so this stash is kept in memory only for this session.", + data: { hideCopyButton: true }, + }); + } // Only the prompt and images are cleared — terminal/element contexts, // preview annotations, and review comments are not stashable, so @@ -2192,7 +2162,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toastManager.add({ type: "warning", title: "Oldest stashed prompt discarded", - description: `The ${stashProviderLabel} stash holds ${MAX_STASH_ENTRIES_PER_QUEUE} prompts; the oldest was removed to make room.`, + description: `The stash holds ${MAX_STASH_ENTRIES} prompts; the oldest was removed to make room.`, data: { hideCopyButton: true }, }); } @@ -2224,7 +2194,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { kept, droppedNames } = partitionStashAttachments(candidateAttachments); - const { attached, durable: imagesDurable } = finalizeStashEntryImages(scopeKey, entryId, { + const { attached, durable: imagesDurable } = finalizeStashEntryImages(entryId, { attachments: kept, droppedImageNames: [...oversizedImageNames, ...droppedNames], unreadableImageNames, @@ -2233,8 +2203,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // The second phase can be rejected on its own: the text-only entry // fit, but adding image payloads pushed past the quota. Disk would // then still hold the phase-one entry with pendingImageCount set, - // which reads as an orphan after reload — so say so now. - if (!imagesDurable && images.length > 0) { + // which reads as an orphan after reload — so say so now. Gated on the + // entry write having been durable: on the in-memory fallback nothing + // is ever durable, and the session-only warning already covered it. + if (!imagesDurable && durable && images.length > 0) { toastManager.add({ type: "warning", title: "Stashed images were not saved", @@ -2264,13 +2236,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerImagesRef, finalizeStashEntryImages, - noProviderAvailable, promptRef, pulseStashBadge, - selectedModelSelection, stashEntryToQueue, - stashProviderLabel, - stashScopeInstanceId, ]); const toggleStashMenu = useCallback(() => { @@ -3028,8 +2996,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen(false)} diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 3f8f56f041a..699c9cfd9c4 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -93,7 +93,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro {showCollapsedStackCap ? (
{item.icon} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9021b6b4609..73fc6348905 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -139,7 +139,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }} > -
+
{props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx new file mode 100644 index 00000000000..8eab75171c8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -0,0 +1,71 @@ +import type { ComponentProps } from "react"; +import { ChevronDownIcon, type LucideIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { SelectTrigger } from "../ui/select"; + +const composerControlClassName = + "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + +export function ComposerControl({ + className, + size = "sm", + variant = "ghost", + ...props +}: ComponentProps) { + return ( +
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..4407e621525 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -101,11 +101,11 @@ export function DraftHeroHeadline({ {activeProjectDisplayName ?? "Choose a project"} - + { @@ -138,7 +138,7 @@ export function DraftHeroHeadline({ diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 2ec4be70994..e86435561a2 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -50,7 +50,7 @@ export const ModelListRow = memo(function ModelListRow(props: { disabled={Boolean(props.disabledReason)} contentClassName="flex w-full items-center gap-3" className={cn( - "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", + "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]", "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index bbdbd8bd9d9..d317ee6061c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -43,6 +43,10 @@ type ModelPickerItem = { const EMPTY_MODEL_JUMP_LABELS = new Map(); +function ModelListSeparator() { + return
; +} + // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the // first colon — anything after that is the slug. @@ -521,7 +525,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -571,12 +575,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Search bar */} -
-
+
+
+ } value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} @@ -618,8 +622,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Model list */} -
- +
+ ref={modelListRef} data={filteredModelKeys} @@ -656,12 +660,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { estimatedItemSize={60} drawDistance={480} recycleItems + contentContainerClassName="pl-2 pr-px" + ItemSeparatorComponent={ModelListSeparator} onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "model-picker-list h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + showTopScrollFade && "model-picker-list-scroll-fade-top", + showBottomScrollFade && "model-picker-list-scroll-fade-bottom", )} /> diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 36a608888b2..24ec66cd614 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -78,34 +78,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { if (!content) { return; } - const selectedButton = Array.from( + const selectedItem = Array.from( content.querySelectorAll("[data-model-picker-provider]"), - ).find((button) => button.dataset.modelPickerProvider === props.selectedInstanceId); - if (!selectedButton) { + ).find((item) => item.dataset.modelPickerProvider === props.selectedInstanceId); + if (!selectedItem) { setSelectedIndicatorTop(null); return; } - const contentRect = content.getBoundingClientRect(); - const selectedButtonRect = selectedButton.getBoundingClientRect(); - setSelectedIndicatorTop( - selectedButtonRect.top - - contentRect.top + - content.scrollTop + - selectedButtonRect.height / 2 - - 10, - ); + setSelectedIndicatorTop(selectedItem.offsetTop + selectedItem.offsetHeight / 2 - 10); }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); return ( -
+
-
+
{selectedIndicatorTop !== null ? (
-
+ <> +
handleSelect("favorites")} type="button" - data-model-picker-provider="favorites" aria-label="Favorites" > @@ -146,7 +131,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
-
+